generated from dellevin/template
首次提交
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package com.fan.edgex
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.fan.edgex", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.fan.edgex.ui.compose
|
||||
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.ConditionStore
|
||||
import com.fan.edgex.config.configPrefs
|
||||
import com.fan.edgex.ui.MainActivity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class EdgeXComposeSmokeTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val appContext
|
||||
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Before
|
||||
fun clearUiTestPrefs() {
|
||||
appContext.configPrefs().edit()
|
||||
.remove(AppConfig.gestureAction("right_mid", "swipe_left"))
|
||||
.remove(AppConfig.gestureActionLabel("right_mid", "swipe_left"))
|
||||
.remove(AppConfig.UI_ACCENT)
|
||||
.remove(AppConfig.UI_DARK_MODE)
|
||||
.remove(AppConfig.CUSTOM_PANEL_COLOR)
|
||||
.remove(AppConfig.SIDE_BAR_LEFT_COLOR)
|
||||
.remove(AppConfig.SIDE_BAR_RIGHT_COLOR)
|
||||
.commit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeShowsPrimaryEntryPoints() {
|
||||
composeRule.onNodeWithText("EdgeX").assertIsDisplayed()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_home_hero_title)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.header_pie_settings)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_about_support_author)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeTilesNavigateThroughComposeStack() {
|
||||
composeRule.onNodeWithTag("home_tile_theme").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.header_theme)).assertIsDisplayed()
|
||||
|
||||
composeRule.onNodeWithContentDescription(appContext.getString(R.string.compose_back)).assert(hasClickAction()).performClick()
|
||||
composeRule.onNodeWithText("EdgeX").performScrollTo().assertIsDisplayed()
|
||||
|
||||
composeRule.onNodeWithTag("home_tile_gestures").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_gestures_hero)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gestureSheetWritesDirectAction() {
|
||||
composeRule.onNodeWithTag("home_tile_gestures").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_view_list)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_zone_right_mid").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.gesture_swipe_left)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_action_back").performClick()
|
||||
|
||||
val prefs = appContext.configPrefs()
|
||||
assertEquals("back", prefs.getString(AppConfig.gestureAction("right_mid", "swipe_left"), null))
|
||||
assertEquals(appContext.getString(R.string.action_back), prefs.getString(AppConfig.gestureActionLabel("right_mid", "swipe_left"), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun themeControlsPersistAccentDarkModeAndCustomColor() {
|
||||
composeRule.onNodeWithTag("home_tile_theme").performScrollTo().performClick()
|
||||
|
||||
listOf("default", "classic", "cedar", "ocean", "ember").forEach { accent ->
|
||||
composeRule.onNodeWithTag("theme_accent_$accent").performClick()
|
||||
assertEquals(accent, appContext.configPrefs().getString(AppConfig.UI_ACCENT, null))
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag("theme_dark_mode").performScrollTo().performClick()
|
||||
assertNotNull(appContext.configPrefs().getString(AppConfig.UI_DARK_MODE, null))
|
||||
|
||||
composeRule.onNodeWithTag("theme_custom_apply").performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun panelThemeColorRowsPickResetAndPersistIndependently() {
|
||||
composeRule.onNodeWithTag("home_tile_custom_panel").performScrollTo().performClick()
|
||||
composeRule.onNodeWithTag("custom_panel_color_settings").performScrollTo().assertIsDisplayed()
|
||||
composeRule.onNodeWithTag("custom_panel_color_setting").performClick()
|
||||
onView(withText(android.R.string.ok)).perform(click())
|
||||
val customPanelColor = appContext.configPrefs()
|
||||
.getString(AppConfig.CUSTOM_PANEL_COLOR, null)
|
||||
assertNotNull(customPanelColor)
|
||||
|
||||
composeRule.onNodeWithTag("custom_panel_color_setting").performClick()
|
||||
onView(withText(R.string.compose_panel_color_follow_theme)).perform(click())
|
||||
assertEquals("", appContext.configPrefs().getString(AppConfig.CUSTOM_PANEL_COLOR, null))
|
||||
|
||||
composeRule.onNodeWithContentDescription(appContext.getString(R.string.compose_back)).performClick()
|
||||
composeRule.onNodeWithTag("home_tile_side_bar").performScrollTo().performClick()
|
||||
composeRule.onNodeWithTag("side_bar_left_color_settings").performScrollTo().assertIsDisplayed()
|
||||
composeRule.onNodeWithTag("side_bar_left_color_setting").performClick()
|
||||
onView(withText(android.R.string.ok)).perform(click())
|
||||
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_edge_right_short)).performClick()
|
||||
composeRule.onNodeWithTag("side_bar_right_color_settings").performScrollTo().assertIsDisplayed()
|
||||
composeRule.onNodeWithTag("side_bar_right_color_setting").performClick()
|
||||
onView(withText(android.R.string.ok)).perform(click())
|
||||
|
||||
assertEquals("", appContext.configPrefs().getString(AppConfig.CUSTOM_PANEL_COLOR, null))
|
||||
assertNotNull(appContext.configPrefs().getString(AppConfig.SIDE_BAR_LEFT_COLOR, null))
|
||||
assertNotNull(appContext.configPrefs().getString(AppConfig.SIDE_BAR_RIGHT_COLOR, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freezerTabsAndSearchRenderEmptyState() {
|
||||
composeRule.onNodeWithTag("home_tile_freezer").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_filter_all)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_app_frozen)).performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_filter_active)).performClick()
|
||||
|
||||
composeRule.onNodeWithTag("freezer_search").performTextInput("zzzz-no-such-package")
|
||||
waitUntilTextExists(appContext.getString(R.string.compose_no_matching_apps))
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_no_matching_apps)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conditionPickerDisplaysAndSaves() {
|
||||
composeRule.onNodeWithTag("home_tile_gestures").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_view_list)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_zone_right_mid").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.gesture_swipe_left)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_action_condition").performScrollTo().performClick()
|
||||
|
||||
// Tap the IF row
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_label_if)).performClick()
|
||||
|
||||
// Scroll to and tap on a condition at the bottom (e.g. Screen landscape) to verify scrolling works
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_screen_landscape))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
// Verify the condition label was updated in the ConditionSheet
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_screen_landscape))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foregroundAppConditionConfiguresSearchSelectionAndRestoresState() {
|
||||
composeRule.onNodeWithTag("home_tile_gestures").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_view_list)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_zone_right_mid").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.gesture_swipe_left)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_action_condition").performScrollTo().performClick()
|
||||
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_label_if)).performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_foreground_app)).performClick()
|
||||
composeRule.onNodeWithTag("foreground_app_condition_sheet").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag("foreground_app_save").assertIsNotEnabled()
|
||||
|
||||
composeRule.onNodeWithTag("foreground_app_search").performTextInput(appContext.packageName)
|
||||
waitUntilTagExists("foreground_app_package_${appContext.packageName}")
|
||||
composeRule.onNodeWithTag("foreground_app_package_${appContext.packageName}").performClick()
|
||||
composeRule.onNodeWithTag("foreground_app_save").assertIsEnabled().performClick()
|
||||
|
||||
val expectedSummary = appContext.getString(
|
||||
R.string.cond_foreground_summary,
|
||||
appContext.getString(R.string.cond_foreground_app),
|
||||
1,
|
||||
)
|
||||
composeRule.onNodeWithText(expectedSummary).assertIsDisplayed()
|
||||
|
||||
val action = appContext.configPrefs().getString(
|
||||
AppConfig.gestureAction("right_mid", "swipe_left"),
|
||||
null,
|
||||
).orEmpty()
|
||||
val conditionId = requireNotNull(ConditionStore.extractId(action))
|
||||
assertEquals(
|
||||
setOf(appContext.packageName),
|
||||
ConditionStore.decodePackageNames(
|
||||
appContext.configPrefs().getString(ConditionStore.foregroundPackagesKey(conditionId), null).orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_label_if)).performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_foreground_app)).performClick()
|
||||
waitUntilTagExists("foreground_app_checkbox_${appContext.packageName}")
|
||||
composeRule.onNodeWithTag("foreground_app_checkbox_${appContext.packageName}").assertIsOn()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conditionThenElseShellCommandPickerFlow() {
|
||||
composeRule.onNodeWithTag("home_tile_gestures").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_view_list)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_zone_right_mid").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.gesture_swipe_left)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_action_condition").performScrollTo().performClick()
|
||||
|
||||
// Tap the THEN row
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.cond_label_then)).performClick()
|
||||
|
||||
// Click Shell Command
|
||||
composeRule.onNodeWithTag("gesture_action_shell_command").performScrollTo().performClick()
|
||||
|
||||
// Verify Shell Command configuration sheet is immediately shown
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.label_run_as_root)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subGestureShellCommandPickerFlow() {
|
||||
composeRule.onNodeWithTag("home_tile_gestures").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.compose_view_list)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_zone_right_mid").performScrollTo().performClick()
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.gesture_swipe_left)).performClick()
|
||||
composeRule.onNodeWithTag("gesture_action_sub_gesture").performScrollTo().performClick()
|
||||
|
||||
// Tap Hold direction
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.sub_gesture_hold)).performClick()
|
||||
|
||||
// Click Shell Command
|
||||
composeRule.onNodeWithTag("gesture_action_shell_command").performScrollTo().performClick()
|
||||
|
||||
// Verify Shell Command configuration sheet is immediately shown
|
||||
composeRule.onNodeWithText(appContext.getString(R.string.label_run_as_root)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun waitUntilTextExists(text: String) {
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitUntilTagExists(tag: String) {
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
182
app/src/main/AndroidManifest.xml
Normal file
182
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,182 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.EdgeX">
|
||||
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="Gesture Control Module" />
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="82" />
|
||||
<meta-data
|
||||
android:name="xposedscope"
|
||||
android:value="android" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.GesturesActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.FreezerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX"
|
||||
android:windowSoftInputMode="adjustResize"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.ThemeActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ActionSelectionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ShortcutSelectionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ShellCommandActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.KeysActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.SubGestureActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSelectionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.MusicControlActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.FastScrollActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.PieSettingsActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.EdgeLightingSettingsActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.EdgeLightingAppFilterActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.MultiActionsListActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.MultiActionEditActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppIconPickerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.PanelConfigActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ConditionActionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ConditionPickerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.PremiumActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.EdgeX" />
|
||||
|
||||
<receiver
|
||||
android:name=".config.ConfigSnapshotReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".config.ShellExecutorService"
|
||||
android:exported="true" />
|
||||
|
||||
<service
|
||||
android:name=".license.KeystoreVerifierService"
|
||||
android:exported="true" />
|
||||
|
||||
<service
|
||||
android:name=".service.NotificationEdgeService"
|
||||
android:label="@string/edge_lighting_notification_service"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
5
app/src/main/aidl/com/fan/edgex/IKeystoreVerifier.aidl
Normal file
5
app/src/main/aidl/com/fan/edgex/IKeystoreVerifier.aidl
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.fan.edgex;
|
||||
|
||||
interface IKeystoreVerifier {
|
||||
byte[] sign(in byte[] challenge);
|
||||
}
|
||||
5
app/src/main/aidl/com/fan/edgex/IShellCallback.aidl
Normal file
5
app/src/main/aidl/com/fan/edgex/IShellCallback.aidl
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.fan.edgex;
|
||||
|
||||
oneway interface IShellCallback {
|
||||
void onResult(boolean success, String output);
|
||||
}
|
||||
9
app/src/main/aidl/com/fan/edgex/IShellExecutor.aidl
Normal file
9
app/src/main/aidl/com/fan/edgex/IShellExecutor.aidl
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.fan.edgex;
|
||||
|
||||
import com.fan.edgex.IShellCallback;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
oneway interface IShellExecutor {
|
||||
void execute(String command, boolean runAsRoot, IShellCallback callback);
|
||||
void savePngToGallery(in ParcelFileDescriptor png, String displayName, IShellCallback callback);
|
||||
}
|
||||
1
app/src/main/assets/xposed_init
Normal file
1
app/src/main/assets/xposed_init
Normal file
@@ -0,0 +1 @@
|
||||
com.fan.edgex.hook.MainHook
|
||||
14
app/src/main/java/com/fan/edgex/App.kt
Normal file
14
app/src/main/java/com/fan/edgex/App.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.fan.edgex
|
||||
|
||||
import android.app.Application
|
||||
import com.topjohnwu.superuser.Shell
|
||||
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Shell.setDefaultBuilder(
|
||||
Shell.Builder.create()
|
||||
.setTimeout(10),
|
||||
)
|
||||
}
|
||||
}
|
||||
183
app/src/main/java/com/fan/edgex/action/AppActionExecutor.kt
Normal file
183
app/src/main/java/com/fan/edgex/action/AppActionExecutor.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package com.fan.edgex.action
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
import android.widget.Toast
|
||||
import com.fan.edgex.config.MultiActionStep
|
||||
import com.topjohnwu.superuser.Shell
|
||||
|
||||
/**
|
||||
* Executes actions using standard Android APIs available in any process context.
|
||||
* Used by both GestureActionDispatcher (system_server) and the UI (app process).
|
||||
*
|
||||
* Actions that require Xposed / system_server privileges (back, lock_screen, kill_app,
|
||||
* screenshot, etc.) return false from execute() and must be handled by the caller.
|
||||
*/
|
||||
object AppActionExecutor {
|
||||
|
||||
/**
|
||||
* Execute an action. Returns true if handled, false if caller must handle it
|
||||
* (e.g. system_server-only actions).
|
||||
*/
|
||||
fun execute(context: Context, code: String): Boolean = when {
|
||||
code == "volume_up" -> { adjustVolume(context, true); true }
|
||||
code == "volume_down" -> { adjustVolume(context, false); true }
|
||||
code == "brightness_up" -> { adjustBrightness(context, true); true }
|
||||
code == "brightness_down" -> { adjustBrightness(context, false); true }
|
||||
code.startsWith("music_control:") -> { dispatchMusicControl(context, code); true }
|
||||
code == "home" -> { launchHome(context); true }
|
||||
code == "recents" || code == "recent" -> { toggleRecents(context); true }
|
||||
code == "expand_notifications" || code == "notifications" -> { expandNotifications(context); true }
|
||||
code.startsWith("launch_app:") -> { launchApp(context, code); true }
|
||||
code.startsWith("app_shortcut:") -> { launchShortcut(context, code); true }
|
||||
code.startsWith("shell:") -> { executeShell(context, code); true }
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun adjustVolume(context: Context, up: Boolean) {
|
||||
try {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.adjustStreamVolume(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
if (up) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER,
|
||||
AudioManager.FLAG_SHOW_UI,
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun adjustBrightness(context: Context, up: Boolean) {
|
||||
try {
|
||||
val dm = context.getSystemService("display") as android.hardware.display.DisplayManager
|
||||
val get = android.hardware.display.DisplayManager::class.java.getMethod("getBrightness", Int::class.java)
|
||||
val set = android.hardware.display.DisplayManager::class.java.getMethod("setBrightness", Int::class.java, Float::class.java)
|
||||
val current = get.invoke(dm, 0) as Float
|
||||
val step = 1.0f / 16f
|
||||
set.invoke(dm, 0, if (up) minOf(1.0f, current + step) else maxOf(0.0f, current - step))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun dispatchMusicControl(context: Context, action: String) {
|
||||
try {
|
||||
val keyCode = when (action.removePrefix("music_control:")) {
|
||||
"play_pause" -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
"stop" -> KeyEvent.KEYCODE_MEDIA_STOP
|
||||
"next" -> KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
"previous" -> KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
else -> return
|
||||
}
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val now = SystemClock.uptimeMillis()
|
||||
am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
am.dispatchMediaKeyEvent(KeyEvent(now, now + 10, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun launchHome(context: Context) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_MAIN).apply {
|
||||
addCategory(Intent.CATEGORY_HOME)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleRecents(context: Context) {
|
||||
runCatching {
|
||||
val sb = context.getSystemService("statusbar") ?: return
|
||||
Class.forName("android.app.StatusBarManager").getMethod("toggleRecentApps").invoke(sb)
|
||||
}
|
||||
}
|
||||
|
||||
private fun expandNotifications(context: Context) {
|
||||
runCatching {
|
||||
val sb = context.getSystemService("statusbar") ?: return
|
||||
Class.forName("android.app.StatusBarManager").getMethod("expandNotificationsPanel").invoke(sb)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchApp(context: Context, action: String) {
|
||||
runCatching {
|
||||
val pkg = action.removePrefix("launch_app:").takeIf { it.isNotBlank() } ?: return
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg) ?: return
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a list of steps sequentially with action-type-aware delays between them.
|
||||
* Uses a main-thread Handler so callers don't need to manage one.
|
||||
*/
|
||||
fun executeSteps(context: Context, steps: List<MultiActionStep>, handler: Handler = Handler(Looper.getMainLooper())) {
|
||||
var delay = 0L
|
||||
for (step in steps) {
|
||||
if (step.code.isBlank() || step.code == "none") continue
|
||||
val code = step.code
|
||||
handler.postDelayed({
|
||||
runCatching { execute(context, code) }
|
||||
}, delay)
|
||||
delay += stepSettleDuration(code)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait after dispatching [code] before the next step is safe to fire.
|
||||
* Matches the delays used in GestureActionDispatcher for consistency.
|
||||
*/
|
||||
fun stepSettleDuration(code: String): Long = when {
|
||||
code == "home" || code == "back" || code == "recent" || code == "recents"
|
||||
|| code == "lock_screen" || code == "notifications" || code == "expand_notifications"
|
||||
|| code == "quick_settings" -> 600L
|
||||
code.startsWith("launch_app:") || code.startsWith("app_shortcut:") -> 500L
|
||||
code == "screenshot" || code == "clear_background" || code == "refreeze" -> 300L
|
||||
code == "prev_app" || code == "next_app" -> 500L
|
||||
else -> 150L
|
||||
}
|
||||
|
||||
private fun executeShell(context: Context, action: String) {
|
||||
val content = action.removePrefix("shell:")
|
||||
val parts = content.split(":", limit = 2)
|
||||
if (parts.size != 2) return
|
||||
val runAsRoot = parts[0] == "true"
|
||||
val command = parts[1].takeIf { it.isNotBlank() } ?: return
|
||||
Thread {
|
||||
try {
|
||||
val (success, output) = if (runAsRoot) {
|
||||
val result = Shell.cmd(command).exec()
|
||||
result.isSuccess to (if (result.isSuccess) result.out else result.err)
|
||||
.joinToString("\n").trim()
|
||||
} else {
|
||||
val process = ProcessBuilder("sh", "-c", command)
|
||||
.redirectErrorStream(true).start()
|
||||
process.outputStream.close()
|
||||
val out = process.inputStream.bufferedReader().readText().trim()
|
||||
(process.waitFor() == 0) to out
|
||||
}
|
||||
val msg = output.take(200).ifBlank { if (success) "OK" else "Failed" }
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, e.message ?: "Error", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun launchShortcut(context: Context, action: String) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return
|
||||
val parts = action.split(":", limit = 3)
|
||||
if (parts.size != 3) return
|
||||
val la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as android.content.pm.LauncherApps
|
||||
la.startShortcut(parts[1], parts[2], null, null, android.os.Process.myUserHandle())
|
||||
}
|
||||
}
|
||||
}
|
||||
145
app/src/main/java/com/fan/edgex/config/AppConfig.kt
Normal file
145
app/src/main/java/com/fan/edgex/config/AppConfig.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
object AppConfig {
|
||||
const val PREFS_NAME = "config"
|
||||
|
||||
// Top-level flags
|
||||
const val GESTURES_ENABLED = "gestures_enabled"
|
||||
const val KEYS_ENABLED = "keys_enabled"
|
||||
const val DEBUG_MATRIX = "debug_matrix_enabled"
|
||||
const val FREEZER_ARC_DRAWER = "freezer_arc_drawer_enabled"
|
||||
const val FREEZER_APP_LIST = "freezer_app_list"
|
||||
const val HAS_MIGRATED_FREEZER_LIST = "has_migrated_freezer_list"
|
||||
const val THEME_PRESET = "theme_preset"
|
||||
const val THEME_CUSTOM_COLOR = "theme_custom_color"
|
||||
const val UI_ACCENT = "ui_accent"
|
||||
const val UI_DARK_MODE = "ui_dark_mode"
|
||||
const val UI_DENSITY = "ui_density"
|
||||
const val HAPTIC_FEEDBACK = "haptic_feedback_enabled"
|
||||
const val HAPTIC_FEEDBACK_TYPE = "haptic_feedback_type"
|
||||
const val EDGE_LIGHTING_ENABLED = "edge_lighting_enabled"
|
||||
const val EDGE_LIGHTING_AUTO_COLOR = "edge_lighting_auto_color"
|
||||
const val EDGE_LIGHTING_COLOR = "edge_lighting_color"
|
||||
const val EDGE_LIGHTING_WIDTH_DP = "edge_lighting_width_dp"
|
||||
const val EDGE_LIGHTING_DURATION_MS = "edge_lighting_duration_ms"
|
||||
const val EDGE_LIGHTING_ALPHA = "edge_lighting_alpha"
|
||||
const val EDGE_LIGHTING_APP_LIST = "edge_lighting_app_list"
|
||||
const val EDGE_LIGHTING_EFFECT = "edge_lighting_effect"
|
||||
const val EDGE_LIGHTING_EFFECT_BASIC = "basic"
|
||||
const val EDGE_LIGHTING_EFFECT_BREATHING = "breathing"
|
||||
const val EDGE_LIGHTING_EFFECT_FLOW = "flow"
|
||||
const val EDGE_LIGHTING_EFFECT_MULTICOLOR = "multicolor"
|
||||
const val EDGE_LIGHTING_EFFECT_SPOTLIGHT = "spotlight"
|
||||
const val EDGE_LIGHTING_EFFECT_ECLIPSE = "eclipse"
|
||||
const val EDGE_LIGHTING_EFFECT_ECHO = "echo"
|
||||
const val EDGE_LIGHTING_EFFECT_COMET = "comet"
|
||||
const val EDGE_LIGHTING_EFFECT_RIPPLE = "ripple"
|
||||
|
||||
const val HAPTIC_FEEDBACK_TYPE_CLICK = "click"
|
||||
const val HAPTIC_FEEDBACK_TYPE_TICK = "tick"
|
||||
const val HAPTIC_FEEDBACK_TYPE_HEAVY_CLICK = "heavy_click"
|
||||
const val HAPTIC_FEEDBACK_TYPE_DOUBLE_CLICK = "double_click"
|
||||
|
||||
const val CUSTOM_PANEL_ACTION = "custom_panel"
|
||||
const val SIDE_BAR_LEFT_ACTION = "side_bar:left"
|
||||
const val SIDE_BAR_RIGHT_ACTION = "side_bar:right"
|
||||
const val CUSTOM_PANEL_ROWS = 4
|
||||
const val CUSTOM_PANEL_COLUMNS = 4
|
||||
const val SIDE_BAR_SLOTS = 7
|
||||
const val CUSTOM_PANEL_COLOR = "custom_panel_color"
|
||||
const val SIDE_BAR_LEFT_COLOR = "side_bar_left_color"
|
||||
const val SIDE_BAR_RIGHT_COLOR = "side_bar_right_color"
|
||||
|
||||
val ZONES = listOf(
|
||||
"left_top",
|
||||
"left_mid",
|
||||
"left_bottom",
|
||||
"left",
|
||||
"right_top",
|
||||
"right_mid",
|
||||
"right_bottom",
|
||||
"right",
|
||||
"top_left",
|
||||
"top_mid",
|
||||
"top_right",
|
||||
"top",
|
||||
"bottom_left",
|
||||
"bottom_mid",
|
||||
"bottom_right",
|
||||
"bottom",
|
||||
)
|
||||
val GESTURES = listOf("click", "double_click", "long_press", "swipe_up", "swipe_down", "swipe_left", "swipe_right")
|
||||
val KEY_TRIGGERS = listOf("click", "double_click", "long_press")
|
||||
|
||||
const val SUB_GESTURE_ACTION = "sub_gesture"
|
||||
val SUB_GESTURE_DIRECTIONS = listOf("hold", "swipe_left", "swipe_right", "swipe_up", "swipe_down")
|
||||
|
||||
fun subGestureChildKey(parentKey: String, direction: String) = "${parentKey}_sub_${direction}"
|
||||
|
||||
const val PIE_ACTION = "pie"
|
||||
const val PARTIAL_SCREENSHOT_ACTION = "partial_screenshot"
|
||||
const val PIE_RINGS = 2
|
||||
const val PIE_SLOTS_PER_RING = 6
|
||||
const val PIE_SIZE_SCALE = "pie_size_scale"
|
||||
const val PIE_COLOR = "pie_color"
|
||||
const val PIE_SIZE_SCALE_DEFAULT = 1.0f
|
||||
val PIE_EDGES = listOf("left", "right", "top", "bottom")
|
||||
|
||||
fun pieSlot(edge: String, ring: Int, slot: Int) = "pie_${edge}_ring${ring}_slot${slot}"
|
||||
fun pieSlotLabel(edge: String, ring: Int, slot: Int) = "pie_${edge}_ring${ring}_slot${slot}_label"
|
||||
|
||||
fun zoneEnabled(zone: String) = "zone_enabled_$zone"
|
||||
fun gestureAction(zone: String, gesture: String) = "${zone}_${gesture}"
|
||||
fun gestureActionLabel(zone: String, gesture: String) = "${zone}_${gesture}_label"
|
||||
fun keyEnabled(keyCode: Int) = "key_enabled_$keyCode"
|
||||
fun keyAction(keyCode: Int, trigger: String) = "key_${keyCode}_$trigger"
|
||||
fun keyActionLabel(keyCode: Int, trigger: String) = "key_${keyCode}_${trigger}_label"
|
||||
fun customPanelSlot(row: Int, column: Int) = "custom_panel_${row}_${column}"
|
||||
fun customPanelSlotTitle(row: Int, column: Int) = "custom_panel_${row}_${column}_title"
|
||||
fun sideBarSlot(side: String, index: Int) = "side_bar_${side}_$index"
|
||||
fun sideBarSlotTitle(side: String, index: Int) = "side_bar_${side}_${index}_title"
|
||||
|
||||
const val DEFAULT_SPLIT_FIRST_PERCENT = 33
|
||||
const val DEFAULT_SPLIT_SECOND_PERCENT = 66
|
||||
const val MIN_SEGMENT_PERCENT = 10
|
||||
const val DEFAULT_THICKNESS_DP = 16
|
||||
const val MIN_THICKNESS_DP = 8
|
||||
const val MAX_THICKNESS_DP = 32
|
||||
|
||||
fun zoneSplitFirstPercentKey(edge: String) = "zone_split_${edge}_first_percent"
|
||||
fun zoneSplitSecondPercentKey(edge: String) = "zone_split_${edge}_second_percent"
|
||||
fun zoneThicknessKey(zone: String) = "zone_thickness_${zone}_dp"
|
||||
|
||||
fun fallbackEdgeZone(zone: String): String? =
|
||||
when (zone) {
|
||||
"left_top", "left_mid", "left_bottom" -> "left"
|
||||
"right_top", "right_mid", "right_bottom" -> "right"
|
||||
"top_left", "top_mid", "top_right" -> "top"
|
||||
"bottom_left", "bottom_mid", "bottom_right" -> "bottom"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isActiveActionValue(value: String): Boolean =
|
||||
value.isNotBlank() && value != "none"
|
||||
|
||||
fun gestureActionParts(key: String): Pair<String, String>? {
|
||||
val gesture = GESTURES.sortedByDescending(String::length)
|
||||
.firstOrNull { key.endsWith("_$it") }
|
||||
?: return null
|
||||
val zone = key.removeSuffix("_$gesture")
|
||||
return if (zone in ZONES) zone to gesture else null
|
||||
}
|
||||
|
||||
fun keyActionParts(key: String): Pair<Int, String>? {
|
||||
if (!key.startsWith("key_")) return null
|
||||
val trigger = KEY_TRIGGERS.sortedByDescending(String::length)
|
||||
.firstOrNull { key.endsWith("_$it") }
|
||||
?: return null
|
||||
val keyCode = key
|
||||
.removePrefix("key_")
|
||||
.removeSuffix("_$trigger")
|
||||
.toIntOrNull()
|
||||
?: return null
|
||||
return keyCode to trigger
|
||||
}
|
||||
}
|
||||
132
app/src/main/java/com/fan/edgex/config/ConditionStore.kt
Normal file
132
app/src/main/java/com/fan/edgex/config/ConditionStore.kt
Normal file
@@ -0,0 +1,132 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
object ConditionStore {
|
||||
|
||||
const val FOREGROUND_APP = "foreground_app"
|
||||
|
||||
fun condIfKey(id: String) = "cond_${id}_if"
|
||||
fun condIfLabelKey(id: String) = "cond_${id}_if_label"
|
||||
fun condThenKey(id: String) = "cond_${id}_then"
|
||||
fun condThenLabelKey(id: String) = "cond_${id}_then_label"
|
||||
fun condElseKey(id: String) = "cond_${id}_else"
|
||||
fun condElseLabelKey(id: String) = "cond_${id}_else_label"
|
||||
fun foregroundPackagesKey(id: String) = "cond_${id}_foreground_packages"
|
||||
|
||||
fun extractId(actionCode: String): String? {
|
||||
if (!actionCode.startsWith("condition:")) return null
|
||||
return actionCode.removePrefix("condition:").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun buildActionCode(id: String) = "condition:$id"
|
||||
|
||||
fun encodePackageNames(packageNames: Collection<String>): String =
|
||||
normalizePackageNames(packageNames).joinToString(prefix = "[", postfix = "]") { value ->
|
||||
buildString {
|
||||
append('"')
|
||||
value.forEach { char ->
|
||||
when (char) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\b' -> append("\\b")
|
||||
'\u000C' -> append("\\f")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> if (char.code < 0x20) {
|
||||
append("\\u%04x".format(char.code))
|
||||
} else {
|
||||
append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
}
|
||||
|
||||
fun decodePackageNames(rawValue: String): Set<String> {
|
||||
val value = rawValue.trim()
|
||||
if (value.isEmpty()) return emptySet()
|
||||
val decoded = if (value.startsWith("[")) {
|
||||
parseJsonStringArray(value) ?: return emptySet()
|
||||
} else {
|
||||
value.split(',')
|
||||
}
|
||||
return normalizePackageNames(decoded).toCollection(linkedSetOf())
|
||||
}
|
||||
|
||||
private fun normalizePackageNames(packageNames: Collection<String>): List<String> =
|
||||
packageNames.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList()
|
||||
|
||||
private fun parseJsonStringArray(value: String): List<String>? {
|
||||
var index = 0
|
||||
fun skipWhitespace() {
|
||||
while (index < value.length && value[index].isWhitespace()) index++
|
||||
}
|
||||
fun readString(): String? {
|
||||
if (index >= value.length || value[index] != '"') return null
|
||||
index++
|
||||
val result = StringBuilder()
|
||||
while (index < value.length) {
|
||||
val char = value[index++]
|
||||
when (char) {
|
||||
'"' -> return result.toString()
|
||||
'\\' -> {
|
||||
if (index >= value.length) return null
|
||||
when (val escaped = value[index++]) {
|
||||
'"', '\\', '/' -> result.append(escaped)
|
||||
'b' -> result.append('\b')
|
||||
'f' -> result.append('\u000C')
|
||||
'n' -> result.append('\n')
|
||||
'r' -> result.append('\r')
|
||||
't' -> result.append('\t')
|
||||
'u' -> {
|
||||
if (index + 4 > value.length) return null
|
||||
val codePoint = value.substring(index, index + 4).toIntOrNull(16) ?: return null
|
||||
result.append(codePoint.toChar())
|
||||
index += 4
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
else -> if (char.code < 0x20) return null else result.append(char)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
skipWhitespace()
|
||||
if (index >= value.length || value[index++] != '[') return null
|
||||
skipWhitespace()
|
||||
if (index < value.length && value[index] == ']') {
|
||||
index++
|
||||
skipWhitespace()
|
||||
return emptyList<String>().takeIf { index == value.length }
|
||||
}
|
||||
val result = mutableListOf<String>()
|
||||
while (index < value.length) {
|
||||
skipWhitespace()
|
||||
result += readString() ?: return null
|
||||
skipWhitespace()
|
||||
when {
|
||||
index >= value.length -> return null
|
||||
value[index] == ',' -> index++
|
||||
value[index] == ']' -> {
|
||||
index++
|
||||
skipWhitespace()
|
||||
return result.takeIf { index == value.length }
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
data class ForegroundAppConditionConfig(
|
||||
val packageNames: Set<String>,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class ConfigSnapshotReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
HookConfigSnapshot.ACTION_CONFIG_SNAPSHOT_REQUEST -> context.broadcastFullConfigSnapshot()
|
||||
HookConfigSnapshot.ACTION_HOOK_STATUS_RESPONSE -> {
|
||||
val activeAt = intent.getLongExtra(HookConfigSnapshot.EXTRA_HOOK_ACTIVE_AT, 0L)
|
||||
if (activeAt > 0L) {
|
||||
ModuleActivationState.markActive(context, activeAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
app/src/main/java/com/fan/edgex/config/ConfigStore.kt
Normal file
177
app/src/main/java/com/fan/edgex/config/ConfigStore.kt
Normal file
@@ -0,0 +1,177 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
// UI-side config access. All writes go through here so the hook is always notified.
|
||||
// Values are stored as strings to keep the hook snapshot schema stable across releases.
|
||||
|
||||
fun Context.configPrefs(): SharedPreferences =
|
||||
getSharedPreferences(AppConfig.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun Context.putConfig(key: String, value: String) {
|
||||
val changedValues = runtimeValuesAfterChange(mapOf(key to value))
|
||||
configPrefs().edit {
|
||||
changedValues.forEach { (changedKey, changedValue) ->
|
||||
putString(changedKey, changedValue)
|
||||
}
|
||||
}
|
||||
notifyConfigChanged(changedValues)
|
||||
}
|
||||
|
||||
fun Context.putConfig(key: String, value: Boolean) = putConfig(key, value.toString())
|
||||
|
||||
fun Context.putConfigsSync(vararg entries: Pair<String, String>): Boolean {
|
||||
if (entries.isEmpty()) return true
|
||||
|
||||
val changedValues = runtimeValuesAfterChange(entries.toMap())
|
||||
val committed = configPrefs().edit().apply {
|
||||
changedValues.forEach { (key, value) ->
|
||||
putString(key, value)
|
||||
}
|
||||
}.commit()
|
||||
|
||||
if (committed) {
|
||||
notifyConfigChanged(changedValues)
|
||||
}
|
||||
|
||||
return committed
|
||||
}
|
||||
|
||||
fun Context.broadcastFullConfigSnapshot() {
|
||||
HookConfigSnapshot.writeFromPreferences(this)
|
||||
val values = configPrefs().all
|
||||
.mapValues { (_, value) -> value?.toString() ?: "" }
|
||||
.filterKeys(HookConfigSnapshot::isHookRuntimeKey)
|
||||
sendConfigBroadcast(values, fullSnapshot = true)
|
||||
}
|
||||
|
||||
fun Context.requestHookActionExecution(actionCode: String) {
|
||||
if (actionCode.isBlank() || actionCode == "none") return
|
||||
|
||||
HookConfigSnapshot.writeFromPreferences(this)
|
||||
sendBroadcast(Intent(HookConfigSnapshot.ACTION_EXECUTE_ACTION).apply {
|
||||
putExtra(HookConfigSnapshot.EXTRA_ACTION_CODE, actionCode)
|
||||
})
|
||||
}
|
||||
|
||||
// Reads for UI. Includes a legacy fallback for values previously stored as native booleans.
|
||||
fun Context.getConfigString(key: String, default: String = ""): String =
|
||||
configPrefs().run { getString(key, null) ?: default }
|
||||
|
||||
fun Context.getConfigBool(key: String, default: Boolean = false): Boolean =
|
||||
configPrefs().run {
|
||||
runCatching { getString(key, null) }.getOrNull()?.toBooleanStrictOrNull()
|
||||
?: runCatching { getBoolean(key, default) }.getOrDefault(default)
|
||||
}
|
||||
|
||||
fun Context.syncRuntimeEnableFlagsFromConfiguredActions(): Boolean {
|
||||
val prefs = configPrefs()
|
||||
val values = prefs.all.mapValues { (_, value) -> value?.toString() ?: "" }
|
||||
val derivedValues = mutableMapOf<String, String>()
|
||||
|
||||
AppConfig.ZONES.forEach { zone ->
|
||||
val enabledKey = AppConfig.zoneEnabled(zone)
|
||||
if (!prefs.contains(enabledKey) && zoneHasConfiguredAction(zone, values)) {
|
||||
derivedValues[enabledKey] = true.toString()
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.KEY_TRIGGERS.flatMap { trigger ->
|
||||
values.keys.mapNotNull { key ->
|
||||
AppConfig.keyActionParts(key)
|
||||
?.takeIf { (_, parsedTrigger) -> parsedTrigger == trigger }
|
||||
?.first
|
||||
}
|
||||
}.toSet().forEach { keyCode ->
|
||||
val enabledKey = AppConfig.keyEnabled(keyCode)
|
||||
if (!prefs.contains(enabledKey) && keyHasConfiguredAction(keyCode, values)) {
|
||||
derivedValues[enabledKey] = true.toString()
|
||||
}
|
||||
}
|
||||
|
||||
if (derivedValues.isEmpty()) return false
|
||||
|
||||
val committed = prefs.edit().apply {
|
||||
derivedValues.forEach { (key, value) -> putString(key, value) }
|
||||
}.apply()
|
||||
notifyConfigChanged(derivedValues)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Context.notifyConfigChanged(changedValues: Map<String, String>) {
|
||||
HookConfigSnapshot.writeFromPreferences(this)
|
||||
sendConfigBroadcast(changedValues, fullSnapshot = false)
|
||||
}
|
||||
|
||||
private fun Context.runtimeValuesAfterChange(changedValues: Map<String, String>): Map<String, String> {
|
||||
val prefs = configPrefs()
|
||||
val result = changedValues.toMutableMap()
|
||||
|
||||
changedValues.keys.mapNotNull(AppConfig::gestureActionParts)
|
||||
.map { (zone, _) -> zone }
|
||||
.toSet()
|
||||
.forEach { zone ->
|
||||
val enabledKey = AppConfig.zoneEnabled(zone)
|
||||
if (!prefs.contains(enabledKey)) {
|
||||
result[enabledKey] = zoneHasConfiguredAction(zone, changedValues, prefs).toString()
|
||||
}
|
||||
}
|
||||
|
||||
changedValues.keys.mapNotNull(AppConfig::keyActionParts)
|
||||
.map { (keyCode, _) -> keyCode }
|
||||
.toSet()
|
||||
.forEach { keyCode ->
|
||||
val enabledKey = AppConfig.keyEnabled(keyCode)
|
||||
if (!prefs.contains(enabledKey)) {
|
||||
result[enabledKey] = keyHasConfiguredAction(keyCode, changedValues, prefs).toString()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun zoneHasConfiguredAction(
|
||||
zone: String,
|
||||
changedValues: Map<String, String>,
|
||||
prefs: SharedPreferences,
|
||||
): Boolean =
|
||||
AppConfig.GESTURES.any { gesture ->
|
||||
val key = AppConfig.gestureAction(zone, gesture)
|
||||
AppConfig.isActiveActionValue(changedValues[key] ?: prefs.getString(key, "").orEmpty())
|
||||
}
|
||||
|
||||
private fun zoneHasConfiguredAction(zone: String, values: Map<String, String>): Boolean =
|
||||
AppConfig.GESTURES.any { gesture ->
|
||||
AppConfig.isActiveActionValue(values[AppConfig.gestureAction(zone, gesture)].orEmpty())
|
||||
}
|
||||
|
||||
private fun keyHasConfiguredAction(
|
||||
keyCode: Int,
|
||||
changedValues: Map<String, String>,
|
||||
prefs: SharedPreferences,
|
||||
): Boolean =
|
||||
AppConfig.KEY_TRIGGERS.any { trigger ->
|
||||
val key = AppConfig.keyAction(keyCode, trigger)
|
||||
AppConfig.isActiveActionValue(changedValues[key] ?: prefs.getString(key, "").orEmpty())
|
||||
}
|
||||
|
||||
private fun keyHasConfiguredAction(keyCode: Int, values: Map<String, String>): Boolean =
|
||||
AppConfig.KEY_TRIGGERS.any { trigger ->
|
||||
AppConfig.isActiveActionValue(values[AppConfig.keyAction(keyCode, trigger)].orEmpty())
|
||||
}
|
||||
|
||||
private fun Context.sendConfigBroadcast(valuesByKey: Map<String, String>, fullSnapshot: Boolean) {
|
||||
val hookValues = valuesByKey.filterKeys(HookConfigSnapshot::isHookRuntimeKey)
|
||||
if (hookValues.isEmpty() && !fullSnapshot) return
|
||||
|
||||
val keys = hookValues.keys.toTypedArray()
|
||||
val values = keys.map { hookValues.getValue(it) }.toTypedArray()
|
||||
sendBroadcast(Intent(HookConfigSnapshot.ACTION_CONFIG_CHANGED).apply {
|
||||
putExtra(HookConfigSnapshot.EXTRA_KEYS, keys)
|
||||
putExtra(HookConfigSnapshot.EXTRA_VALUES, values)
|
||||
putExtra(HookConfigSnapshot.EXTRA_FULL_SNAPSHOT, fullSnapshot)
|
||||
})
|
||||
}
|
||||
60
app/src/main/java/com/fan/edgex/config/FreezerBootstrap.kt
Normal file
60
app/src/main/java/com/fan/edgex/config/FreezerBootstrap.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object FreezerBootstrap {
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
fun ensureMigrated(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
if (appContext.getConfigBool(AppConfig.HAS_MIGRATED_FREEZER_LIST)) return
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
|
||||
Thread {
|
||||
try {
|
||||
if (appContext.getConfigBool(AppConfig.HAS_MIGRATED_FREEZER_LIST)) return@Thread
|
||||
|
||||
val pm = appContext.packageManager
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
}
|
||||
val disabledLauncherApps = pm.queryIntentActivities(
|
||||
mainIntent,
|
||||
android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS,
|
||||
)
|
||||
.asSequence()
|
||||
.map { it.activityInfo.applicationInfo }
|
||||
.filter { !it.enabled }
|
||||
.map { it.packageName.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toCollection(linkedSetOf())
|
||||
|
||||
android.util.Log.d(
|
||||
"EdgeX",
|
||||
"Freezer bootstrap scan: found ${disabledLauncherApps.size} apps: $disabledLauncherApps",
|
||||
)
|
||||
|
||||
val currentSet = appContext.getConfigString(AppConfig.FREEZER_APP_LIST)
|
||||
.split(',')
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toCollection(linkedSetOf())
|
||||
currentSet.addAll(disabledLauncherApps)
|
||||
|
||||
val persisted = appContext.putConfigsSync(
|
||||
AppConfig.FREEZER_APP_LIST to currentSet.joinToString(","),
|
||||
AppConfig.HAS_MIGRATED_FREEZER_LIST to true.toString(),
|
||||
)
|
||||
android.util.Log.d(
|
||||
"EdgeX",
|
||||
"Freezer bootstrap persisted=$persisted, size=${currentSet.size}",
|
||||
)
|
||||
} finally {
|
||||
started.set(false)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
class GestureZoneGeometryCalculator(
|
||||
private val getVal: (String, String) -> String
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_PERCENT = 10
|
||||
const val MAX_PERCENT = 90
|
||||
const val MIN_SEGMENT_PERCENT = 10
|
||||
const val DEFAULT_FIRST_PERCENT = 33
|
||||
const val DEFAULT_SECOND_PERCENT = 66
|
||||
const val DEFAULT_THICKNESS_DP = 16
|
||||
const val MIN_THICKNESS_DP = 8
|
||||
const val MAX_THICKNESS_DP = 32
|
||||
|
||||
fun clampFirstPercent(first: Int): Int =
|
||||
first.coerceIn(MIN_PERCENT, MAX_PERCENT - MIN_SEGMENT_PERCENT)
|
||||
|
||||
fun clampSecondPercent(first: Int, second: Int): Int =
|
||||
second.coerceIn(first + MIN_SEGMENT_PERCENT, MAX_PERCENT)
|
||||
|
||||
fun adjustFirst(first: Int, currentSecond: Int): Pair<Int, Int> {
|
||||
val f = first.coerceIn(MIN_PERCENT, MAX_PERCENT - MIN_SEGMENT_PERCENT)
|
||||
val s = currentSecond.coerceIn(f + MIN_SEGMENT_PERCENT, MAX_PERCENT)
|
||||
return Pair(f, s)
|
||||
}
|
||||
|
||||
fun adjustSecond(currentFirst: Int, second: Int): Pair<Int, Int> {
|
||||
val s = second.coerceIn(MIN_PERCENT + MIN_SEGMENT_PERCENT, MAX_PERCENT)
|
||||
val f = currentFirst.coerceIn(MIN_PERCENT, s - MIN_SEGMENT_PERCENT)
|
||||
return Pair(f, s)
|
||||
}
|
||||
|
||||
fun adjustMiddleHeight(newH: Int, currentFirst: Int, currentSecond: Int): Pair<Int, Int> {
|
||||
val h = newH.coerceIn(MIN_SEGMENT_PERCENT, MAX_PERCENT - MIN_PERCENT)
|
||||
val currentMid = (currentFirst + currentSecond) / 2.0
|
||||
var f = kotlin.math.round(currentMid - h / 2.0).toInt()
|
||||
var s = f + h
|
||||
if (f < MIN_PERCENT) {
|
||||
f = MIN_PERCENT
|
||||
s = f + h
|
||||
}
|
||||
if (s > MAX_PERCENT) {
|
||||
s = MAX_PERCENT
|
||||
f = s - h
|
||||
}
|
||||
return Pair(f, s)
|
||||
}
|
||||
|
||||
fun resolveSegment(v: Float, totalLength: Float, firstPercent: Int, secondPercent: Int): Int {
|
||||
val p1 = totalLength * (firstPercent / 100f)
|
||||
val p2 = totalLength * (secondPercent / 100f)
|
||||
return when {
|
||||
v < p1 -> 0
|
||||
v < p2 -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveSegmentWithOffset(
|
||||
v: Float,
|
||||
startOffset: Float,
|
||||
endOffset: Float,
|
||||
firstPercent: Int,
|
||||
secondPercent: Int
|
||||
): Int {
|
||||
val activeLength = endOffset - startOffset
|
||||
val p1 = startOffset + activeLength * (firstPercent / 100f)
|
||||
val p2 = startOffset + activeLength * (secondPercent / 100f)
|
||||
return when {
|
||||
v < p1 -> 0
|
||||
v < p2 -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSplits(edge: String): Pair<Int, Int> {
|
||||
val firstKey = AppConfig.zoneSplitFirstPercentKey(edge)
|
||||
val secondKey = AppConfig.zoneSplitSecondPercentKey(edge)
|
||||
val firstVal = getVal(firstKey, DEFAULT_FIRST_PERCENT.toString()).toIntOrNull() ?: DEFAULT_FIRST_PERCENT
|
||||
val secondVal = getVal(secondKey, DEFAULT_SECOND_PERCENT.toString()).toIntOrNull() ?: DEFAULT_SECOND_PERCENT
|
||||
|
||||
val f = clampFirstPercent(firstVal)
|
||||
val s = clampSecondPercent(f, secondVal)
|
||||
return Pair(f, s)
|
||||
}
|
||||
|
||||
fun getThicknessDp(zone: String): Int {
|
||||
val key = AppConfig.zoneThicknessKey(zone)
|
||||
val value = getVal(key, DEFAULT_THICKNESS_DP.toString()).toIntOrNull() ?: DEFAULT_THICKNESS_DP
|
||||
return value.coerceIn(MIN_THICKNESS_DP, MAX_THICKNESS_DP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import com.fan.edgex.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Properties
|
||||
|
||||
object HookClipboardHistoryStore {
|
||||
private const val HISTORY_FILE = "clipboard_history.properties"
|
||||
private const val TEMP_FILE = "$HISTORY_FILE.tmp"
|
||||
private const val KEY_VERSION = "__version"
|
||||
private const val KEY_COUNT = "count"
|
||||
private const val ENTRY_PREFIX = "entry."
|
||||
private const val VERSION = "1"
|
||||
|
||||
fun readForHook(maxItems: Int): List<String> =
|
||||
read(historyFileForHook(), maxItems)
|
||||
|
||||
fun writeForHook(items: List<String>, maxItems: Int): Boolean =
|
||||
write(systemHistoryFile(), items.take(maxItems))
|
||||
|
||||
private fun historyFileForHook(): File =
|
||||
systemHistoryFile().takeIf { it.isFile && it.canRead() }
|
||||
?: File("/data/user_de/0/${BuildConfig.APPLICATION_ID}/files/$HISTORY_FILE")
|
||||
|
||||
private fun read(file: File, maxItems: Int): List<String> {
|
||||
if (!file.isFile || !file.canRead()) return emptyList()
|
||||
return runCatching {
|
||||
val properties = Properties()
|
||||
FileInputStream(file).use(properties::load)
|
||||
val count = properties.getProperty(KEY_COUNT, "0").toIntOrNull() ?: 0
|
||||
(0 until count.coerceAtMost(maxItems)).mapNotNull { index ->
|
||||
properties.getProperty("$ENTRY_PREFIX$index")?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun write(file: File, items: List<String>): Boolean {
|
||||
return runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val properties = Properties()
|
||||
properties.setProperty(KEY_VERSION, VERSION)
|
||||
properties.setProperty(KEY_COUNT, items.size.toString())
|
||||
items.forEachIndexed { index, text ->
|
||||
properties.setProperty("$ENTRY_PREFIX$index", text)
|
||||
}
|
||||
|
||||
val temp = File(file.parentFile, TEMP_FILE)
|
||||
FileOutputStream(temp).use { out ->
|
||||
properties.store(out, "EdgeX clipboard history")
|
||||
out.fd.sync()
|
||||
}
|
||||
if (!temp.renameTo(file)) {
|
||||
temp.copyTo(file, overwrite = true)
|
||||
temp.delete()
|
||||
}
|
||||
makeHookReadable(file)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun systemHistoryFile(): File =
|
||||
File("/data/system/edgex/$HISTORY_FILE")
|
||||
|
||||
private fun makeHookReadable(file: File) {
|
||||
file.setReadable(true, false)
|
||||
file.setWritable(true, true)
|
||||
|
||||
file.parentFile?.let { filesDir ->
|
||||
filesDir.setExecutable(true, false)
|
||||
filesDir.setReadable(true, false)
|
||||
}
|
||||
|
||||
file.parentFile?.parentFile?.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
113
app/src/main/java/com/fan/edgex/config/HookConfigSnapshot.kt
Normal file
113
app/src/main/java/com/fan/edgex/config/HookConfigSnapshot.kt
Normal file
@@ -0,0 +1,113 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.content.Context
|
||||
import com.fan.edgex.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Properties
|
||||
|
||||
object HookConfigSnapshot {
|
||||
val ACTION_CONFIG_CHANGED = "${BuildConfig.APPLICATION_ID}.ACTION_CONFIG_CHANGED"
|
||||
val ACTION_CONFIG_SNAPSHOT_REQUEST = "${BuildConfig.APPLICATION_ID}.ACTION_CONFIG_SNAPSHOT_REQUEST"
|
||||
val ACTION_EXECUTE_ACTION = "${BuildConfig.APPLICATION_ID}.ACTION_EXECUTE_ACTION"
|
||||
val ACTION_HOOK_STATUS_REQUEST = "${BuildConfig.APPLICATION_ID}.ACTION_HOOK_STATUS_REQUEST"
|
||||
val ACTION_HOOK_STATUS_RESPONSE = "${BuildConfig.APPLICATION_ID}.ACTION_HOOK_STATUS_RESPONSE"
|
||||
val ACTION_EDGE_LIGHTING = "${BuildConfig.APPLICATION_ID}.ACTION_EDGE_LIGHTING"
|
||||
val ACTION_EDGE_LIGHTING_DISMISS = "${BuildConfig.APPLICATION_ID}.ACTION_EDGE_LIGHTING_DISMISS"
|
||||
const val EXTRA_EDGE_LIGHTING_NOTIFICATION_KEY = "edge_lighting_notification_key"
|
||||
const val EXTRA_KEYS = "keys"
|
||||
const val EXTRA_VALUES = "values"
|
||||
const val EXTRA_FULL_SNAPSHOT = "full_snapshot"
|
||||
const val EXTRA_ACTION_CODE = "action_code"
|
||||
const val EXTRA_HOOK_ACTIVE_AT = "hook_active_at"
|
||||
const val EXTRA_EDGE_LIGHTING_COLOR = "color"
|
||||
const val EXTRA_EDGE_LIGHTING_DURATION_MS = "duration_ms"
|
||||
|
||||
private const val SNAPSHOT_FILE = "hook_config.properties"
|
||||
private const val TEMP_FILE = "$SNAPSHOT_FILE.tmp"
|
||||
private const val KEY_VERSION = "__version"
|
||||
private const val VERSION = "1"
|
||||
|
||||
fun snapshotFileForHook(): File =
|
||||
systemSnapshotFile().takeIf { it.isFile && it.canRead() }
|
||||
?: File("/data/user_de/0/${BuildConfig.APPLICATION_ID}/files/$SNAPSHOT_FILE")
|
||||
|
||||
fun writeFromPreferences(context: Context): Boolean {
|
||||
val prefs = context.configPrefs()
|
||||
val values = prefs.all.mapValues { (_, value) -> value?.toString() ?: "" }
|
||||
return write(context, valuesForHook(values))
|
||||
}
|
||||
|
||||
fun readFromHookFile(): Map<String, String> =
|
||||
read(snapshotFileForHook())
|
||||
|
||||
fun readFromContext(context: Context): Map<String, String> =
|
||||
read(snapshotFile(context))
|
||||
|
||||
fun writeForHook(values: Map<String, String>): Boolean =
|
||||
write(systemSnapshotFile(), valuesForHook(values))
|
||||
|
||||
fun isHookRuntimeKey(key: String): Boolean =
|
||||
key != KEY_VERSION && !key.endsWith("_label")
|
||||
|
||||
private fun write(context: Context, values: Map<String, String>): Boolean {
|
||||
return write(snapshotFile(context), values)
|
||||
}
|
||||
|
||||
private fun write(file: File, values: Map<String, String>): Boolean {
|
||||
return runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val properties = Properties()
|
||||
properties.setProperty(KEY_VERSION, VERSION)
|
||||
values.forEach { (key, value) ->
|
||||
properties.setProperty(key, value)
|
||||
}
|
||||
|
||||
val temp = File(file.parentFile, TEMP_FILE)
|
||||
FileOutputStream(temp).use { out ->
|
||||
properties.store(out, "EdgeX hook config snapshot")
|
||||
out.fd.sync()
|
||||
}
|
||||
if (!temp.renameTo(file)) {
|
||||
temp.copyTo(file, overwrite = true)
|
||||
temp.delete()
|
||||
}
|
||||
makeHookReadable(file)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun read(file: File): Map<String, String> {
|
||||
if (!file.isFile || !file.canRead()) return emptyMap()
|
||||
return runCatching {
|
||||
val properties = Properties()
|
||||
FileInputStream(file).use(properties::load)
|
||||
properties.stringPropertyNames()
|
||||
.filter(::isHookRuntimeKey)
|
||||
.associateWith { properties.getProperty(it, "") }
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun valuesForHook(values: Map<String, String>): Map<String, String> =
|
||||
values.filterKeys(::isHookRuntimeKey)
|
||||
|
||||
private fun snapshotFile(context: Context): File =
|
||||
File(context.createDeviceProtectedStorageContext().filesDir, SNAPSHOT_FILE)
|
||||
|
||||
private fun systemSnapshotFile(): File =
|
||||
File("/data/system/edgex/$SNAPSHOT_FILE")
|
||||
|
||||
private fun makeHookReadable(file: File) {
|
||||
file.setReadable(true, false)
|
||||
file.setWritable(true, true)
|
||||
|
||||
file.parentFile?.let { filesDir ->
|
||||
filesDir.setExecutable(true, false)
|
||||
filesDir.setReadable(true, false)
|
||||
}
|
||||
|
||||
file.parentFile?.parentFile?.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import com.fan.edgex.BuildConfig
|
||||
|
||||
object ModuleActivationState {
|
||||
private const val PREFS_NAME = "module_activation"
|
||||
private const val KEY_ACTIVE_AT = "active_at"
|
||||
|
||||
fun markActive(context: Context, activeAt: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_ACTIVE_AT, activeAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun isActive(context: Context): Boolean {
|
||||
val activeAt = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_ACTIVE_AT, 0L)
|
||||
val bootTime = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||
return activeAt > bootTime
|
||||
}
|
||||
|
||||
fun requestRefresh(context: Context) {
|
||||
context.sendBroadcast(Intent(HookConfigSnapshot.ACTION_HOOK_STATUS_REQUEST).apply {
|
||||
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
|
||||
})
|
||||
}
|
||||
|
||||
fun responseIntent(activeAt: Long): Intent =
|
||||
Intent(HookConfigSnapshot.ACTION_HOOK_STATUS_RESPONSE).apply {
|
||||
component = ComponentName(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
"${BuildConfig.APPLICATION_ID}.config.ConfigSnapshotReceiver",
|
||||
)
|
||||
setPackage(BuildConfig.APPLICATION_ID)
|
||||
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
|
||||
putExtra(HookConfigSnapshot.EXTRA_HOOK_ACTIVE_AT, activeAt)
|
||||
}
|
||||
}
|
||||
99
app/src/main/java/com/fan/edgex/config/MultiActionStore.kt
Normal file
99
app/src/main/java/com/fan/edgex/config/MultiActionStore.kt
Normal file
@@ -0,0 +1,99 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
data class MultiActionStep(
|
||||
val code: String,
|
||||
val label: String,
|
||||
val iconCode: String = "",
|
||||
)
|
||||
|
||||
data class MultiAction(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val steps: MutableList<MultiActionStep>,
|
||||
val iconRef: String = "",
|
||||
)
|
||||
|
||||
object MultiActionStore {
|
||||
private const val KEY_INDEX = "multi_actions_index"
|
||||
private const val TEMP_STEP_KEY = "_edit_step_tmp"
|
||||
|
||||
fun generateId(): String =
|
||||
SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(Date())
|
||||
|
||||
fun actionCode(id: String) = "multi_action:$id"
|
||||
|
||||
fun tempStepKey() = TEMP_STEP_KEY
|
||||
|
||||
fun getAll(prefs: SharedPreferences): List<MultiAction> {
|
||||
val ids = prefs.getString(KEY_INDEX, "")
|
||||
?.split(",")?.filter { it.isNotBlank() } ?: emptyList()
|
||||
return ids.mapNotNull { id -> load(prefs, id) }
|
||||
}
|
||||
|
||||
fun get(prefs: SharedPreferences, id: String): MultiAction? = load(prefs, id)
|
||||
|
||||
private fun load(prefs: SharedPreferences, id: String): MultiAction? {
|
||||
val name = prefs.getString("multi_action_${id}_name", null) ?: return null
|
||||
val stepsJson = prefs.getString("multi_action_${id}_steps", null) ?: "[]"
|
||||
val iconRef = prefs.getString("multi_action_${id}_icon", "") ?: ""
|
||||
return MultiAction(id, name, parseSteps(stepsJson).toMutableList(), iconRef)
|
||||
}
|
||||
|
||||
fun save(prefs: SharedPreferences, multiAction: MultiAction) {
|
||||
val existing = prefs.getString(KEY_INDEX, "")
|
||||
?.split(",")?.filter { it.isNotBlank() }?.toMutableList() ?: mutableListOf()
|
||||
if (multiAction.id !in existing) existing.add(multiAction.id)
|
||||
prefs.edit()
|
||||
.putString(KEY_INDEX, existing.joinToString(","))
|
||||
.putString("multi_action_${multiAction.id}_name", multiAction.name)
|
||||
.putString("multi_action_${multiAction.id}_steps", serializeSteps(multiAction.steps))
|
||||
.putString("multi_action_${multiAction.id}_icon", multiAction.iconRef)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun delete(prefs: SharedPreferences, id: String) {
|
||||
val existing = prefs.getString(KEY_INDEX, "")
|
||||
?.split(",")?.filter { it.isNotBlank() && it != id } ?: emptyList()
|
||||
prefs.edit()
|
||||
.putString(KEY_INDEX, existing.joinToString(","))
|
||||
.remove("multi_action_${id}_name")
|
||||
.remove("multi_action_${id}_steps")
|
||||
.remove("multi_action_${id}_icon")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun serializeSteps(steps: List<MultiActionStep>): String {
|
||||
val arr = JSONArray()
|
||||
steps.forEach { step ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("code", step.code)
|
||||
put("label", step.label)
|
||||
put("iconCode", step.iconCode)
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
fun parseSteps(json: String): List<MultiActionStep> =
|
||||
runCatching {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
MultiActionStep(
|
||||
code = obj.getString("code"),
|
||||
label = obj.optString("label", obj.getString("code")),
|
||||
iconCode = obj.optString("iconCode", ""),
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
fun getStepsFromConfig(resolveConfig: (String) -> String, id: String): List<MultiActionStep> =
|
||||
parseSteps(resolveConfig("multi_action_${id}_steps"))
|
||||
}
|
||||
109
app/src/main/java/com/fan/edgex/config/ShellExecutorService.kt
Normal file
109
app/src/main/java/com/fan/edgex/config/ShellExecutorService.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.Process
|
||||
import android.provider.MediaStore
|
||||
import com.fan.edgex.IShellCallback
|
||||
import com.fan.edgex.IShellExecutor
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import java.io.IOException
|
||||
|
||||
class ShellExecutorService : Service() {
|
||||
|
||||
private val stub = object : IShellExecutor.Stub() {
|
||||
override fun execute(command: String, runAsRoot: Boolean, callback: IShellCallback?) {
|
||||
if (!isSystemServerCaller()) {
|
||||
callback?.onResult(false, "")
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
try {
|
||||
if (runAsRoot) {
|
||||
val result = Shell.cmd(command).exec()
|
||||
val output = if (result.isSuccess) {
|
||||
result.out.joinToString("\n").trim()
|
||||
} else {
|
||||
result.err.joinToString("\n").trim()
|
||||
}
|
||||
callback?.onResult(result.isSuccess, output)
|
||||
} else {
|
||||
val process = ProcessBuilder("sh", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process.outputStream.close()
|
||||
val output = process.inputStream.bufferedReader().readText().trim()
|
||||
val exitCode = process.waitFor()
|
||||
callback?.onResult(exitCode == 0, output)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback?.onResult(false, e.message.orEmpty())
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun savePngToGallery(
|
||||
png: ParcelFileDescriptor?,
|
||||
displayName: String?,
|
||||
callback: IShellCallback?,
|
||||
) {
|
||||
if (!isSystemServerCaller()) {
|
||||
callback?.onResult(false, "")
|
||||
png?.close()
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
var insertedUri: android.net.Uri? = null
|
||||
try {
|
||||
if (png == null) throw IOException("PNG pipe is null")
|
||||
val now = System.currentTimeMillis()
|
||||
val values = ContentValues().apply {
|
||||
put(
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
displayName?.takeIf { it.isNotBlank() } ?: "Screenshot_$now.png",
|
||||
)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Screenshots")
|
||||
put(MediaStore.Images.Media.DATE_ADDED, now / 1000)
|
||||
put(MediaStore.Images.Media.DATE_TAKEN, now)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
val resolver = contentResolver
|
||||
insertedUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IOException("MediaStore insert returned null")
|
||||
ParcelFileDescriptor.AutoCloseInputStream(png).use { input ->
|
||||
val output = resolver.openOutputStream(insertedUri, "w")
|
||||
?: throw IOException("MediaStore output stream is null")
|
||||
output.use { input.copyTo(it) }
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val publishValues = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
}
|
||||
resolver.update(insertedUri, publishValues, null, null)
|
||||
}
|
||||
callback?.onResult(true, insertedUri.toString())
|
||||
} catch (e: Exception) {
|
||||
insertedUri?.let { runCatching { contentResolver.delete(it, null, null) } }
|
||||
callback?.onResult(false, e.message.orEmpty())
|
||||
png?.close()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder = stub
|
||||
|
||||
private fun isSystemServerCaller(): Boolean {
|
||||
val callerUid = Binder.getCallingUid()
|
||||
val callerPackages = packageManager.getPackagesForUid(callerUid)
|
||||
return callerUid == Process.SYSTEM_UID && callerPackages?.contains("android") == true
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/com/fan/edgex/config/ThemeColorResolver.kt
Normal file
54
app/src/main/java/com/fan/edgex/config/ThemeColorResolver.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.fan.edgex.config
|
||||
|
||||
/** Resolves UI theme and optional per-surface colors in both app and hook processes. */
|
||||
object ThemeColorResolver {
|
||||
private const val DEFAULT_ACCENT = 0xFF2F8A3E.toInt()
|
||||
|
||||
private val uiAccents = mapOf(
|
||||
"green" to DEFAULT_ACCENT,
|
||||
"blue" to 0xFF3B6CE5.toInt(),
|
||||
"coral" to 0xFFDD5A48.toInt(),
|
||||
"violet" to 0xFF7B4FE0.toInt(),
|
||||
"amber" to 0xFFC68A1A.toInt(),
|
||||
)
|
||||
|
||||
private val legacyPresets = mapOf(
|
||||
"default" to 0xFF326D32.toInt(),
|
||||
"classic" to 0xFF00796B.toInt(),
|
||||
"cedar" to 0xFF496B3D.toInt(),
|
||||
"ocean" to 0xFF2F6F8F.toInt(),
|
||||
"ember" to 0xFFC56B2A.toInt(),
|
||||
)
|
||||
|
||||
fun resolveConfiguredColor(configKey: String, resolveConfig: (String) -> String): Int =
|
||||
parseColorOrNull(resolveConfig(configKey)) ?: resolveThemeColor(resolveConfig)
|
||||
|
||||
fun resolveThemeColor(resolveConfig: (String) -> String): Int {
|
||||
val uiAccent = resolveConfig(AppConfig.UI_ACCENT)
|
||||
if (uiAccent == "custom") {
|
||||
parseColorOrNull(resolveConfig(AppConfig.THEME_CUSTOM_COLOR))?.let { return it }
|
||||
} else {
|
||||
uiAccents[uiAccent]?.let { return it }
|
||||
}
|
||||
|
||||
val preset = resolveConfig(AppConfig.THEME_PRESET).ifBlank { "default" }
|
||||
if (preset == "custom") {
|
||||
parseColorOrNull(resolveConfig(AppConfig.THEME_CUSTOM_COLOR))?.let { return it }
|
||||
}
|
||||
return legacyPresets[preset] ?: DEFAULT_ACCENT
|
||||
}
|
||||
|
||||
fun parseColorOrNull(value: String): Int? {
|
||||
val hex = value.trim().removePrefix("#")
|
||||
return runCatching {
|
||||
when (hex.length) {
|
||||
6 -> (0xFF000000L or hex.toLong(16)).toInt()
|
||||
8 -> hex.toLong(16).toInt()
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun formatArgb(color: Int): String =
|
||||
"#%08X".format(color.toLong() and 0xFFFFFFFFL)
|
||||
}
|
||||
71
app/src/main/java/com/fan/edgex/hook/ClipboardHook.kt
Normal file
71
app/src/main/java/com/fan/edgex/hook/ClipboardHook.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.ClipData
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
|
||||
/**
|
||||
* Hooks ClipboardService to cache clipboard text without relying on
|
||||
* ClipboardManager.getPrimaryClip(), which is rejected by the clipboard
|
||||
* access-gate in Android 10+ for background/system callers.
|
||||
*
|
||||
* We hook the internal write path so every clipboard change is captured
|
||||
* regardless of which entry point was used.
|
||||
*/
|
||||
object ClipboardHook {
|
||||
|
||||
private const val TAG = "EdgeX"
|
||||
|
||||
fun installHook(classLoader: ClassLoader) {
|
||||
// Try internal storage methods first (more reliable across API levels)
|
||||
val hookedInternal = tryHookByName(classLoader, "com.android.server.clipboard.ClipboardService",
|
||||
"setPrimaryClipInternal", // Android 12-15
|
||||
"setPrimaryClipInternalLocked" // older fallback
|
||||
)
|
||||
|
||||
// Always also hook the public entry point as a belt-and-suspenders fallback
|
||||
val hookedPublic = tryHookByName(classLoader, "com.android.server.clipboard.ClipboardService",
|
||||
"setPrimaryClip"
|
||||
)
|
||||
|
||||
if (!hookedInternal && !hookedPublic) {
|
||||
XposedBridge.log("$TAG: ClipboardHook — no hook point found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryHookByName(classLoader: ClassLoader, className: String, vararg methodNames: String): Boolean {
|
||||
return try {
|
||||
val cls = XposedHelpers.findClass(className, classLoader)
|
||||
var hooked = false
|
||||
for (name in methodNames) {
|
||||
try {
|
||||
val count = XposedBridge.hookAllMethods(cls, name, clipHook).size
|
||||
if (count > 0) {
|
||||
XposedBridge.log("$TAG: ClipboardHook hooked $className#$name ($count overloads)")
|
||||
hooked = true
|
||||
}
|
||||
} catch (_: Throwable) { }
|
||||
}
|
||||
hooked
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val clipHook = object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
// Find the ClipData argument regardless of overload signature
|
||||
val clip = param.args.firstOrNull { it is ClipData } as? ClipData ?: return
|
||||
val text = extractText(clip)
|
||||
ClipboardOverlay.onClipboardChanged(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractText(clip: ClipData): String? {
|
||||
if (clip.itemCount == 0) return null
|
||||
// Prefer getText() — no context needed, no URI resolution
|
||||
val text = clip.getItemAt(0).text?.toString()?.trim()
|
||||
return if (text.isNullOrEmpty()) null else text
|
||||
}
|
||||
}
|
||||
517
app/src/main/java/com/fan/edgex/hook/ClipboardOverlay.kt
Normal file
517
app/src/main/java/com/fan/edgex/hook/ClipboardOverlay.kt
Normal file
@@ -0,0 +1,517 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.core.graphics.toColorInt
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.HookClipboardHistoryStore
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Clipboard history overlay — styled to match DrawerWindow.
|
||||
* ClipboardHook feeds new entries; up to MAX_HISTORY are kept (deduped, most-recent first).
|
||||
* Tap an item → paste; tap × → delete that entry; "清空" → clear all.
|
||||
*/
|
||||
object ClipboardOverlay {
|
||||
|
||||
private const val TAG = "EdgeX"
|
||||
private const val AUTO_DISMISS_MS = 30_000L
|
||||
private const val MAX_HISTORY = 50
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var overlayRef: WeakReference<View>? = null
|
||||
private var autoDismissRunnable: Runnable? = null
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
private val history = mutableListOf<String>()
|
||||
private var historyLoaded = false
|
||||
|
||||
@Synchronized
|
||||
fun onClipboardChanged(text: String?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
ensureHistoryLoadedLocked()
|
||||
history.remove(text)
|
||||
history.add(0, text)
|
||||
if (history.size > MAX_HISTORY) history.removeAt(history.lastIndex)
|
||||
persistHistoryLocked()
|
||||
}
|
||||
|
||||
@Synchronized private fun historySnapshot(): ArrayList<String> {
|
||||
ensureHistoryLoadedLocked()
|
||||
return ArrayList(history)
|
||||
}
|
||||
|
||||
@Synchronized private fun deleteEntry(text: String) {
|
||||
ensureHistoryLoadedLocked()
|
||||
if (history.remove(text)) {
|
||||
persistHistoryLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized private fun clearAll() {
|
||||
ensureHistoryLoadedLocked()
|
||||
history.clear()
|
||||
persistHistoryLocked()
|
||||
}
|
||||
|
||||
private fun ensureHistoryLoadedLocked() {
|
||||
if (historyLoaded) return
|
||||
history.clear()
|
||||
history.addAll(HookClipboardHistoryStore.readForHook(MAX_HISTORY))
|
||||
historyLoaded = true
|
||||
}
|
||||
|
||||
private fun persistHistoryLocked() {
|
||||
if (!HookClipboardHistoryStore.writeForHook(history, MAX_HISTORY)) {
|
||||
XposedBridge.log("$TAG: Clipboard history persist failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Show / dismiss ─────────────────────────────────────────────────────────
|
||||
|
||||
fun isShowing(): Boolean = overlayRef?.get() != null
|
||||
|
||||
fun show(context: Context) {
|
||||
handler.post {
|
||||
dismiss()
|
||||
val items = historySnapshot()
|
||||
try {
|
||||
addOverlay(context, items)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: ClipboardOverlay show failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
autoDismissRunnable?.let { handler.removeCallbacks(it) }
|
||||
autoDismissRunnable = null
|
||||
val overlay = overlayRef?.get() ?: return
|
||||
overlayRef = null
|
||||
try {
|
||||
val wm = overlay.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
wm.removeViewImmediate(overlay)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: ClipboardOverlay dismiss failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme (mirrors DrawerWindow) ───────────────────────────────────────────
|
||||
|
||||
private fun isDark(context: Context): Boolean {
|
||||
val snapshot = HookConfigSnapshot.readFromHookFile()
|
||||
val darkSetting = snapshot[AppConfig.UI_DARK_MODE] ?: "system"
|
||||
return when (darkSetting) {
|
||||
"dark", "true" -> true
|
||||
"light", "false" -> false
|
||||
"system" -> {
|
||||
(context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
else -> {
|
||||
darkSetting.toBooleanStrictOrNull() ?: ((context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAccentColor(): Int {
|
||||
val snapshot = HookConfigSnapshot.readFromHookFile()
|
||||
return when (snapshot[AppConfig.THEME_PRESET]) {
|
||||
"custom" -> runCatching {
|
||||
(snapshot[AppConfig.THEME_CUSTOM_COLOR] ?: "").toColorInt()
|
||||
}.getOrElse { "#326D32".toColorInt() }
|
||||
"classic" -> "#00796B".toColorInt()
|
||||
"cedar" -> "#496B3D".toColorInt()
|
||||
"ocean" -> "#2F6F8F".toColorInt()
|
||||
"ember" -> "#C56B2A".toColorInt()
|
||||
else -> "#326D32".toColorInt()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overlay construction ───────────────────────────────────────────────────
|
||||
|
||||
private fun addOverlay(context: Context, items: List<String>) {
|
||||
val dp = context.resources.displayMetrics.density
|
||||
val dpi = { v: Int -> (v * dp + 0.5f).toInt() }
|
||||
val screenH = context.resources.displayMetrics.heightPixels
|
||||
val bottomSafeArea = maxOf(dpi(48), navigationBarHeight(context))
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val accent = readAccentColor()
|
||||
val dark = isDark(context)
|
||||
|
||||
// Color tokens — identical to DrawerWindow.setupModernLayout
|
||||
val surfaceBg = if (dark) Color.argb(238, 20, 19, 30) else Color.argb(238, 250, 248, 255)
|
||||
val textPrimary = if (dark) Color.WHITE else "#1C1B1F".toColorInt()
|
||||
val textMuted = if (dark) "#9A97AA".toColorInt() else "#6B6880".toColorInt()
|
||||
val divider = if (dark) Color.argb(35, 255, 255, 255) else Color.argb(40, 0, 0, 0)
|
||||
val itemBg = if (dark) Color.argb(160, 42, 40, 58) else Color.argb(170, 230, 226, 244)
|
||||
val cornerRad = 28f * dp
|
||||
|
||||
var sheetTop = 0 // populated after first layout; used by dispatchTouchEvent
|
||||
|
||||
val root = object : FrameLayout(context) {
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
// Single-tap anywhere above the sheet → dismiss immediately on ACTION_DOWN
|
||||
if (ev.action == MotionEvent.ACTION_DOWN && sheetTop > 0 && ev.y < sheetTop) {
|
||||
dismiss()
|
||||
return true
|
||||
}
|
||||
super.dispatchTouchEvent(ev)
|
||||
return true
|
||||
}
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
|
||||
dismiss()
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}.apply {
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
val sheet = buildSheet(
|
||||
context, items, dp, screenH, bottomSafeArea,
|
||||
surfaceBg, textPrimary, textMuted, divider, itemBg, cornerRad, accent
|
||||
)
|
||||
root.addView(sheet, FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.BOTTOM })
|
||||
|
||||
// Capture sheet top after layout so dispatchTouchEvent can compare y
|
||||
sheet.addOnLayoutChangeListener { _, _, top, _, _, _, _, _, _ ->
|
||||
sheetTop = top
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
wm.addView(root, WindowManager.LayoutParams().apply {
|
||||
type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
flags = WindowManager.LayoutParams.FLAG_DIM_BEHIND or
|
||||
WindowManager.LayoutParams.FLAG_BLUR_BEHIND or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
|
||||
dimAmount = 0.25f
|
||||
blurBehindRadius = 36
|
||||
})
|
||||
overlayRef = WeakReference(root)
|
||||
|
||||
autoDismissRunnable = Runnable { dismiss() }.also {
|
||||
handler.postDelayed(it, AUTO_DISMISS_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSheet(
|
||||
context: Context,
|
||||
initialItems: List<String>,
|
||||
dp: Float,
|
||||
screenH: Int,
|
||||
bottomSafeArea: Int,
|
||||
surfaceBg: Int,
|
||||
textPrimary: Int,
|
||||
textMuted: Int,
|
||||
dividerColor: Int,
|
||||
itemBg: Int,
|
||||
cornerRad: Float,
|
||||
accent: Int
|
||||
): View {
|
||||
val dpi = { v: Int -> (v * dp + 0.5f).toInt() }
|
||||
|
||||
val sheet = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = GradientDrawable().apply {
|
||||
setColor(surfaceBg)
|
||||
cornerRadii = floatArrayOf(
|
||||
cornerRad, cornerRad, cornerRad, cornerRad, 0f, 0f, 0f, 0f
|
||||
)
|
||||
}
|
||||
elevation = 20f * dp
|
||||
isClickable = true // block touches from falling through to root behind
|
||||
}
|
||||
|
||||
// ── Handle ──
|
||||
sheet.addView(View(context).apply {
|
||||
background = GradientDrawable().apply {
|
||||
setColor(dividerColor)
|
||||
cornerRadius = dpi(2).toFloat()
|
||||
}
|
||||
}, LinearLayout.LayoutParams(dpi(32), dpi(4)).apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
topMargin = dpi(12)
|
||||
bottomMargin = dpi(6)
|
||||
})
|
||||
|
||||
// ── Header — mirrors DrawerWindow header style ──
|
||||
val header = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dpi(20), dpi(12), dpi(16), dpi(14))
|
||||
}
|
||||
|
||||
// Top row: title + clear-all
|
||||
val titleRow = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
}
|
||||
titleRow.addView(TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.clipboard_overlay_title)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
letterSpacing = -0.02f
|
||||
setTextColor(textPrimary)
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
|
||||
val clearAllBtn = TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.clipboard_clear_all)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(accent)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dpi(12), dpi(8), dpi(4), dpi(8))
|
||||
setOnClickListener { clearAll(); dismiss() }
|
||||
}
|
||||
titleRow.addView(clearAllBtn)
|
||||
header.addView(titleRow, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
// Subtitle: "N 条记录"
|
||||
val countView = TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.clipboard_count, initialItems.size)
|
||||
textSize = 12.5f
|
||||
setTextColor(textMuted)
|
||||
setPadding(0, dpi(4), 0, 0)
|
||||
}
|
||||
header.addView(countView, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
sheet.addView(header, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
// ── Divider ──
|
||||
sheet.addView(View(context).apply { setBackgroundColor(dividerColor) },
|
||||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1))
|
||||
|
||||
// ── Scrollable list ──
|
||||
val listContainer = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
|
||||
val scrollView = MaxHeightScrollView(context, (screenH * 0.30f).toInt()).apply {
|
||||
isVerticalScrollBarEnabled = true
|
||||
scrollBarStyle = View.SCROLLBARS_INSIDE_INSET
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(listContainer, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
}
|
||||
sheet.addView(scrollView, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
sheet.addView(View(context), LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, bottomSafeArea
|
||||
))
|
||||
|
||||
// ── Populate / rebuild helper ──
|
||||
fun rebuildList(items: List<String>) {
|
||||
listContainer.removeAllViews()
|
||||
countView.text = ModuleRes.getString(R.string.clipboard_count, items.size)
|
||||
clearAllBtn.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE
|
||||
|
||||
if (items.isEmpty()) {
|
||||
listContainer.addView(TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.clipboard_empty)
|
||||
textSize = 14f
|
||||
setTextColor(textMuted)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dpi(20), dpi(28), dpi(20), dpi(28))
|
||||
}, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
items.forEachIndexed { index, text ->
|
||||
listContainer.addView(
|
||||
buildItemRow(context, text, dp, textPrimary, textMuted, itemBg,
|
||||
onPaste = {
|
||||
dismiss()
|
||||
handler.postDelayed({ pasteText(context, text) }, 150)
|
||||
},
|
||||
onDelete = {
|
||||
deleteEntry(text)
|
||||
val updated = historySnapshot()
|
||||
rebuildList(updated)
|
||||
}
|
||||
),
|
||||
LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
)
|
||||
if (index < items.lastIndex) {
|
||||
listContainer.addView(View(context).apply { setBackgroundColor(dividerColor) },
|
||||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1).apply {
|
||||
marginStart = dpi(20); marginEnd = dpi(20)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
rebuildList(initialItems)
|
||||
return sheet
|
||||
}
|
||||
|
||||
private fun buildItemRow(
|
||||
context: Context,
|
||||
text: String,
|
||||
dp: Float,
|
||||
textPrimary: Int,
|
||||
textMuted: Int,
|
||||
itemBg: Int,
|
||||
onPaste: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
): View {
|
||||
val dpi = { v: Int -> (v * dp + 0.5f).toInt() }
|
||||
|
||||
val row = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dpi(20), dpi(12), dpi(8), dpi(12))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener { onPaste() }
|
||||
}
|
||||
|
||||
row.addView(TextView(context).apply {
|
||||
this.text = text
|
||||
textSize = 14f
|
||||
setTextColor(textPrimary)
|
||||
maxLines = 2
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
setLineSpacing((2 * dp), 1f)
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginEnd = dpi(4)
|
||||
})
|
||||
|
||||
// × delete button
|
||||
row.addView(TextView(context).apply {
|
||||
this.text = "×"
|
||||
textSize = 20f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(textMuted)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dpi(12), dpi(4), dpi(12), dpi(4))
|
||||
setOnClickListener { onDelete() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
private class MaxHeightScrollView(
|
||||
context: Context,
|
||||
private val maxHeight: Int
|
||||
) : ScrollView(context) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val cappedHeightSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
|
||||
super.onMeasure(widthMeasureSpec, cappedHeightSpec)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigationBarHeight(context: Context): Int {
|
||||
return try {
|
||||
val res = context.resources
|
||||
val id = res.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
if (id > 0) res.getDimensionPixelSize(id) else 0
|
||||
} catch (_: Throwable) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste [text] without writing to ClipboardManager, avoiding the system
|
||||
* clipboard-change notification. Accessibility insertion handles Unicode
|
||||
* input methods better; key-event injection remains a fallback.
|
||||
*/
|
||||
private fun pasteText(context: Context, text: String) {
|
||||
UniversalCopyManager.injectIntoFocusedField(context, text) { inserted ->
|
||||
if (!inserted) {
|
||||
handler.post { injectText(context, text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject [text] directly into the focused input field via key events,
|
||||
* mirroring XPE's approach (y0.i0 / KeyCharacterMap.getEvents).
|
||||
*
|
||||
* For ASCII / mappable characters: KeyCharacterMap converts each char to
|
||||
* DOWN+UP key events and we inject them one by one.
|
||||
* For Unicode / non-ASCII that can't be mapped: fall back to the synthetic
|
||||
* ACTION_MULTIPLE + KEYCODE_UNKNOWN "characters" KeyEvent, which Android's
|
||||
* InputDispatcher forwards as commitText on the active input connection.
|
||||
*
|
||||
* Neither path writes to ClipboardManager, so the system clipboard-change
|
||||
* notification (bottom-left toast) is never triggered.
|
||||
*/
|
||||
private fun injectText(context: Context, text: String) {
|
||||
if (text.isEmpty()) return
|
||||
try {
|
||||
val inputManager = context.getSystemService(Context.INPUT_SERVICE) ?: return
|
||||
val injectMethod = inputManager.javaClass.getMethod(
|
||||
"injectInputEvent",
|
||||
android.view.InputEvent::class.java,
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
|
||||
val charMap = android.view.KeyCharacterMap.load(android.view.KeyCharacterMap.VIRTUAL_KEYBOARD)
|
||||
val events = charMap.getEvents(text.toCharArray())
|
||||
|
||||
if (events != null) {
|
||||
// ASCII / fully mappable — inject each DOWN+UP key event
|
||||
for (event in events) {
|
||||
val timed = android.view.KeyEvent.changeTimeRepeat(
|
||||
event, android.os.SystemClock.uptimeMillis(), 0
|
||||
)
|
||||
injectMethod.invoke(inputManager, timed, 0)
|
||||
}
|
||||
} else {
|
||||
// Unicode / non-ASCII — synthetic "characters" event
|
||||
// (deprecated API but still processed by InputDispatcher on Android 15)
|
||||
@Suppress("DEPRECATION")
|
||||
val charEvent = android.view.KeyEvent(
|
||||
android.os.SystemClock.uptimeMillis(),
|
||||
text,
|
||||
android.view.KeyCharacterMap.VIRTUAL_KEYBOARD,
|
||||
0
|
||||
)
|
||||
injectMethod.invoke(inputManager, charEvent, 0)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: injectText failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
129
app/src/main/java/com/fan/edgex/hook/ConditionEvaluator.kt
Normal file
129
app/src/main/java/com/fan/edgex/hook/ConditionEvaluator.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import android.nfc.NfcAdapter
|
||||
import android.os.BatteryManager
|
||||
import android.provider.Settings
|
||||
import android.telephony.TelephonyManager
|
||||
import com.fan.edgex.config.ConditionStore
|
||||
import com.fan.edgex.config.ForegroundAppConditionConfig
|
||||
|
||||
internal object ConditionEvaluator {
|
||||
|
||||
fun evaluate(
|
||||
conditionCode: String,
|
||||
context: Context,
|
||||
foregroundAppConfig: ForegroundAppConditionConfig? = null,
|
||||
): Boolean = try {
|
||||
when (conditionCode) {
|
||||
"auto_brightness" -> isAutoBrightnessOn(context)
|
||||
"auto_rotate" -> isAutoRotateOn(context)
|
||||
"wifi_enabled" -> isWifiEnabled(context)
|
||||
"mobile_data" -> isMobileDataEnabled(context)
|
||||
"location" -> isLocationEnabled(context)
|
||||
"bluetooth" -> isBluetoothEnabled(context)
|
||||
"nfc" -> isNfcEnabled(context)
|
||||
"power_connected" -> isPowerConnected(context)
|
||||
"wifi_connected" -> isWifiConnected(context)
|
||||
"network_connected" -> isNetworkConnected(context)
|
||||
"media_playing" -> isMediaPlaying(context)
|
||||
"screen_portrait" -> isPortrait(context)
|
||||
"screen_landscape" -> isLandscape(context)
|
||||
ConditionStore.FOREGROUND_APP -> isForegroundAppMatch(context, foregroundAppConfig)
|
||||
else -> false
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun isAutoBrightnessOn(context: Context) =
|
||||
Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS_MODE, 0) ==
|
||||
Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC
|
||||
|
||||
private fun isAutoRotateOn(context: Context) =
|
||||
Settings.System.getInt(context.contentResolver, Settings.System.ACCELEROMETER_ROTATION, 0) == 1
|
||||
|
||||
private fun isWifiEnabled(context: Context): Boolean {
|
||||
val wm = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return false
|
||||
return wm.isWifiEnabled
|
||||
}
|
||||
|
||||
private fun isMobileDataEnabled(context: Context): Boolean {
|
||||
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return false
|
||||
return tm.dataState == TelephonyManager.DATA_CONNECTED
|
||||
}
|
||||
|
||||
private fun isLocationEnabled(context: Context) =
|
||||
Settings.Secure.getInt(context.contentResolver, Settings.Secure.LOCATION_MODE, 0) !=
|
||||
Settings.Secure.LOCATION_MODE_OFF
|
||||
|
||||
private fun isBluetoothEnabled(context: Context): Boolean {
|
||||
val bm = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return false
|
||||
return bm.adapter?.state == BluetoothAdapter.STATE_ON
|
||||
}
|
||||
|
||||
private fun isNfcEnabled(context: Context): Boolean {
|
||||
val adapter = NfcAdapter.getDefaultAdapter(context) ?: return false
|
||||
return adapter.isEnabled
|
||||
}
|
||||
|
||||
private fun isPowerConnected(context: Context): Boolean {
|
||||
val bm = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager ?: return false
|
||||
return bm.isCharging
|
||||
}
|
||||
|
||||
private fun isWifiConnected(context: Context): Boolean {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
private fun isNetworkConnected(context: Context): Boolean {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
private fun isMediaPlaying(context: Context): Boolean {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false
|
||||
return am.isMusicActive
|
||||
}
|
||||
|
||||
private fun isPortrait(context: Context) =
|
||||
context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
|
||||
private fun isLandscape(context: Context) =
|
||||
context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isForegroundAppMatch(
|
||||
context: Context,
|
||||
config: ForegroundAppConditionConfig?,
|
||||
): Boolean {
|
||||
val activityManager = context.getSystemService(ActivityManager::class.java) ?: return false
|
||||
val foregroundPackage = activityManager.getRunningTasks(1)
|
||||
.firstOrNull()
|
||||
?.topActivity
|
||||
?.packageName
|
||||
return matchesForegroundApp(config, foregroundPackage)
|
||||
}
|
||||
|
||||
internal fun matchesForegroundApp(
|
||||
config: ForegroundAppConditionConfig?,
|
||||
foregroundPackage: String?,
|
||||
): Boolean {
|
||||
if (config == null || foregroundPackage.isNullOrBlank() || config.packageNames.isEmpty()) return false
|
||||
return foregroundPackage in config.packageNames
|
||||
}
|
||||
}
|
||||
432
app/src/main/java/com/fan/edgex/hook/CopyPanelOverlay.kt
Normal file
432
app/src/main/java/com/fan/edgex/hook/CopyPanelOverlay.kt
Normal file
@@ -0,0 +1,432 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import androidx.core.graphics.toColorInt
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Google Lens-style text selection overlay.
|
||||
* Highlights text blocks in-place on the current screen.
|
||||
* User taps blocks to select, then copies selected text.
|
||||
*/
|
||||
object TextSelectionOverlay {
|
||||
|
||||
private const val TAG = "EdgeX"
|
||||
private const val AUTO_DISMISS_MS = 30000L
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var currentOverlay: WeakReference<View>? = null
|
||||
private var dismissRunnable: Runnable? = null
|
||||
|
||||
private var hintView: WeakReference<TextView>? = null
|
||||
private var copyButton: WeakReference<TextView>? = null
|
||||
private var selectAllButton: WeakReference<TextView>? = null
|
||||
|
||||
fun isShowing(): Boolean = currentOverlay?.get() != null
|
||||
|
||||
private class SelectableBlock(
|
||||
val text: String,
|
||||
val bounds: Rect,
|
||||
var selected: Boolean = false
|
||||
)
|
||||
|
||||
fun show(context: Context, blocks: List<UniversalCopyManager.TextBlock>) {
|
||||
handler.post {
|
||||
dismiss()
|
||||
try {
|
||||
val selectable = blocks.map { SelectableBlock(it.text, Rect(it.bounds)) }
|
||||
addOverlay(context, selectable)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: TextSelectionOverlay show failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
val runnable = dismissRunnable
|
||||
if (runnable != null) {
|
||||
handler.removeCallbacks(runnable)
|
||||
dismissRunnable = null
|
||||
}
|
||||
hintView = null
|
||||
copyButton = null
|
||||
selectAllButton = null
|
||||
val overlay = currentOverlay?.get() ?: return
|
||||
try {
|
||||
val wm = overlay.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
wm.removeViewImmediate(overlay)
|
||||
currentOverlay = null
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: TextSelectionOverlay dismiss failed, retrying: ${t.message}")
|
||||
handler.postDelayed({ dismiss() }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAccentColor(context: Context): Int {
|
||||
val presetId = queryConfig(context, AppConfig.THEME_PRESET)
|
||||
return when (presetId) {
|
||||
"custom" -> {
|
||||
val hex = queryConfig(context, AppConfig.THEME_CUSTOM_COLOR)
|
||||
runCatching { hex.toColorInt() }.getOrElse { "#326D32".toColorInt() }
|
||||
}
|
||||
"classic" -> "#00796B".toColorInt()
|
||||
"cedar" -> "#496B3D".toColorInt()
|
||||
"ocean" -> "#2F6F8F".toColorInt()
|
||||
"ember" -> "#C56B2A".toColorInt()
|
||||
else -> "#326D32".toColorInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryConfig(context: Context, key: String): String {
|
||||
val snapshot = HookConfigSnapshot.readFromHookFile()
|
||||
if (snapshot.containsKey(key)) return snapshot.getValue(key)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun addOverlay(context: Context, blocks: List<SelectableBlock>) {
|
||||
val density = context.resources.displayMetrics.density
|
||||
val dp = { value: Int -> (value * density + 0.5f).toInt() }
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
val accentColor = readAccentColor(context)
|
||||
|
||||
val root = object : FrameLayout(context) {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
|
||||
dismiss()
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}.apply {
|
||||
isFocusableInTouchMode = true
|
||||
isFocusable = true
|
||||
}
|
||||
|
||||
// Custom view for drawing and selecting text blocks
|
||||
val blocksView = TextBlocksView(context, blocks, density, accentColor,
|
||||
onEmptyTap = { dismiss() },
|
||||
onSelectionChanged = { updateToolbarState(blocks) }
|
||||
)
|
||||
root.addView(blocksView, FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
))
|
||||
|
||||
// Bottom toolbar
|
||||
val toolbar = createToolbar(context, blocks, density, accentColor)
|
||||
val toolbarParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||
bottomMargin = dp(48)
|
||||
}
|
||||
root.addView(toolbar, toolbarParams)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val windowParams = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.TYPE_SYSTEM_ERROR,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
PixelFormat.TRANSLUCENT
|
||||
)
|
||||
|
||||
wm.addView(root, windowParams)
|
||||
currentOverlay = WeakReference(root)
|
||||
|
||||
val runnable = Runnable { dismiss() }
|
||||
dismissRunnable = runnable
|
||||
handler.postDelayed(runnable, AUTO_DISMISS_MS)
|
||||
}
|
||||
|
||||
private fun createToolbar(
|
||||
context: Context,
|
||||
blocks: List<SelectableBlock>,
|
||||
density: Float,
|
||||
accentColor: Int
|
||||
): LinearLayout {
|
||||
val dp = { value: Int -> (value * density + 0.5f).toInt() }
|
||||
|
||||
val toolbar = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
background = GradientDrawable().apply {
|
||||
setColor("#E8222222".toColorInt())
|
||||
cornerRadius = dp(28).toFloat()
|
||||
}
|
||||
setPadding(dp(16), dp(10), dp(8), dp(10))
|
||||
elevation = dp(8).toFloat()
|
||||
// Consume touches so they don't reach the blocks view
|
||||
setOnTouchListener { _, _ -> true }
|
||||
}
|
||||
|
||||
// Hint / selected count
|
||||
val hint = TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.copy_tap_to_select)
|
||||
setTextColor("#AAAAAA".toColorInt())
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
}
|
||||
hintView = WeakReference(hint)
|
||||
toolbar.addView(hint, LinearLayout.LayoutParams(
|
||||
0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
|
||||
).apply { marginEnd = dp(12) })
|
||||
|
||||
// Select All button
|
||||
val selectAll = createToolbarButton(context, density,
|
||||
ModuleRes.getString(R.string.copy_select_all),
|
||||
"#3A3A3A".toColorInt(),
|
||||
"#CCCCCC".toColorInt()
|
||||
) {
|
||||
val allSelected = blocks.all { it.selected }
|
||||
blocks.forEach { it.selected = !allSelected }
|
||||
(currentOverlay?.get() as? ViewGroup)?.getChildAt(0)?.invalidate()
|
||||
updateToolbarState(blocks)
|
||||
}
|
||||
selectAllButton = WeakReference(selectAll)
|
||||
toolbar.addView(selectAll, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT, dp(34)
|
||||
).apply { marginEnd = dp(6) })
|
||||
|
||||
// Copy button
|
||||
val copy = createToolbarButton(context, density,
|
||||
ModuleRes.getString(R.string.copy_copy),
|
||||
accentColor,
|
||||
Color.WHITE
|
||||
) {
|
||||
val selected = blocks.filter { it.selected }
|
||||
if (selected.isNotEmpty()) {
|
||||
val text = selected.joinToString("\n") { it.text }
|
||||
copyToClipboard(context, text)
|
||||
val msg = ModuleRes.getString(R.string.copy_copied_count, selected.size)
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
copy.alpha = 0.4f
|
||||
copy.isEnabled = false
|
||||
copyButton = WeakReference(copy)
|
||||
toolbar.addView(copy, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT, dp(34)
|
||||
).apply { marginEnd = dp(6) })
|
||||
|
||||
// Close button
|
||||
val close = createToolbarButton(context, density,
|
||||
"×",
|
||||
"#3A3A3A".toColorInt(),
|
||||
"#CCCCCC".toColorInt()
|
||||
) { dismiss() }
|
||||
close.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
|
||||
toolbar.addView(close, LinearLayout.LayoutParams(dp(34), dp(34)))
|
||||
|
||||
return toolbar
|
||||
}
|
||||
|
||||
private fun createToolbarButton(
|
||||
context: Context,
|
||||
density: Float,
|
||||
label: String,
|
||||
bgColor: Int,
|
||||
textColor: Int,
|
||||
onClick: () -> Unit
|
||||
): TextView {
|
||||
val dp = { value: Int -> (value * density + 0.5f).toInt() }
|
||||
return TextView(context).apply {
|
||||
text = label
|
||||
setTextColor(textColor)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dp(14), 0, dp(14), 0)
|
||||
background = GradientDrawable().apply {
|
||||
setColor(bgColor)
|
||||
cornerRadius = dp(17).toFloat()
|
||||
}
|
||||
setOnClickListener { onClick() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateToolbarState(blocks: List<SelectableBlock>) {
|
||||
val count = blocks.count { it.selected }
|
||||
hintView?.get()?.text = if (count > 0) {
|
||||
ModuleRes.getString(R.string.copy_selected_count, count)
|
||||
} else {
|
||||
ModuleRes.getString(R.string.copy_tap_to_select)
|
||||
}
|
||||
copyButton?.get()?.apply {
|
||||
alpha = if (count > 0) 1f else 0.4f
|
||||
isEnabled = count > 0
|
||||
}
|
||||
selectAllButton?.get()?.text = if (blocks.all { it.selected }) {
|
||||
ModuleRes.getString(R.string.copy_deselect)
|
||||
} else {
|
||||
ModuleRes.getString(R.string.copy_select_all)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
try {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
cm.setPrimaryClip(ClipData.newPlainText("EdgeX", text))
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Clipboard copy failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom view that draws highlight boxes at text positions and handles tap selection.
|
||||
*/
|
||||
private class TextBlocksView(
|
||||
context: Context,
|
||||
private val blocks: List<SelectableBlock>,
|
||||
private val density: Float,
|
||||
accentColor: Int,
|
||||
private val onEmptyTap: () -> Unit,
|
||||
private val onSelectionChanged: () -> Unit
|
||||
) : View(context) {
|
||||
|
||||
private val cornerRadius = 4f * density
|
||||
private val tapPadding = (10 * density).toInt()
|
||||
private val tapSlopSq = (24 * density * 24 * density)
|
||||
|
||||
private val scrimPaint = Paint().apply {
|
||||
color = "#28000000".toColorInt()
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val normalFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = "#18FFFFFF".toColorInt()
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val normalStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = "#44FFFFFF".toColorInt()
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 1f * density
|
||||
}
|
||||
|
||||
private val selectedFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = (accentColor and 0x00FFFFFF) or (0x55 shl 24)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val selectedStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = (accentColor and 0x00FFFFFF) or (0xDD.toInt() shl 24)
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2f * density
|
||||
}
|
||||
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
private var downBlock: SelectableBlock? = null
|
||||
private val tmpRect = RectF()
|
||||
|
||||
// Offset between view-local coords and screen coords
|
||||
private var viewOffsetX = 0
|
||||
private var viewOffsetY = 0
|
||||
private val locationOnScreen = IntArray(2)
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
getLocationOnScreen(locationOnScreen)
|
||||
viewOffsetX = locationOnScreen[0]
|
||||
viewOffsetY = locationOnScreen[1]
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), scrimPaint)
|
||||
for (block in blocks) {
|
||||
// Convert screen coords → view-local coords for drawing
|
||||
tmpRect.set(
|
||||
block.bounds.left.toFloat() - viewOffsetX,
|
||||
block.bounds.top.toFloat() - viewOffsetY,
|
||||
block.bounds.right.toFloat() - viewOffsetX,
|
||||
block.bounds.bottom.toFloat() - viewOffsetY
|
||||
)
|
||||
if (block.selected) {
|
||||
canvas.drawRoundRect(tmpRect, cornerRadius, cornerRadius, selectedFillPaint)
|
||||
canvas.drawRoundRect(tmpRect, cornerRadius, cornerRadius, selectedStrokePaint)
|
||||
} else {
|
||||
canvas.drawRoundRect(tmpRect, cornerRadius, cornerRadius, normalFillPaint)
|
||||
canvas.drawRoundRect(tmpRect, cornerRadius, cornerRadius, normalStrokePaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downX = event.rawX
|
||||
downY = event.rawY
|
||||
downBlock = findBlockAt(event.rawX, event.rawY)
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val dx = event.rawX - downX
|
||||
val dy = event.rawY - downY
|
||||
if (dx * dx + dy * dy < tapSlopSq) {
|
||||
val upBlock = findBlockAt(event.rawX, event.rawY)
|
||||
if (upBlock != null && upBlock === downBlock) {
|
||||
upBlock.selected = !upBlock.selected
|
||||
invalidate()
|
||||
onSelectionChanged()
|
||||
} else if (upBlock == null && downBlock == null) {
|
||||
onEmptyTap()
|
||||
}
|
||||
}
|
||||
downBlock = null
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun findBlockAt(x: Float, y: Float): SelectableBlock? {
|
||||
val ix = x.toInt()
|
||||
val iy = y.toInt()
|
||||
var best: SelectableBlock? = null
|
||||
var bestArea = Int.MAX_VALUE
|
||||
for (block in blocks) {
|
||||
val b = block.bounds
|
||||
if (ix >= b.left - tapPadding && ix <= b.right + tapPadding &&
|
||||
iy >= b.top - tapPadding && iy <= b.bottom + tapPadding
|
||||
) {
|
||||
val area = b.width() * b.height()
|
||||
if (area < bestArea) {
|
||||
best = block
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
}
|
||||
314
app/src/main/java/com/fan/edgex/hook/DebugOverlayController.kt
Normal file
314
app/src/main/java/com/fan/edgex/hook/DebugOverlayController.kt
Normal file
@@ -0,0 +1,314 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.overlay.DrawerManager
|
||||
|
||||
internal class DebugOverlayController(
|
||||
private val config: ConfigAccess,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private enum class OverlayEdge {
|
||||
LEFT,
|
||||
RIGHT,
|
||||
TOP,
|
||||
BOTTOM,
|
||||
}
|
||||
|
||||
interface ConfigAccess {
|
||||
fun isGesturesEnabled(): Boolean
|
||||
fun isZoneEnabled(zone: String): Boolean
|
||||
fun isDebugEnabled(): Boolean
|
||||
fun getZoneThicknessDp(zone: String): Int
|
||||
fun getEdgeSplits(edge: String): Pair<Int, Int>
|
||||
}
|
||||
|
||||
private var initialized = false
|
||||
private var receiverRegistered = false
|
||||
private var systemUiContext: Context? = null
|
||||
private val debugViews = mutableListOf<DebugOverlayView>()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
systemUiContext = context
|
||||
registerScreenStateReceiver(context)
|
||||
try {
|
||||
addDebugOverlayView(context, OverlayEdge.LEFT)
|
||||
addDebugOverlayView(context, OverlayEdge.RIGHT)
|
||||
addDebugOverlayView(context, OverlayEdge.TOP)
|
||||
addDebugOverlayView(context, OverlayEdge.BOTTOM)
|
||||
} catch (t: Throwable) {
|
||||
log("Failed to add debug overlay views: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val debug = config.isDebugEnabled()
|
||||
val color = if (debug) 0x3300FF00.toInt() else 0x00000000
|
||||
debugViews.forEach { view ->
|
||||
view.updateDebugColor(color)
|
||||
view.updateWindowRegion()
|
||||
view.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerScreenStateReceiver(context: Context) {
|
||||
if (receiverRegistered) return
|
||||
receiverRegistered = true
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_SCREEN_OFF) {
|
||||
log("SCREEN_OFF in SystemUI — dismissing overlays")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
TextSelectionOverlay.dismiss()
|
||||
} catch (t: Throwable) {
|
||||
log("Failed to dismiss TextSelectionOverlay: ${t.message}")
|
||||
}
|
||||
try {
|
||||
DrawerManager.dismissDrawer()
|
||||
} catch (t: Throwable) {
|
||||
log("Failed to dismiss DrawerWindow: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
|
||||
log("Screen state receiver registered in SystemUI")
|
||||
} catch (e: Exception) {
|
||||
receiverRegistered = false
|
||||
log("Failed to register screen state receiver in SystemUI: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDebugOverlayView(context: Context, edge: OverlayEdge) {
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val thicknessPx = (12 * context.resources.displayMetrics.density).toInt()
|
||||
|
||||
val view = DebugOverlayView(context, edge, config)
|
||||
val params = WindowManager.LayoutParams(
|
||||
if (edge == OverlayEdge.LEFT || edge == OverlayEdge.RIGHT) thicknessPx else WindowManager.LayoutParams.MATCH_PARENT,
|
||||
if (edge == OverlayEdge.TOP || edge == OverlayEdge.BOTTOM) thicknessPx else WindowManager.LayoutParams.MATCH_PARENT,
|
||||
2027,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = overlayGravity(edge)
|
||||
}
|
||||
|
||||
wm.addView(view, params)
|
||||
debugViews.add(view)
|
||||
}
|
||||
|
||||
private fun overlayGravity(edge: OverlayEdge): Int =
|
||||
when (edge) {
|
||||
OverlayEdge.LEFT -> Gravity.START or Gravity.TOP
|
||||
OverlayEdge.RIGHT -> Gravity.END or Gravity.TOP
|
||||
OverlayEdge.TOP -> Gravity.TOP or Gravity.START
|
||||
OverlayEdge.BOTTOM -> Gravity.BOTTOM or Gravity.START
|
||||
}
|
||||
|
||||
private class DebugOverlayView(
|
||||
context: Context,
|
||||
private val edge: OverlayEdge,
|
||||
private val config: ConfigAccess,
|
||||
) : View(context) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val paint = android.graphics.Paint()
|
||||
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = Unit
|
||||
override fun onDisplayRemoved(displayId: Int) = Unit
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
handler.post {
|
||||
updateWindowRegion()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
paint.color = 0x00000000
|
||||
paint.style = android.graphics.Paint.Style.FILL
|
||||
setWillNotDraw(false)
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null)
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
displayManager.registerDisplayListener(displayListener, handler)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
displayManager.unregisterDisplayListener(displayListener)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: android.graphics.Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawColor(android.graphics.Color.TRANSPARENT, android.graphics.PorterDuff.Mode.CLEAR)
|
||||
|
||||
if (!config.isDebugEnabled()) return
|
||||
if (!config.isGesturesEnabled()) return
|
||||
|
||||
val h = height.toFloat()
|
||||
val w = width.toFloat()
|
||||
|
||||
when (edge) {
|
||||
OverlayEdge.LEFT -> {
|
||||
val splits = config.getEdgeSplits("left")
|
||||
val p1 = h * (splits.first / 100f)
|
||||
val p2 = h * (splits.second / 100f)
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
if (config.isZoneEnabled("left_top") || config.isZoneEnabled("left")) {
|
||||
val thick = if (config.isZoneEnabled("left_top")) config.getZoneThicknessDp("left_top") else 8
|
||||
canvas.drawRect(0f, 0f, thick * density, p1, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("left_mid") || config.isZoneEnabled("left")) {
|
||||
val thick = if (config.isZoneEnabled("left_mid")) config.getZoneThicknessDp("left_mid") else 8
|
||||
canvas.drawRect(0f, p1, thick * density, p2, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("left_bottom") || config.isZoneEnabled("left")) {
|
||||
val thick = if (config.isZoneEnabled("left_bottom")) config.getZoneThicknessDp("left_bottom") else 8
|
||||
canvas.drawRect(0f, p2, thick * density, h, paint)
|
||||
}
|
||||
}
|
||||
OverlayEdge.RIGHT -> {
|
||||
val splits = config.getEdgeSplits("right")
|
||||
val p1 = h * (splits.first / 100f)
|
||||
val p2 = h * (splits.second / 100f)
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
if (config.isZoneEnabled("right_top") || config.isZoneEnabled("right")) {
|
||||
val thick = if (config.isZoneEnabled("right_top")) config.getZoneThicknessDp("right_top") else 8
|
||||
canvas.drawRect(w - thick * density, 0f, w, p1, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("right_mid") || config.isZoneEnabled("right")) {
|
||||
val thick = if (config.isZoneEnabled("right_mid")) config.getZoneThicknessDp("right_mid") else 8
|
||||
canvas.drawRect(w - thick * density, p1, w, p2, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("right_bottom") || config.isZoneEnabled("right")) {
|
||||
val thick = if (config.isZoneEnabled("right_bottom")) config.getZoneThicknessDp("right_bottom") else 8
|
||||
canvas.drawRect(w - thick * density, p2, w, h, paint)
|
||||
}
|
||||
}
|
||||
OverlayEdge.TOP -> {
|
||||
val splits = config.getEdgeSplits("top")
|
||||
val p1 = w * (splits.first / 100f)
|
||||
val p2 = w * (splits.second / 100f)
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
if (config.isZoneEnabled("top_left") || config.isZoneEnabled("top")) {
|
||||
val thick = if (config.isZoneEnabled("top_left")) config.getZoneThicknessDp("top_left") else 8
|
||||
canvas.drawRect(0f, 0f, p1, thick * density, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("top_mid") || config.isZoneEnabled("top")) {
|
||||
val thick = if (config.isZoneEnabled("top_mid")) config.getZoneThicknessDp("top_mid") else 8
|
||||
canvas.drawRect(p1, 0f, p2, thick * density, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("top_right") || config.isZoneEnabled("top")) {
|
||||
val thick = if (config.isZoneEnabled("top_right")) config.getZoneThicknessDp("top_right") else 8
|
||||
canvas.drawRect(p2, 0f, w, thick * density, paint)
|
||||
}
|
||||
}
|
||||
OverlayEdge.BOTTOM -> {
|
||||
val splits = config.getEdgeSplits("bottom")
|
||||
val p1 = w * (splits.first / 100f)
|
||||
val p2 = w * (splits.second / 100f)
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
if (config.isZoneEnabled("bottom_left") || config.isZoneEnabled("bottom")) {
|
||||
val thick = if (config.isZoneEnabled("bottom_left")) config.getZoneThicknessDp("bottom_left") else 8
|
||||
canvas.drawRect(0f, h - thick * density, p1, h, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("bottom_mid") || config.isZoneEnabled("bottom")) {
|
||||
val thick = if (config.isZoneEnabled("bottom_mid")) config.getZoneThicknessDp("bottom_mid") else 8
|
||||
canvas.drawRect(p1, h - thick * density, p2, h, paint)
|
||||
}
|
||||
if (config.isZoneEnabled("bottom_right") || config.isZoneEnabled("bottom")) {
|
||||
val thick = if (config.isZoneEnabled("bottom_right")) config.getZoneThicknessDp("bottom_right") else 8
|
||||
canvas.drawRect(p2, h - thick * density, w, h, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWindowRegion() {
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val bounds = wm.currentWindowMetrics.bounds
|
||||
|
||||
val maxThicknessDp = when (edge) {
|
||||
OverlayEdge.LEFT -> listOf("left_top", "left_mid", "left_bottom").maxOfOrNull {
|
||||
val directThick = if (config.isZoneEnabled(it)) config.getZoneThicknessDp(it) else 8
|
||||
val fallbackThick = if (config.isZoneEnabled("left")) 8 else 0
|
||||
maxOf(directThick, fallbackThick)
|
||||
} ?: 8
|
||||
OverlayEdge.RIGHT -> listOf("right_top", "right_mid", "right_bottom").maxOfOrNull {
|
||||
val directThick = if (config.isZoneEnabled(it)) config.getZoneThicknessDp(it) else 8
|
||||
val fallbackThick = if (config.isZoneEnabled("right")) 8 else 0
|
||||
maxOf(directThick, fallbackThick)
|
||||
} ?: 8
|
||||
OverlayEdge.TOP -> listOf("top_left", "top_mid", "top_right").maxOfOrNull {
|
||||
val directThick = if (config.isZoneEnabled(it)) config.getZoneThicknessDp(it) else 8
|
||||
val fallbackThick = if (config.isZoneEnabled("top")) 8 else 0
|
||||
maxOf(directThick, fallbackThick)
|
||||
} ?: 8
|
||||
OverlayEdge.BOTTOM -> listOf("bottom_left", "bottom_mid", "bottom_right").maxOfOrNull {
|
||||
val directThick = if (config.isZoneEnabled(it)) config.getZoneThicknessDp(it) else 8
|
||||
val fallbackThick = if (config.isZoneEnabled("bottom")) 8 else 0
|
||||
maxOf(directThick, fallbackThick)
|
||||
} ?: 8
|
||||
}
|
||||
val thicknessPx = (maxThicknessDp * context.resources.displayMetrics.density).toInt()
|
||||
|
||||
try {
|
||||
val params = layoutParams as WindowManager.LayoutParams
|
||||
if (config.isDebugEnabled()) {
|
||||
visibility = VISIBLE
|
||||
params.gravity = when (edge) {
|
||||
OverlayEdge.LEFT -> Gravity.START or Gravity.TOP
|
||||
OverlayEdge.RIGHT -> Gravity.END or Gravity.TOP
|
||||
OverlayEdge.TOP -> Gravity.TOP or Gravity.START
|
||||
OverlayEdge.BOTTOM -> Gravity.BOTTOM or Gravity.START
|
||||
}
|
||||
params.x = 0
|
||||
params.y = 0
|
||||
params.width =
|
||||
if (edge == OverlayEdge.LEFT || edge == OverlayEdge.RIGHT) thicknessPx else bounds.width()
|
||||
params.height =
|
||||
if (edge == OverlayEdge.TOP || edge == OverlayEdge.BOTTOM) thicknessPx else bounds.height()
|
||||
} else {
|
||||
visibility = GONE
|
||||
params.width = 0
|
||||
params.height = 0
|
||||
}
|
||||
wm.updateViewLayout(this, params)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDebugColor(color: Int) {
|
||||
paint.color = color
|
||||
}
|
||||
}
|
||||
}
|
||||
581
app/src/main/java/com/fan/edgex/hook/EdgeGestureDetector.kt
Normal file
581
app/src/main/java/com/fan/edgex/hook/EdgeGestureDetector.kt
Normal file
@@ -0,0 +1,581 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.view.MotionEvent
|
||||
import android.view.WindowManager
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.AppConfig.SUB_GESTURE_ACTION
|
||||
import kotlin.math.abs
|
||||
|
||||
internal class EdgeGestureDetector(
|
||||
private val handoff: NativeTouchHandoff,
|
||||
private val callbacks: Callbacks,
|
||||
private val handlerProvider: () -> Handler,
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
interface Callbacks {
|
||||
fun isZoneEnabled(zone: String): Boolean
|
||||
fun resolveAction(zone: String, gestureType: String): String
|
||||
fun dispatchAction(zone: String, gestureType: String, context: Context, touchX: Float, touchY: Float)
|
||||
fun performContinuousAdjustment(action: String, context: Context, up: Boolean)
|
||||
fun isGlobalCopyModeActive(): Boolean
|
||||
fun log(message: String)
|
||||
fun showPie(context: Context, anchorX: Float, anchorY: Float, edge: String)
|
||||
fun updatePie(x: Float, y: Float)
|
||||
fun commitPie(context: Context)
|
||||
fun cancelPie()
|
||||
fun getZoneThicknessDp(zone: String): Int
|
||||
fun getEdgeSplits(edge: String): Pair<Int, Int>
|
||||
}
|
||||
|
||||
private enum class Edge { LEFT, RIGHT, TOP, BOTTOM }
|
||||
|
||||
private enum class AdjustmentAxis { HORIZONTAL, VERTICAL }
|
||||
|
||||
private data class EdgeZoneMatch(
|
||||
val zone: String,
|
||||
val edge: Edge,
|
||||
)
|
||||
|
||||
private data class GestureSession(
|
||||
val zone: String,
|
||||
val edge: Edge,
|
||||
val downX: Float,
|
||||
val downY: Float,
|
||||
var targetX: Float,
|
||||
var targetY: Float,
|
||||
val startedAtMs: Long,
|
||||
val handoff: NativeTouchHandoff.Session,
|
||||
var isSwiping: Boolean = false,
|
||||
var continuousAction: String? = null,
|
||||
var adjustmentAxis: AdjustmentAxis? = null,
|
||||
var lastAdjustCoord: Float = 0f,
|
||||
// Sub-gesture state
|
||||
var subGestureMode: Boolean = false,
|
||||
var subGestureAnchorX: Float = 0f, // tracks the farthest point in the primary direction
|
||||
var subGestureAnchorY: Float = 0f,
|
||||
var subGestureAnchorEventTime: Long = 0L,
|
||||
var subGestureAnchorLocked: Boolean = false,
|
||||
var primaryGesture: String = "",
|
||||
// Pie mode: finger holds to select, release to execute
|
||||
var pieMode: Boolean = false,
|
||||
)
|
||||
|
||||
private var activeSession: GestureSession? = null
|
||||
private var lastTapUpTime = 0L
|
||||
private var lastTapZone: String? = null
|
||||
private var pendingClickRunnable: Runnable? = null
|
||||
private var pendingLongPressRunnable: Runnable? = null
|
||||
|
||||
fun handle(event: MotionEvent, context: Context): Boolean {
|
||||
activeSession?.let { session ->
|
||||
if (!session.pieMode && nowMillis() - session.startedAtMs > GESTURE_TIMEOUT_MS) reset()
|
||||
}
|
||||
|
||||
return when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> handleDown(event, context)
|
||||
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
reset()
|
||||
false
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> handleMove(event, context)
|
||||
MotionEvent.ACTION_UP -> handleUp(event, context)
|
||||
MotionEvent.ACTION_CANCEL -> handleCancel(event, context)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
val previousSession = activeSession
|
||||
activeSession = null
|
||||
|
||||
if (previousSession?.pieMode == true) {
|
||||
callbacks.cancelPie()
|
||||
}
|
||||
|
||||
pendingLongPressRunnable?.let { handlerProvider().removeCallbacks(it) }
|
||||
pendingLongPressRunnable = null
|
||||
|
||||
previousSession?.let { handoff.dispose(it.handoff) }
|
||||
}
|
||||
|
||||
private fun handleDown(event: MotionEvent, context: Context): Boolean {
|
||||
if (callbacks.isGlobalCopyModeActive()) {
|
||||
if (activeSession != null) reset()
|
||||
return false
|
||||
}
|
||||
|
||||
val zoneMatch = resolveEdgeZone(context, event.rawX, event.rawY) ?: run {
|
||||
reset()
|
||||
return false
|
||||
}
|
||||
|
||||
clearPendingSingleClick()
|
||||
|
||||
val session = GestureSession(
|
||||
zone = zoneMatch.zone,
|
||||
edge = zoneMatch.edge,
|
||||
downX = event.rawX,
|
||||
downY = event.rawY,
|
||||
targetX = event.rawX,
|
||||
targetY = event.rawY,
|
||||
startedAtMs = nowMillis(),
|
||||
handoff = handoff.begin(event),
|
||||
)
|
||||
activeSession = session
|
||||
startLongPressTimer(context, session)
|
||||
return session.handoff.consumeStream
|
||||
}
|
||||
|
||||
private fun handleMove(event: MotionEvent, context: Context): Boolean {
|
||||
val session = activeSession ?: return false
|
||||
updateTargetPoint(session, event.rawX, event.rawY)
|
||||
|
||||
if (session.pieMode) {
|
||||
callbacks.updatePie(event.rawX, event.rawY)
|
||||
return session.handoff.consumeStream
|
||||
}
|
||||
|
||||
if (session.subGestureMode) {
|
||||
updateSubGestureAnchor(session, event.rawX, event.rawY, event.eventTime)
|
||||
return session.handoff.consumeStream
|
||||
}
|
||||
|
||||
if (!session.isSwiping) {
|
||||
val dx = event.rawX - session.downX
|
||||
val dy = event.rawY - session.downY
|
||||
if ((dx * dx) + (dy * dy) > TOUCH_SLOP_SQ) {
|
||||
session.isSwiping = true
|
||||
val gestureType = resolveSwipeGesture(dx, dy)
|
||||
val action = callbacks.resolveAction(session.zone, gestureType)
|
||||
|
||||
when {
|
||||
action == SUB_GESTURE_ACTION -> {
|
||||
handoff.cancel(session.handoff, context)
|
||||
cancelLongPressTimer()
|
||||
session.subGestureMode = true
|
||||
session.subGestureAnchorX = event.rawX
|
||||
session.subGestureAnchorY = event.rawY
|
||||
session.subGestureAnchorEventTime = event.eventTime
|
||||
session.primaryGesture = gestureType
|
||||
}
|
||||
action == AppConfig.PIE_ACTION -> {
|
||||
handoff.cancel(session.handoff, context)
|
||||
cancelLongPressTimer()
|
||||
session.pieMode = true
|
||||
callbacks.showPie(context, session.downX, session.downY, session.edge.name.lowercase())
|
||||
}
|
||||
hasConfiguredAction(action) -> {
|
||||
handoff.cancel(session.handoff, context)
|
||||
cancelLongPressTimer()
|
||||
if (isContinuousAdjustmentAction(action)) {
|
||||
session.continuousAction = action
|
||||
session.adjustmentAxis = resolveAdjustmentAxis(gestureType)
|
||||
session.lastAdjustCoord =
|
||||
resolveAdjustCoord(session.adjustmentAxis ?: AdjustmentAxis.VERTICAL, event.rawX, event.rawY)
|
||||
} else {
|
||||
callbacks.dispatchAction(session.zone, gestureType, context, session.targetX, session.targetY)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
handoff.resume(session.handoff, context, event)
|
||||
cancelLongPressTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val continuousAction = session.continuousAction
|
||||
when {
|
||||
continuousAction != null ->
|
||||
handleContinuousAdjustment(session, continuousAction, context, event.rawX, event.rawY)
|
||||
handoff.shouldProxyToNative(session.handoff) -> handoff.forwardToNative(session.handoff, context, event)
|
||||
}
|
||||
}
|
||||
|
||||
return session.handoff.consumeStream
|
||||
}
|
||||
|
||||
private fun handleUp(event: MotionEvent, context: Context): Boolean {
|
||||
val session = activeSession ?: return false
|
||||
|
||||
if (session.pieMode) {
|
||||
callbacks.commitPie(context)
|
||||
return finishSession()
|
||||
}
|
||||
|
||||
if (session.subGestureMode) {
|
||||
updateSubGestureAnchor(session, event.rawX, event.rawY, event.eventTime)
|
||||
|
||||
// The anchor follows the first swipe to its farthest primary point. Child
|
||||
// direction is selected only by movement after that point, so the first swipe
|
||||
// itself does not get mistaken for a same-direction child gesture.
|
||||
val anchorDx = event.rawX - session.subGestureAnchorX
|
||||
val anchorDy = event.rawY - session.subGestureAnchorY
|
||||
val anchorDistanceSq = anchorDx * anchorDx + anchorDy * anchorDy
|
||||
val subDirection = when {
|
||||
anchorDistanceSq >= SUB_GESTURE_SLOP_SQ -> resolveSwipeGesture(anchorDx, anchorDy)
|
||||
else -> "hold"
|
||||
}
|
||||
val subGestureType = "${session.primaryGesture}_sub_${subDirection}"
|
||||
val childAction = callbacks.resolveAction(session.zone, subGestureType)
|
||||
if (hasConfiguredAction(childAction)) {
|
||||
callbacks.dispatchAction(session.zone, subGestureType, context, session.targetX, session.targetY)
|
||||
}
|
||||
return finishSession()
|
||||
}
|
||||
|
||||
if (session.isSwiping) {
|
||||
handoff.forwardToNative(session.handoff, context, event)
|
||||
return finishSession()
|
||||
}
|
||||
|
||||
val clickAction = callbacks.resolveAction(session.zone, "click")
|
||||
val hasClickAction = hasConfiguredAction(clickAction)
|
||||
val hasDoubleClickAction = hasConfiguredAction(callbacks.resolveAction(session.zone, "double_click"))
|
||||
|
||||
if (hasClickAction || hasDoubleClickAction) {
|
||||
handoff.cancel(session.handoff, context)
|
||||
cancelLongPressTimer()
|
||||
|
||||
if (!hasDoubleClickAction) {
|
||||
callbacks.dispatchAction(session.zone, "click", context, session.targetX, session.targetY)
|
||||
} else {
|
||||
resolveTapAction(session, context, event.eventTime)
|
||||
}
|
||||
} else {
|
||||
handoff.resume(session.handoff, context, event)
|
||||
cancelLongPressTimer()
|
||||
}
|
||||
|
||||
return finishSession()
|
||||
}
|
||||
|
||||
private fun handleCancel(event: MotionEvent, context: Context): Boolean {
|
||||
val session = activeSession ?: return false
|
||||
if (!session.handoff.nativeStreamCancelled) {
|
||||
handoff.resume(session.handoff, context, event)
|
||||
}
|
||||
reset()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun finishSession(): Boolean {
|
||||
val consumed = activeSession?.handoff?.consumeStream ?: false
|
||||
reset()
|
||||
return consumed
|
||||
}
|
||||
|
||||
private fun startLongPressTimer(context: Context, session: GestureSession) {
|
||||
cancelLongPressTimer()
|
||||
val runnable = Runnable {
|
||||
pendingLongPressRunnable = null
|
||||
if (activeSession !== session) return@Runnable
|
||||
if (session.isSwiping) return@Runnable
|
||||
|
||||
val action = callbacks.resolveAction(session.zone, "long_press")
|
||||
when {
|
||||
action == SUB_GESTURE_ACTION -> {
|
||||
handoff.cancel(session.handoff, context)
|
||||
session.subGestureMode = true
|
||||
session.subGestureAnchorX = session.targetX
|
||||
session.subGestureAnchorY = session.targetY
|
||||
session.subGestureAnchorEventTime = nowMillis()
|
||||
session.primaryGesture = "long_press"
|
||||
}
|
||||
hasConfiguredAction(action) -> {
|
||||
handoff.cancel(session.handoff, context)
|
||||
callbacks.dispatchAction(session.zone, "long_press", context, session.targetX, session.targetY)
|
||||
}
|
||||
else -> {
|
||||
handoff.dispatchSavedDownIfNeeded(session.handoff, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingLongPressRunnable = runnable
|
||||
handlerProvider().postDelayed(runnable, LONG_PRESS_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun cancelLongPressTimer() {
|
||||
pendingLongPressRunnable?.let { handlerProvider().removeCallbacks(it) }
|
||||
pendingLongPressRunnable = null
|
||||
}
|
||||
|
||||
private fun clearPendingSingleClick() {
|
||||
pendingClickRunnable?.let { handlerProvider().removeCallbacks(it) }
|
||||
pendingClickRunnable = null
|
||||
}
|
||||
|
||||
private fun resolveTapAction(session: GestureSession, context: Context, eventTime: Long) {
|
||||
val zone = session.zone
|
||||
val capturedX = session.targetX
|
||||
val capturedY = session.targetY
|
||||
val timeSinceLast = eventTime - lastTapUpTime
|
||||
|
||||
if (timeSinceLast < DOUBLE_TAP_TIMEOUT_MS && lastTapZone == zone) {
|
||||
clearPendingSingleClick()
|
||||
lastTapUpTime = 0L
|
||||
lastTapZone = null
|
||||
callbacks.dispatchAction(zone, "double_click", context, capturedX, capturedY)
|
||||
return
|
||||
}
|
||||
|
||||
lastTapUpTime = eventTime
|
||||
lastTapZone = zone
|
||||
val runnable = Runnable {
|
||||
pendingClickRunnable = null
|
||||
lastTapUpTime = 0L
|
||||
lastTapZone = null
|
||||
callbacks.dispatchAction(zone, "click", context, capturedX, capturedY)
|
||||
}
|
||||
pendingClickRunnable = runnable
|
||||
handlerProvider().postDelayed(runnable, DOUBLE_TAP_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun resolveEdgeZone(context: Context, x: Float, y: Float): EdgeZoneMatch? {
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val bounds = windowManager.currentWindowMetrics.bounds
|
||||
val width = bounds.width().toFloat()
|
||||
val height = bounds.height().toFloat()
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
val candidates = buildList {
|
||||
resolveEdgeMatch(Edge.LEFT, x, y, width, height, density)?.let { add(it) }
|
||||
resolveEdgeMatch(Edge.RIGHT, x, y, width, height, density)?.let { add(it) }
|
||||
resolveEdgeMatch(Edge.TOP, x, y, width, height, density)?.let { add(it) }
|
||||
resolveEdgeMatch(Edge.BOTTOM, x, y, width, height, density)?.let { add(it) }
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
val closest = candidates.sortedBy { it.second }.firstOrNull()
|
||||
return closest?.first
|
||||
}
|
||||
|
||||
private fun resolveEdgeMatch(
|
||||
edge: Edge,
|
||||
x: Float,
|
||||
y: Float,
|
||||
width: Float,
|
||||
height: Float,
|
||||
density: Float
|
||||
): Pair<EdgeZoneMatch, Float>? {
|
||||
val zone = resolveZoneForEdge(edge, x, y, width, height)
|
||||
val isDirectEnabled = callbacks.isZoneEnabled(zone)
|
||||
val fallbackZone = AppConfig.fallbackEdgeZone(zone)
|
||||
val isFallbackEnabled = fallbackZone != null && callbacks.isZoneEnabled(fallbackZone)
|
||||
|
||||
if (!isDirectEnabled && !isFallbackEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
val thicknessDp = if (isDirectEnabled) {
|
||||
callbacks.getZoneThicknessDp(zone)
|
||||
} else {
|
||||
callbacks.getZoneThicknessDp(fallbackZone!!)
|
||||
}
|
||||
val thicknessPx = thicknessDp * density
|
||||
|
||||
val (inside, distance) = when (edge) {
|
||||
Edge.LEFT -> (x < thicknessPx) to x
|
||||
Edge.RIGHT -> (x > width - thicknessPx) to (width - x)
|
||||
Edge.TOP -> (y < thicknessPx) to y
|
||||
Edge.BOTTOM -> (y > height - thicknessPx) to (height - y)
|
||||
}
|
||||
|
||||
if (!inside) return null
|
||||
|
||||
val matchedZone = if (isDirectEnabled) zone else fallbackZone!!
|
||||
return Pair(EdgeZoneMatch(matchedZone, matchedZone.substringBefore("_").let { edgeName ->
|
||||
when (edgeName) {
|
||||
"left" -> Edge.LEFT
|
||||
"right" -> Edge.RIGHT
|
||||
"top" -> Edge.TOP
|
||||
"bottom" -> Edge.BOTTOM
|
||||
else -> edge
|
||||
}
|
||||
}), distance)
|
||||
}
|
||||
|
||||
private fun resolveZoneForEdge(edge: Edge, x: Float, y: Float, width: Float, height: Float): String =
|
||||
when (edge) {
|
||||
Edge.LEFT -> "left_${resolveVerticalSegment("left", y, height)}"
|
||||
Edge.RIGHT -> "right_${resolveVerticalSegment("right", y, height)}"
|
||||
Edge.TOP -> "top_${resolveHorizontalSegment("top", x, width)}"
|
||||
Edge.BOTTOM -> "bottom_${resolveHorizontalSegment("bottom", x, width)}"
|
||||
}
|
||||
|
||||
private fun resolveVerticalSegment(edgeKey: String, y: Float, height: Float): String {
|
||||
val splits = callbacks.getEdgeSplits(edgeKey)
|
||||
val p1 = height * (splits.first / 100f)
|
||||
val p2 = height * (splits.second / 100f)
|
||||
return when {
|
||||
y < p1 -> "top"
|
||||
y < p2 -> "mid"
|
||||
else -> "bottom"
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveHorizontalSegment(edgeKey: String, x: Float, width: Float): String {
|
||||
val splits = callbacks.getEdgeSplits(edgeKey)
|
||||
val p1 = width * (splits.first / 100f)
|
||||
val p2 = width * (splits.second / 100f)
|
||||
return when {
|
||||
x < p1 -> "left"
|
||||
x < p2 -> "mid"
|
||||
else -> "right"
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAdjustmentAxis(gestureType: String): AdjustmentAxis =
|
||||
when (gestureType) {
|
||||
"swipe_left", "swipe_right" -> AdjustmentAxis.HORIZONTAL
|
||||
else -> AdjustmentAxis.VERTICAL
|
||||
}
|
||||
|
||||
private fun resolveAdjustCoord(axis: AdjustmentAxis, x: Float, y: Float): Float =
|
||||
if (axis == AdjustmentAxis.HORIZONTAL) x else y
|
||||
|
||||
private fun updateSubGestureAnchor(session: GestureSession, x: Float, y: Float, eventTime: Long) {
|
||||
val advanced = when (session.primaryGesture) {
|
||||
"swipe_left" -> {
|
||||
if (x < session.subGestureAnchorX) {
|
||||
updatePrimaryAnchorOrLock(session, x, y, eventTime)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"swipe_right" -> {
|
||||
if (x > session.subGestureAnchorX) {
|
||||
updatePrimaryAnchorOrLock(session, x, y, eventTime)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"swipe_up" -> {
|
||||
if (y < session.subGestureAnchorY) {
|
||||
updatePrimaryAnchorOrLock(session, x, y, eventTime)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"swipe_down" -> {
|
||||
if (y > session.subGestureAnchorY) {
|
||||
updatePrimaryAnchorOrLock(session, x, y, eventTime)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!advanced) {
|
||||
lockSubGestureAnchorOnTurn(session, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePrimaryAnchorOrLock(session: GestureSession, x: Float, y: Float, eventTime: Long) {
|
||||
if (session.subGestureAnchorLocked) return
|
||||
|
||||
val elapsedSinceAnchor = eventTime - session.subGestureAnchorEventTime
|
||||
if (elapsedSinceAnchor >= SUB_GESTURE_SEGMENT_PAUSE_MS) {
|
||||
session.subGestureAnchorLocked = true
|
||||
return
|
||||
}
|
||||
|
||||
session.subGestureAnchorX = x
|
||||
session.subGestureAnchorY = y
|
||||
session.subGestureAnchorEventTime = eventTime
|
||||
}
|
||||
|
||||
private fun lockSubGestureAnchorOnTurn(session: GestureSession, x: Float, y: Float) {
|
||||
if (session.subGestureAnchorLocked) return
|
||||
val dx = x - session.subGestureAnchorX
|
||||
val dy = y - session.subGestureAnchorY
|
||||
if ((dx * dx) + (dy * dy) >= SUB_GESTURE_SLOP_SQ) {
|
||||
session.subGestureAnchorLocked = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTargetPoint(session: GestureSession, x: Float, y: Float) {
|
||||
when (session.edge) {
|
||||
Edge.LEFT -> {
|
||||
if (x > session.targetX) {
|
||||
session.targetX = x
|
||||
session.targetY = y
|
||||
}
|
||||
}
|
||||
Edge.RIGHT -> {
|
||||
if (x < session.targetX) {
|
||||
session.targetX = x
|
||||
session.targetY = y
|
||||
}
|
||||
}
|
||||
Edge.TOP -> {
|
||||
if (y > session.targetY) {
|
||||
session.targetX = x
|
||||
session.targetY = y
|
||||
}
|
||||
}
|
||||
Edge.BOTTOM -> {
|
||||
if (y < session.targetY) {
|
||||
session.targetX = x
|
||||
session.targetY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSwipeGesture(dx: Float, dy: Float): String =
|
||||
when {
|
||||
abs(dx) > abs(dy) -> if (dx < 0) "swipe_left" else "swipe_right"
|
||||
else -> if (dy < 0) "swipe_up" else "swipe_down"
|
||||
}
|
||||
|
||||
private fun hasConfiguredAction(action: String): Boolean =
|
||||
action.isNotEmpty() && action != "none"
|
||||
|
||||
private fun isContinuousAdjustmentAction(action: String): Boolean =
|
||||
action == "brightness_up" || action == "brightness_down" ||
|
||||
action == "volume_up" || action == "volume_down"
|
||||
|
||||
private fun handleContinuousAdjustment(
|
||||
session: GestureSession,
|
||||
action: String,
|
||||
context: Context,
|
||||
currentX: Float,
|
||||
currentY: Float,
|
||||
) {
|
||||
val axis = session.adjustmentAxis ?: return
|
||||
val currentCoord = resolveAdjustCoord(axis, currentX, currentY)
|
||||
val rawDelta = currentCoord - session.lastAdjustCoord
|
||||
val effectiveDelta = if (axis == AdjustmentAxis.HORIZONTAL) rawDelta else -rawDelta
|
||||
if (abs(effectiveDelta) < CONTINUOUS_STEP_PX) return
|
||||
|
||||
val steps = (abs(effectiveDelta) / CONTINUOUS_STEP_PX).toInt()
|
||||
val up = effectiveDelta > 0
|
||||
repeat(steps) {
|
||||
handlerProvider().post {
|
||||
callbacks.performContinuousAdjustment(action, context, up)
|
||||
}
|
||||
}
|
||||
session.lastAdjustCoord += steps * CONTINUOUS_STEP_PX * (if (rawDelta > 0) 1 else -1)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONTINUOUS_STEP_PX = 30
|
||||
const val GESTURE_TIMEOUT_MS = 5000L
|
||||
const val DOUBLE_TAP_TIMEOUT_MS = 300L
|
||||
const val LONG_PRESS_TIMEOUT_MS = 500L
|
||||
const val EDGE_THRESHOLD_DP = 8f
|
||||
const val TOUCH_SLOP_PX = 24f
|
||||
const val TOUCH_SLOP_SQ = TOUCH_SLOP_PX * TOUCH_SLOP_PX
|
||||
const val SUB_GESTURE_SLOP_PX = 40f
|
||||
const val SUB_GESTURE_SLOP_SQ = SUB_GESTURE_SLOP_PX * SUB_GESTURE_SLOP_PX
|
||||
const val SUB_GESTURE_SEGMENT_PAUSE_MS = 120L
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/com/fan/edgex/hook/FlashlightManager.kt
Normal file
155
app/src/main/java/com/fan/edgex/hook/FlashlightManager.kt
Normal file
@@ -0,0 +1,155 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Icon
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.os.Handler
|
||||
import android.widget.Toast
|
||||
import com.fan.edgex.R
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
object FlashlightManager {
|
||||
|
||||
const val ACTION_TURN_OFF = "com.fan.edgex.ACTION_TURN_OFF_FLASHLIGHT"
|
||||
private const val NOTIFICATION_ID = 7392
|
||||
private const val CHANNEL_ID = "edgex_flashlight"
|
||||
|
||||
@Volatile private var torchOn = false
|
||||
@Volatile private var cameraId: String? = null
|
||||
|
||||
fun initialize(context: Context, handler: Handler) {
|
||||
try {
|
||||
val cm = context.getSystemService(CameraManager::class.java) ?: return
|
||||
resolveBackCamera(cm)
|
||||
cm.registerTorchCallback(object : CameraManager.TorchCallback() {
|
||||
override fun onTorchModeChanged(id: String, enabled: Boolean) {
|
||||
if (id == cameraId) torchOn = enabled
|
||||
}
|
||||
override fun onTorchModeUnavailable(id: String) {
|
||||
if (id == cameraId) torchOn = false
|
||||
}
|
||||
}, handler)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: FlashlightManager.initialize failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle(context: Context, handler: Handler) {
|
||||
try {
|
||||
val cm = context.getSystemService(CameraManager::class.java) ?: return
|
||||
val id = cameraId ?: resolveBackCamera(cm) ?: return
|
||||
val target = !torchOn
|
||||
cm.setTorchMode(id, target)
|
||||
if (target) {
|
||||
handler.post { toast(context, ModuleRes.getString(R.string.flashlight_toast_on)) }
|
||||
showNotification(context)
|
||||
} else {
|
||||
handler.post { toast(context, ModuleRes.getString(R.string.flashlight_toast_off)) }
|
||||
cancelNotification(context)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: FlashlightManager.toggle failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun turnOff(context: Context, handler: Handler) {
|
||||
try {
|
||||
val cm = context.getSystemService(CameraManager::class.java) ?: return
|
||||
val id = cameraId ?: resolveBackCamera(cm) ?: return
|
||||
cm.setTorchMode(id, false)
|
||||
handler.post { toast(context, ModuleRes.getString(R.string.flashlight_toast_off)) }
|
||||
cancelNotification(context)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: FlashlightManager.turnOff failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: Context, text: String) {
|
||||
try { Toast.makeText(context, "EdgeX: $text", Toast.LENGTH_SHORT).show() } catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
private fun showNotification(context: Context) {
|
||||
try {
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
ensureChannel(nm)
|
||||
val pi = PendingIntent.getBroadcast(
|
||||
context, 0,
|
||||
Intent(ACTION_TURN_OFF),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(buildIcon())
|
||||
.setContentTitle("EdgeX")
|
||||
.setContentText(ModuleRes.getString(R.string.flashlight_notification_text))
|
||||
.setContentIntent(pi)
|
||||
.setDeleteIntent(pi)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: FlashlightManager.showNotification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelNotification(context: Context) {
|
||||
try {
|
||||
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: FlashlightManager.cancelNotification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(nm: NotificationManager) {
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
ModuleRes.getString(R.string.flashlight_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { setShowBadge(false) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildIcon(): Icon {
|
||||
val drawable = ModuleRes.getDrawable(R.drawable.ic_flashlight)
|
||||
if (drawable != null) {
|
||||
val size = 96
|
||||
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
drawable.setBounds(0, 0, size, size)
|
||||
drawable.draw(canvas)
|
||||
return Icon.createWithBitmap(bmp)
|
||||
}
|
||||
return Icon.createWithResource("android", android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
|
||||
private fun resolveBackCamera(cm: CameraManager): String? {
|
||||
if (cameraId != null) return cameraId
|
||||
for (id in cm.cameraIdList) {
|
||||
try {
|
||||
val chars = cm.getCameraCharacteristics(id)
|
||||
val hasFlash = chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
|
||||
val facing = chars.get(CameraCharacteristics.LENS_FACING)
|
||||
if (hasFlash && facing == CameraCharacteristics.LENS_FACING_BACK) {
|
||||
cameraId = id; return id
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
for (id in cm.cameraIdList) {
|
||||
try {
|
||||
if (cm.getCameraCharacteristics(id).get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true) {
|
||||
cameraId = id; return id
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
106
app/src/main/java/com/fan/edgex/hook/GameModeManager.kt
Normal file
106
app/src/main/java/com/fan/edgex/hook/GameModeManager.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Handler
|
||||
import android.widget.Toast
|
||||
import com.fan.edgex.R
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
object GameModeManager {
|
||||
|
||||
const val ACTION_DISABLE = "com.fan.edgex.ACTION_DISABLE_GAME_MODE"
|
||||
private const val CHANNEL_ID = "edgex_game_mode_2"
|
||||
private const val NOTIFICATION_ID = 7391
|
||||
|
||||
@Volatile var isActive = false
|
||||
private set
|
||||
|
||||
fun enable(context: Context, handler: Handler) {
|
||||
if (isActive) return
|
||||
isActive = true
|
||||
handler.post {
|
||||
try {
|
||||
Toast.makeText(context, "EdgeX: ${ModuleRes.getString(R.string.game_mode_toast_on)}", Toast.LENGTH_SHORT).show()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
showNotification(context)
|
||||
}
|
||||
|
||||
fun disable(context: Context, handler: Handler) {
|
||||
if (!isActive) return
|
||||
isActive = false
|
||||
handler.post {
|
||||
try {
|
||||
Toast.makeText(context, "EdgeX: ${ModuleRes.getString(R.string.game_mode_toast_off)}", Toast.LENGTH_SHORT).show()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
cancelNotification(context)
|
||||
}
|
||||
|
||||
private fun showNotification(context: Context) {
|
||||
try {
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
ensureChannel(nm)
|
||||
|
||||
val disablePi = PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(ACTION_DISABLE),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notification = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(buildIcon())
|
||||
.setContentTitle("EdgeX")
|
||||
.setContentText(ModuleRes.getString(R.string.game_mode_notification_text))
|
||||
.setContentIntent(disablePi)
|
||||
.setDeleteIntent(disablePi)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: GameModeManager.showNotification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelNotification(context: Context) {
|
||||
try {
|
||||
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: GameModeManager.cancelNotification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(nm: NotificationManager) {
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
ModuleRes.getString(R.string.game_mode_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { setShowBadge(false) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildIcon(): Icon {
|
||||
val drawable = ModuleRes.getDrawable(R.drawable.ic_game_mode)
|
||||
if (drawable != null) {
|
||||
val size = 96
|
||||
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
drawable.setBounds(0, 0, size, size)
|
||||
drawable.draw(canvas)
|
||||
return Icon.createWithBitmap(bmp)
|
||||
}
|
||||
return Icon.createWithResource("android", android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
}
|
||||
1047
app/src/main/java/com/fan/edgex/hook/GestureActionDispatcher.kt
Normal file
1047
app/src/main/java/com/fan/edgex/hook/GestureActionDispatcher.kt
Normal file
File diff suppressed because it is too large
Load Diff
438
app/src/main/java/com/fan/edgex/hook/GestureManager.kt
Normal file
438
app/src/main/java/com/fan/edgex/hook/GestureManager.kt
Normal file
@@ -0,0 +1,438 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.GestureZoneGeometryCalculator
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import com.fan.edgex.config.ModuleActivationState
|
||||
import com.fan.edgex.overlay.PanelOverlayManager
|
||||
import com.fan.edgex.overlay.PieManager
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
object GestureManager {
|
||||
|
||||
private const val TAG = "EdgeX"
|
||||
|
||||
private var systemContext: Context? = null
|
||||
private var windowAnchor: View? = null
|
||||
|
||||
private var screenStateReceiverRegistered = false
|
||||
private var systemConfigReceiverRegistered = false
|
||||
private var keyManagerInitialized = false
|
||||
private var activeEdgeLightingNotificationKey: String? = null
|
||||
|
||||
private var mHandler: Handler? = null
|
||||
|
||||
private val nativeTouchHandoff = NativeTouchHandoff { message ->
|
||||
log(message)
|
||||
}
|
||||
private val configRepository = HookConfigRepository(
|
||||
updateKeyConfig = KeyManager::updateConfig,
|
||||
log = ::log,
|
||||
)
|
||||
private val actionDispatcher by lazy {
|
||||
GestureActionDispatcher(
|
||||
resolveConfig = configRepository::get,
|
||||
handlerProvider = ::mainHandler,
|
||||
log = ::log,
|
||||
)
|
||||
}
|
||||
private val debugOverlayController = DebugOverlayController(
|
||||
config = object : DebugOverlayController.ConfigAccess {
|
||||
override fun isGesturesEnabled(): Boolean = configRepository.isGesturesEnabled()
|
||||
override fun isZoneEnabled(zone: String): Boolean = configRepository.isZoneEnabled(zone)
|
||||
override fun isDebugEnabled(): Boolean = configRepository.get(AppConfig.DEBUG_MATRIX) == "true"
|
||||
override fun getZoneThicknessDp(zone: String): Int {
|
||||
val calc = GestureZoneGeometryCalculator { key, def -> configRepository.get(key, def) }
|
||||
return calc.getThicknessDp(zone)
|
||||
}
|
||||
override fun getEdgeSplits(edge: String): Pair<Int, Int> {
|
||||
val calc = GestureZoneGeometryCalculator { key, def -> configRepository.get(key, def) }
|
||||
return calc.getSplits(edge)
|
||||
}
|
||||
},
|
||||
log = ::log,
|
||||
)
|
||||
private val gestureDetector by lazy {
|
||||
EdgeGestureDetector(
|
||||
handoff = nativeTouchHandoff,
|
||||
handlerProvider = ::mainHandler,
|
||||
callbacks = object : EdgeGestureDetector.Callbacks {
|
||||
override fun isZoneEnabled(zone: String): Boolean =
|
||||
configRepository.isZoneEnabled(zone)
|
||||
|
||||
override fun resolveAction(zone: String, gestureType: String): String {
|
||||
val direct = configRepository.get(AppConfig.gestureAction(zone, gestureType))
|
||||
if (direct.isNotEmpty() && direct != "none") return direct
|
||||
|
||||
val fallbackZone = AppConfig.fallbackEdgeZone(zone) ?: return direct
|
||||
return configRepository.get(AppConfig.gestureAction(fallbackZone, gestureType), direct)
|
||||
}
|
||||
|
||||
override fun dispatchAction(
|
||||
zone: String,
|
||||
gestureType: String,
|
||||
context: Context,
|
||||
touchX: Float,
|
||||
touchY: Float,
|
||||
) {
|
||||
mainHandler().post {
|
||||
actionDispatcher.triggerGestureAction(zone, gestureType, context, touchX, touchY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun performContinuousAdjustment(action: String, context: Context, up: Boolean) {
|
||||
when {
|
||||
action == "brightness_up" || action == "brightness_down" ->
|
||||
actionDispatcher.adjustBrightness(context, up)
|
||||
action == "volume_up" || action == "volume_down" ->
|
||||
actionDispatcher.adjustVolume(context, up)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isGlobalCopyModeActive(): Boolean =
|
||||
TextSelectionOverlay.isShowing()
|
||||
|
||||
override fun log(message: String) {
|
||||
gestureLog(message)
|
||||
}
|
||||
|
||||
override fun showPie(context: Context, anchorX: Float, anchorY: Float, edge: String) {
|
||||
mainHandler().post { actionDispatcher.showPie(context, anchorX, anchorY, edge) }
|
||||
}
|
||||
|
||||
override fun updatePie(x: Float, y: Float) {
|
||||
mainHandler().post { PieManager.update(x, y) }
|
||||
}
|
||||
|
||||
override fun commitPie(context: Context) {
|
||||
mainHandler().post { actionDispatcher.commitPieAction(context) }
|
||||
}
|
||||
|
||||
override fun cancelPie() {
|
||||
mainHandler().post { PieManager.dismiss() }
|
||||
}
|
||||
|
||||
override fun getZoneThicknessDp(zone: String): Int {
|
||||
val calc = GestureZoneGeometryCalculator { key, def -> configRepository.get(key, def) }
|
||||
return calc.getThicknessDp(zone)
|
||||
}
|
||||
|
||||
override fun getEdgeSplits(edge: String): Pair<Int, Int> {
|
||||
val calc = GestureZoneGeometryCalculator { key, def -> configRepository.get(key, def) }
|
||||
return calc.getSplits(edge)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun mainHandler(): Handler =
|
||||
mHandler ?: Handler(Looper.getMainLooper()).also { mHandler = it }
|
||||
|
||||
private fun log(message: String) {
|
||||
XposedBridge.log("$TAG: $message")
|
||||
}
|
||||
|
||||
private fun gestureLog(message: String) {
|
||||
XposedBridge.log("$TAG: [Gesture] $message")
|
||||
}
|
||||
|
||||
private fun ensureSystemServerInitialized(context: Context, initializeKeys: Boolean) {
|
||||
if (systemContext == null) {
|
||||
systemContext = context
|
||||
configRepository.attachSystemContext(context)
|
||||
configRepository.reloadAsync()
|
||||
registerScreenStateReceiver(context)
|
||||
registerConfigChangeReceiver(context)
|
||||
mainHandler().post {
|
||||
debugOverlayController.initialize(context)
|
||||
addWindowAnchor(context)
|
||||
FlashlightManager.initialize(context, mainHandler())
|
||||
configRepository.reloadAsync(::refreshDebugOverlay)
|
||||
}
|
||||
actionDispatcher.bindShellService(context)
|
||||
}
|
||||
if (initializeKeys && !keyManagerInitialized) {
|
||||
KeyManager.init(context)
|
||||
keyManagerInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun initSystemServer(context: Context) {
|
||||
ensureSystemServerInitialized(context, initializeKeys = false)
|
||||
}
|
||||
|
||||
internal fun getConfigRepository(): HookConfigRepository = configRepository
|
||||
|
||||
internal fun getSystemContext(): Context? = systemContext
|
||||
|
||||
private fun refreshDebugOverlay() {
|
||||
debugOverlayController.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register broadcast receiver for SCREEN_OFF/ON in system_server process.
|
||||
* Resets gesture and key state when the screen turns off to prevent
|
||||
* stale state from blocking touch after unlock.
|
||||
*/
|
||||
private fun registerScreenStateReceiver(context: Context) {
|
||||
if (screenStateReceiverRegistered) return
|
||||
screenStateReceiverRegistered = true
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
gestureDetector.reset()
|
||||
KeyManager.reset()
|
||||
mainHandler().post {
|
||||
PieManager.dismiss()
|
||||
PanelOverlayManager.dismiss()
|
||||
PremiumRuntime.onScreenOff()
|
||||
LocalOverlayRuntime.onScreenOff()
|
||||
}
|
||||
}
|
||||
Intent.ACTION_USER_UNLOCKED -> {
|
||||
configRepository.invalidate()
|
||||
configRepository.reloadAsync()
|
||||
actionDispatcher.onUserUnlocked(ctx)
|
||||
}
|
||||
Intent.ACTION_USER_PRESENT -> {
|
||||
actionDispatcher.onUserUnlocked(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_USER_UNLOCKED)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
}
|
||||
context.registerReceiver(receiver, filter)
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$TAG: Failed to register screen state receiver: ${e.message}")
|
||||
screenStateReceiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerConfigChangeReceiver(context: Context) {
|
||||
if (systemConfigReceiverRegistered) return
|
||||
systemConfigReceiverRegistered = true
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(HookConfigSnapshot.ACTION_CONFIG_CHANGED)
|
||||
addAction(HookConfigSnapshot.ACTION_EXECUTE_ACTION)
|
||||
addAction(HookConfigSnapshot.ACTION_HOOK_STATUS_REQUEST)
|
||||
addAction(HookConfigSnapshot.ACTION_EDGE_LIGHTING)
|
||||
addAction(HookConfigSnapshot.ACTION_EDGE_LIGHTING_DISMISS)
|
||||
addAction(GameModeManager.ACTION_DISABLE)
|
||||
addAction(FlashlightManager.ACTION_TURN_OFF)
|
||||
}
|
||||
|
||||
fun createReceiver(): BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
HookConfigSnapshot.ACTION_CONFIG_CHANGED -> {
|
||||
val keys = intent.getStringArrayExtra(HookConfigSnapshot.EXTRA_KEYS)
|
||||
val values = intent.getStringArrayExtra(HookConfigSnapshot.EXTRA_VALUES)
|
||||
log("ACTION_CONFIG_CHANGED received in system_server: keys=${keys?.joinToString()}, values=${values?.joinToString()}")
|
||||
if (keys != null && values != null) {
|
||||
configRepository.updateFromBroadcast(
|
||||
keys,
|
||||
values,
|
||||
intent.getBooleanExtra(HookConfigSnapshot.EXTRA_FULL_SNAPSHOT, false),
|
||||
)
|
||||
refreshDebugOverlay()
|
||||
} else if (intent.getBooleanExtra(HookConfigSnapshot.EXTRA_FULL_SNAPSHOT, false)) {
|
||||
configRepository.invalidate()
|
||||
configRepository.reloadAsync(::refreshDebugOverlay)
|
||||
}
|
||||
PremiumPluginLoader.retryChallengeIfNeeded(ctx)
|
||||
}
|
||||
HookConfigSnapshot.ACTION_EXECUTE_ACTION -> {
|
||||
val action = intent.getStringExtra(HookConfigSnapshot.EXTRA_ACTION_CODE).orEmpty()
|
||||
if (action.isBlank() || action == "none") return
|
||||
|
||||
log("Execute action request from UI: $action")
|
||||
actionDispatcher.executeKeyAction(action, ctx)
|
||||
}
|
||||
HookConfigSnapshot.ACTION_HOOK_STATUS_REQUEST -> {
|
||||
ctx.sendBroadcast(ModuleActivationState.responseIntent(System.currentTimeMillis()))
|
||||
PremiumPluginLoader.retryChallengeIfNeeded(ctx)
|
||||
}
|
||||
HookConfigSnapshot.ACTION_EDGE_LIGHTING -> {
|
||||
if (configRepository.get(AppConfig.EDGE_LIGHTING_ENABLED) != "true") return
|
||||
|
||||
val sysCtx = systemContext ?: ctx
|
||||
val color = intent.getIntExtra(
|
||||
HookConfigSnapshot.EXTRA_EDGE_LIGHTING_COLOR,
|
||||
parseColor(configRepository.get(AppConfig.EDGE_LIGHTING_COLOR), 0xFF00FFFF.toInt()),
|
||||
)
|
||||
val durationMs = intent.getIntExtra(
|
||||
HookConfigSnapshot.EXTRA_EDGE_LIGHTING_DURATION_MS,
|
||||
configRepository.get(AppConfig.EDGE_LIGHTING_DURATION_MS).toIntOrNull() ?: 3000,
|
||||
).coerceIn(500, 60000)
|
||||
val widthDp = (configRepository.get(AppConfig.EDGE_LIGHTING_WIDTH_DP).toIntOrNull() ?: 5)
|
||||
.coerceIn(1, 20)
|
||||
val alpha = (configRepository.get(AppConfig.EDGE_LIGHTING_ALPHA).toFloatOrNull() ?: 1f)
|
||||
.coerceIn(0f, 1f)
|
||||
val effect = configRepository.get(
|
||||
AppConfig.EDGE_LIGHTING_EFFECT,
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_BASIC,
|
||||
)
|
||||
val notificationKey = intent.getStringExtra(
|
||||
HookConfigSnapshot.EXTRA_EDGE_LIGHTING_NOTIFICATION_KEY,
|
||||
) ?: return
|
||||
mainHandler().post {
|
||||
val shown = if (PremiumRuntime.isActive()) {
|
||||
PremiumRuntime.showEdgeLighting(sysCtx, effect, color, durationMs, widthDp, alpha)
|
||||
} else {
|
||||
LocalOverlayRuntime.showEdgeLighting(sysCtx, effect, color, durationMs, widthDp, alpha)
|
||||
}
|
||||
if (shown) {
|
||||
activeEdgeLightingNotificationKey = notificationKey
|
||||
}
|
||||
}
|
||||
}
|
||||
HookConfigSnapshot.ACTION_EDGE_LIGHTING_DISMISS -> {
|
||||
val notificationKey = intent.getStringExtra(
|
||||
HookConfigSnapshot.EXTRA_EDGE_LIGHTING_NOTIFICATION_KEY,
|
||||
) ?: return
|
||||
mainHandler().post {
|
||||
if (notificationKey == activeEdgeLightingNotificationKey) {
|
||||
activeEdgeLightingNotificationKey = null
|
||||
if (PremiumRuntime.isActive()) {
|
||||
PremiumRuntime.dismissEdgeLighting()
|
||||
} else {
|
||||
LocalOverlayRuntime.dismissEdgeLighting()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GameModeManager.ACTION_DISABLE -> {
|
||||
val sysCtx = systemContext ?: ctx
|
||||
val h = mainHandler()
|
||||
h.post { GameModeManager.disable(sysCtx, h) }
|
||||
}
|
||||
FlashlightManager.ACTION_TURN_OFF -> {
|
||||
val sysCtx = systemContext ?: ctx
|
||||
val h = mainHandler()
|
||||
h.post { FlashlightManager.turnOff(sysCtx, h) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var registered = false
|
||||
try {
|
||||
val receiver = createReceiver()
|
||||
de.robv.android.xposed.XposedHelpers.callMethod(
|
||||
context,
|
||||
"registerReceiverForAllUsers",
|
||||
receiver,
|
||||
filter,
|
||||
null,
|
||||
mainHandler(),
|
||||
Context.RECEIVER_EXPORTED
|
||||
)
|
||||
registered = true
|
||||
log("Registered config receiver for all users in system_server")
|
||||
} catch (t: Throwable) {
|
||||
log("Failed to registerReceiverForAllUsers: ${t.message}")
|
||||
}
|
||||
|
||||
if (!registered) {
|
||||
try {
|
||||
val receiver = createReceiver()
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
|
||||
log("Fallback: Registered config receiver with standard registerReceiver in system_server")
|
||||
} catch (e: Exception) {
|
||||
systemConfigReceiverRegistered = false
|
||||
log("Failed to register config broadcast receiver: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from system_server (filterInputEvent hook).
|
||||
* Handles MotionEvent at the input pipeline level and consumes touches
|
||||
* once a gesture starts from an enabled edge zone.
|
||||
*/
|
||||
fun handleMotionEvent(event: MotionEvent, context: Context): Boolean {
|
||||
ensureSystemServerInitialized(context, initializeKeys = false)
|
||||
|
||||
if (!configRepository.isGesturesEnabled()) return false
|
||||
if (GameModeManager.isActive) return false
|
||||
|
||||
return gestureDetector.handle(event, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from system_server (filterInputEvent hook) for KeyEvents.
|
||||
* Delegates to KeyManager for state machine processing.
|
||||
*/
|
||||
fun handleKeyEvent(event: KeyEvent, context: Context, hookParam: de.robv.android.xposed.XC_MethodHook.MethodHookParam, policyFlags: Int = 0): Boolean {
|
||||
ensureSystemServerInitialized(context, initializeKeys = true)
|
||||
|
||||
if (GameModeManager.isActive) return false
|
||||
return KeyManager.handleKeyEvent(event, context, hookParam, policyFlags)
|
||||
}
|
||||
|
||||
fun executeKeyAction(action: String, context: Context) {
|
||||
actionDispatcher.executeKeyAction(action, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a 1×1 transparent system window so that system_server always has a visible
|
||||
* window in WMS. Android 15 home-screen protection blocks startActivity() from any
|
||||
* process that has no visible WMS window when the launcher task is in the foreground,
|
||||
* even for SYSTEM_UID callers. This anchor satisfies that check without any visual
|
||||
* effect (1 px, fully transparent, FLAG_NOT_TOUCHABLE).
|
||||
*/
|
||||
private fun addWindowAnchor(context: Context) {
|
||||
if (windowAnchor != null) return
|
||||
try {
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val anchor = View(context)
|
||||
val params = WindowManager.LayoutParams(
|
||||
1, 1,
|
||||
2027, // TYPE_ACCESSIBILITY_MAGNIFICATION_OVERLAY – usable by system processes
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
}
|
||||
wm.addView(anchor, params)
|
||||
windowAnchor = anchor
|
||||
log("Window anchor added")
|
||||
} catch (t: Throwable) {
|
||||
log("Failed to add window anchor: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseColor(value: String, fallback: Int): Int =
|
||||
try {
|
||||
android.graphics.Color.parseColor(value)
|
||||
} catch (_: Exception) {
|
||||
fallback
|
||||
}
|
||||
|
||||
}
|
||||
138
app/src/main/java/com/fan/edgex/hook/GlobalActionHelper.kt
Normal file
138
app/src/main/java/com/fan/edgex/hook/GlobalActionHelper.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
|
||||
/**
|
||||
* Helper class to perform global actions (back, home, recents, etc.) by creating
|
||||
* a fake AccessibilityService that connects to the system's AccessibilityManager.
|
||||
*
|
||||
* This technique is borrowed from Xposed Edge Pro, which uses reflection to:
|
||||
* 1. Get the internal IAccessibilityManager service from AccessibilityManager
|
||||
* 2. Get a connection ID from the service's InteractionBridge
|
||||
* 3. Set that connection ID on a fake AccessibilityService instance
|
||||
* 4. Call performGlobalAction() on the fake service
|
||||
*/
|
||||
object GlobalActionHelper {
|
||||
|
||||
private const val TAG = "EdgeX"
|
||||
|
||||
// Global action constants (from AccessibilityService)
|
||||
const val GLOBAL_ACTION_BACK = 1
|
||||
const val GLOBAL_ACTION_HOME = 2
|
||||
const val GLOBAL_ACTION_RECENTS = 3
|
||||
const val GLOBAL_ACTION_NOTIFICATIONS = 4
|
||||
const val GLOBAL_ACTION_QUICK_SETTINGS = 5
|
||||
const val GLOBAL_ACTION_POWER_DIALOG = 6
|
||||
const val GLOBAL_ACTION_LOCK_SCREEN = 8
|
||||
const val GLOBAL_ACTION_TAKE_SCREENSHOT = 9
|
||||
const val GLOBAL_ACTION_PASTE = 11
|
||||
|
||||
private var fakeService: FakeAccessibilityService? = null
|
||||
|
||||
/**
|
||||
* Perform a global action like back, home, recents, etc.
|
||||
* Must be called from system_server process.
|
||||
*/
|
||||
fun performGlobalAction(context: Context, action: Int): Boolean {
|
||||
try {
|
||||
val service = getOrCreateFakeService(context)
|
||||
if (service != null) {
|
||||
return service.performGlobalAction(action)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$TAG: Failed to perform global action $action: ${e.message}")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getOrCreateFakeService(context: Context): FakeAccessibilityService? {
|
||||
if (fakeService != null) return fakeService
|
||||
|
||||
try {
|
||||
val service = FakeAccessibilityService(context)
|
||||
fakeService = service
|
||||
return service
|
||||
} catch (e: Throwable) {
|
||||
XposedBridge.log("$TAG: Failed to create fake accessibility service: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake AccessibilityService that can call performGlobalAction without
|
||||
* being registered as a real accessibility service.
|
||||
*/
|
||||
private class FakeAccessibilityService(context: Context) : AccessibilityService() {
|
||||
|
||||
init {
|
||||
// Get AccessibilityManager
|
||||
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
|
||||
// Get the internal IAccessibilityManager service
|
||||
val service = getAccessibilityManagerService(am)
|
||||
|
||||
// Get connection ID from the service
|
||||
val connectionId = getConnectionId(service)
|
||||
|
||||
// Set connection ID on this fake service
|
||||
XposedHelpers.setIntField(this, "mConnectionId", connectionId)
|
||||
|
||||
// Attach base context
|
||||
attachBaseContext(context)
|
||||
|
||||
XposedBridge.log("$TAG: FakeAccessibilityService created with connectionId=$connectionId")
|
||||
}
|
||||
|
||||
private fun getAccessibilityManagerService(am: AccessibilityManager): Any {
|
||||
try {
|
||||
// Try to get mService field first
|
||||
val service = XposedHelpers.getObjectField(am, "mService")
|
||||
if (service != null) return service
|
||||
} catch (e: Throwable) {
|
||||
// Fallback: try getServiceLocked
|
||||
}
|
||||
|
||||
// Fallback: call getServiceLocked()
|
||||
synchronized(XposedHelpers.getObjectField(am, "mLock")) {
|
||||
return XposedHelpers.callMethod(am, "getServiceLocked")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConnectionId(service: Any): Int {
|
||||
val sdkVersion = Build.VERSION.SDK_INT
|
||||
|
||||
return if (sdkVersion >= 26) {
|
||||
// API 26+: Use getInteractionBridge()
|
||||
val bridge = XposedHelpers.callMethod(service, "getInteractionBridge")
|
||||
XposedHelpers.getIntField(bridge, "mConnectionId")
|
||||
} else if (sdkVersion >= 21) {
|
||||
// API 21-25: Use getInteractionBridgeLocked()
|
||||
val lock = XposedHelpers.getObjectField(service, "mLock")
|
||||
synchronized(lock) {
|
||||
val bridge = XposedHelpers.callMethod(service, "getInteractionBridgeLocked")
|
||||
XposedHelpers.getIntField(bridge, "mConnectionId")
|
||||
}
|
||||
} else {
|
||||
// API < 21: Use getQueryBridge()
|
||||
val lock = XposedHelpers.getObjectField(service, "mLock")
|
||||
synchronized(lock) {
|
||||
val bridge = XposedHelpers.callMethod(service, "getQueryBridge")
|
||||
XposedHelpers.getIntField(bridge, "mId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: android.view.accessibility.AccessibilityEvent?) {
|
||||
// Not used
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
// Not used
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/src/main/java/com/fan/edgex/hook/HookConfigRepository.kt
Normal file
127
app/src/main/java/com/fan/edgex/hook/HookConfigRepository.kt
Normal file
@@ -0,0 +1,127 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.fan.edgex.BuildConfig
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal class HookConfigRepository(
|
||||
private val updateKeyConfig: (Map<String, String>) -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private val configCache = ConcurrentHashMap<String, String>()
|
||||
private var lastConfigLoad = 0L
|
||||
private var missingSnapshotLogged = false
|
||||
private var lastSnapshotRequest = 0L
|
||||
private var systemContext: Context? = null
|
||||
|
||||
private val configExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "EdgeX-Config").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
fun attachSystemContext(context: Context) {
|
||||
systemContext = context
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
lastConfigLoad = 0L
|
||||
}
|
||||
|
||||
fun updateFromBroadcast(keys: Array<String>, values: Array<String>, fullSnapshot: Boolean) {
|
||||
if (keys.size != values.size) {
|
||||
log("Ignoring malformed config broadcast: keys=${keys.size} values=${values.size}")
|
||||
return
|
||||
}
|
||||
|
||||
if (fullSnapshot) {
|
||||
configCache.clear()
|
||||
}
|
||||
var appliedCount = 0
|
||||
keys.forEachIndexed { index, key ->
|
||||
if (HookConfigSnapshot.isHookRuntimeKey(key)) {
|
||||
configCache[key] = values[index]
|
||||
appliedCount++
|
||||
}
|
||||
}
|
||||
updateKeyConfig(configCache)
|
||||
HookConfigSnapshot.writeForHook(configCache)
|
||||
lastConfigLoad = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun reloadAsync(onLoaded: (() -> Unit)? = null) {
|
||||
if (System.currentTimeMillis() - lastConfigLoad < CONFIG_CACHE_TTL) return
|
||||
configExecutor.execute {
|
||||
try {
|
||||
loadSnapshot()
|
||||
lastConfigLoad = System.currentTimeMillis()
|
||||
onLoaded?.let { Handler(Looper.getMainLooper()).post(it) }
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isGesturesEnabled(): Boolean =
|
||||
get(AppConfig.GESTURES_ENABLED) == "true"
|
||||
|
||||
fun isZoneEnabled(zone: String): Boolean {
|
||||
val enabledValue = get(AppConfig.zoneEnabled(zone))
|
||||
if (enabledValue.isNotEmpty()) return enabledValue == "true"
|
||||
|
||||
return AppConfig.GESTURES.any { gesture ->
|
||||
AppConfig.isActiveActionValue(get(AppConfig.gestureAction(zone, gesture)))
|
||||
}
|
||||
}
|
||||
|
||||
fun get(key: String, defValue: String = ""): String =
|
||||
configCache[key] ?: defValue
|
||||
|
||||
private fun loadSnapshot(): Boolean {
|
||||
val snapshot = HookConfigSnapshot.readFromHookFile()
|
||||
if (snapshot.isEmpty()) {
|
||||
if (!missingSnapshotLogged) {
|
||||
missingSnapshotLogged = true
|
||||
log("Config snapshot unavailable; requesting EdgeX to publish hook config")
|
||||
}
|
||||
requestSnapshotFromApp()
|
||||
return false
|
||||
}
|
||||
|
||||
missingSnapshotLogged = false
|
||||
configCache.clear()
|
||||
configCache.putAll(snapshot)
|
||||
updateKeyConfig(configCache)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun requestSnapshotFromApp() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastSnapshotRequest < SNAPSHOT_REQUEST_THROTTLE) return
|
||||
lastSnapshotRequest = now
|
||||
|
||||
val context = systemContext ?: return
|
||||
try {
|
||||
context.sendBroadcast(Intent(HookConfigSnapshot.ACTION_CONFIG_SNAPSHOT_REQUEST).apply {
|
||||
component = ComponentName(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
"${BuildConfig.APPLICATION_ID}.config.ConfigSnapshotReceiver",
|
||||
)
|
||||
setPackage(BuildConfig.APPLICATION_ID)
|
||||
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
log("Failed to request config snapshot: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONFIG_CACHE_TTL = 2000L
|
||||
const val SNAPSHOT_REQUEST_THROTTLE = 30_000L
|
||||
}
|
||||
}
|
||||
541
app/src/main/java/com/fan/edgex/hook/KeyManager.kt
Normal file
541
app/src/main/java/com/fan/edgex/hook/KeyManager.kt
Normal file
@@ -0,0 +1,541 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import android.view.ViewConfiguration
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
/**
|
||||
* KeyManager handles hardware key interception and action triggering.
|
||||
* Keys are trigger sources parallel to gestures.
|
||||
*
|
||||
* Following Xposed Edge Pro's approach:
|
||||
* - Save MethodHookParam to forward events later if needed
|
||||
* - Use g(param) to invoke original method when we don't want to consume
|
||||
*
|
||||
* Supported interaction modes:
|
||||
* - click (0): Quick press and release
|
||||
* - double_click (1): Two quick presses
|
||||
* - long_press (2): Hold beyond threshold
|
||||
*/
|
||||
object KeyManager {
|
||||
|
||||
private const val TAG = "EdgeX"
|
||||
|
||||
// Interaction modes (matching Xposed Edge Pro)
|
||||
const val MODE_CLICK = 0
|
||||
const val MODE_DOUBLE_CLICK = 1
|
||||
const val MODE_LONG_PRESS = 2
|
||||
|
||||
// Supported keys (keyCode -> config index)
|
||||
val SUPPORTED_KEYS = mapOf(
|
||||
KeyEvent.KEYCODE_VOLUME_UP to 0,
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN to 1,
|
||||
KeyEvent.KEYCODE_POWER to 2
|
||||
)
|
||||
|
||||
// State machine states
|
||||
private const val STATE_IDLE = 0
|
||||
private const val STATE_PRESSED = 1
|
||||
private const val STATE_WAITING_DOUBLE = 2
|
||||
|
||||
private val needsCopyEvent = true
|
||||
|
||||
// Current state per key
|
||||
private val keyStates = mutableMapOf<Int, Int>()
|
||||
|
||||
// Track press times for timing calculations
|
||||
private val keyDownTimes = mutableMapOf<Int, Long>()
|
||||
|
||||
// Store pending KeyEvents for forwarding (like Xposed Edge Pro's H and I)
|
||||
// H = DOWN event, I = UP event (for double-tap waiting)
|
||||
private val pendingDownEvents = mutableMapOf<Int, KeyEvent>()
|
||||
private val pendingUpEvents = mutableMapOf<Int, KeyEvent>()
|
||||
|
||||
// Track if we consumed the key (should not forward)
|
||||
private val keyConsumed = mutableMapOf<Int, Boolean>()
|
||||
|
||||
// Track injected events to avoid infinite loop
|
||||
// We store (downTime, eventTime) pairs of events we injected
|
||||
private val injectedEventTimes = mutableSetOf<Long>()
|
||||
|
||||
// Volume key passthrough: after our action fires for a volume key,
|
||||
// the system volume panel is already showing (handled earlier in pipeline).
|
||||
// Subsequent volume presses should pass through for normal volume control.
|
||||
private var volumePassthroughUntil = 0L
|
||||
private const val VOLUME_PASSTHROUGH_DURATION = 3000L // matches volume panel auto-hide
|
||||
|
||||
private fun isVolumeKey(keyCode: Int): Boolean {
|
||||
return keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
|
||||
}
|
||||
|
||||
// Timeouts
|
||||
private var longPressTimeout = 500L
|
||||
private var doubleTapTimeout = 300L
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Runnables for timeouts
|
||||
private val longPressRunnables = mutableMapOf<Int, Runnable>()
|
||||
private val doubleTapRunnables = mutableMapOf<Int, Runnable>()
|
||||
|
||||
// Config cache
|
||||
private var keysEnabled = false
|
||||
private val keyEnabled = mutableMapOf<Int, Boolean>()
|
||||
private val keyActions = mutableMapOf<String, String>() // "keyCode_mode" -> action
|
||||
|
||||
/**
|
||||
* Initialize timeouts from system configuration.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
longPressTimeout = ViewConfiguration.getLongPressTimeout().toLong()
|
||||
doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration from cache.
|
||||
*/
|
||||
fun updateConfig(configCache: Map<String, String>) {
|
||||
keysEnabled = configCache[AppConfig.KEYS_ENABLED] == "true"
|
||||
|
||||
for (keyCode in SUPPORTED_KEYS.keys) {
|
||||
keyActions["${keyCode}_$MODE_CLICK"] = configCache[AppConfig.keyAction(keyCode, "click")] ?: ""
|
||||
keyActions["${keyCode}_$MODE_DOUBLE_CLICK"] = configCache[AppConfig.keyAction(keyCode, "double_click")] ?: ""
|
||||
keyActions["${keyCode}_$MODE_LONG_PRESS"] = configCache[AppConfig.keyAction(keyCode, "long_press")] ?: ""
|
||||
val enabledValue = configCache[AppConfig.keyEnabled(keyCode)]
|
||||
keyEnabled[keyCode] = if (enabledValue != null) {
|
||||
enabledValue == "true"
|
||||
} else {
|
||||
hasAnyAction(keyCode)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this key has any action configured.
|
||||
*/
|
||||
private fun hasAnyAction(keyCode: Int): Boolean {
|
||||
return keyActions["${keyCode}_$MODE_CLICK"]?.isNotEmpty() == true ||
|
||||
keyActions["${keyCode}_$MODE_DOUBLE_CLICK"]?.isNotEmpty() == true ||
|
||||
keyActions["${keyCode}_$MODE_LONG_PRESS"]?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action for key and mode.
|
||||
*/
|
||||
private fun getAction(keyCode: Int, mode: Int): String {
|
||||
return keyActions["${keyCode}_$mode"] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key has action for specific mode.
|
||||
*/
|
||||
private fun hasAction(keyCode: Int, mode: Int): Boolean {
|
||||
val action = getAction(keyCode, mode)
|
||||
return action.isNotEmpty() && action != "none"
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy KeyEvent for later forwarding (like Xposed Edge Pro's s() method).
|
||||
* On Android 10+, we need to copy the KeyEvent to avoid issues.
|
||||
*/
|
||||
private fun copyKeyEvent(event: KeyEvent): KeyEvent {
|
||||
return if (needsCopyEvent) KeyEvent(event) else event
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward key events by injecting them via InputManager.
|
||||
* This is more reliable than invokeOriginalMethod, especially for delayed forwarding.
|
||||
* Like Xposed Edge Pro's approach using InputManager.injectInputEvent.
|
||||
*/
|
||||
private fun injectKeyEvent(event: KeyEvent, context: Context) {
|
||||
// Mark this event as injected by us (using eventTime as identifier)
|
||||
markInjectedEvent(event)
|
||||
|
||||
// Volume key passthrough: if we inject a volume key, it means we didn't consume it
|
||||
// and it's falling back to the system behavior (bringing up the volume UI).
|
||||
// We activate passthrough so subsequent presses control volume normally.
|
||||
if (isVolumeKey(event.keyCode)) {
|
||||
volumePassthroughUntil = System.currentTimeMillis() + VOLUME_PASSTHROUGH_DURATION
|
||||
}
|
||||
|
||||
// Clean up old entries (keep only last 10)
|
||||
|
||||
try {
|
||||
// Try InputManager.getInstance()
|
||||
val inputManager = context.getSystemService(Context.INPUT_SERVICE)
|
||||
if (inputManager != null) {
|
||||
val injectMethod = inputManager.javaClass.getMethod(
|
||||
"injectInputEvent",
|
||||
Class.forName("android.view.InputEvent"),
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
injectMethod.invoke(inputManager, event, 0) // 0 = INJECT_INPUT_EVENT_MODE_ASYNC
|
||||
return
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: InputManager inject failed: ${t.message}")
|
||||
}
|
||||
|
||||
// Fallback: try InputManagerGlobal
|
||||
try {
|
||||
val globalCls = Class.forName("android.hardware.input.InputManagerGlobal")
|
||||
val getInstance = globalCls.getMethod("getInstance")
|
||||
val global = getInstance.invoke(null)
|
||||
val injectMethod = globalCls.getMethod(
|
||||
"injectInputEvent",
|
||||
Class.forName("android.view.InputEvent"),
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
injectMethod.invoke(global, event, 0)
|
||||
return
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: InputManagerGlobal inject failed: ${t.message}")
|
||||
}
|
||||
|
||||
XposedBridge.log("$TAG: Failed to inject key event - no method worked")
|
||||
}
|
||||
|
||||
fun markInjectedEvent(event: KeyEvent) {
|
||||
injectedEventTimes.add(event.eventTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward saved key events by injecting them.
|
||||
* This replaces the old forwardParam() approach which didn't work reliably.
|
||||
*/
|
||||
private fun forwardKeyEvents(keyCode: Int, context: Context) {
|
||||
val downEvent = pendingDownEvents[keyCode]
|
||||
val upEvent = pendingUpEvents[keyCode]
|
||||
|
||||
|
||||
if (downEvent != null) {
|
||||
injectKeyEvent(downEvent, context)
|
||||
}
|
||||
|
||||
if (upEvent != null) {
|
||||
injectKeyEvent(upEvent, context)
|
||||
}
|
||||
|
||||
pendingDownEvents.remove(keyCode)
|
||||
pendingUpEvents.remove(keyCode)
|
||||
}
|
||||
|
||||
// Policy flag used to mark injected events (to avoid infinite loop)
|
||||
// Following Xposed Edge Pro's approach with 0x4000000
|
||||
private const val INJECTED_EVENT_FLAG = 0x4000000
|
||||
|
||||
/**
|
||||
* Handle key event from interceptKeyBeforeDispatching hook.
|
||||
* Returns true if event should be consumed (not forwarded to system).
|
||||
*/
|
||||
fun handleKeyEvent(event: KeyEvent, context: Context, param: XC_MethodHook.MethodHookParam, policyFlags: Int = 0): Boolean {
|
||||
val keyCode = event.keyCode
|
||||
val eventTime = event.eventTime
|
||||
|
||||
|
||||
if (policyFlags and INJECTED_EVENT_FLAG != 0) return false
|
||||
|
||||
if (injectedEventTimes.contains(eventTime)) {
|
||||
injectedEventTimes.remove(eventTime)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!keysEnabled) return false
|
||||
|
||||
// Check if this key is supported
|
||||
if (!SUPPORTED_KEYS.containsKey(keyCode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this specific key is enabled
|
||||
if (keyEnabled[keyCode] != true) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if key has any action configured - if not, don't intercept
|
||||
if (!hasAnyAction(keyCode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Volume key passthrough: if volume panel is likely showing
|
||||
// (we recently executed an action for a volume key), let
|
||||
// subsequent volume presses pass through for normal volume control.
|
||||
if (isVolumeKey(keyCode) && System.currentTimeMillis() < volumePassthroughUntil) {
|
||||
volumePassthroughUntil = System.currentTimeMillis() + VOLUME_PASSTHROUGH_DURATION
|
||||
return false
|
||||
}
|
||||
|
||||
return when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> handleKeyDown(keyCode, event, context, param)
|
||||
KeyEvent.ACTION_UP -> handleKeyUp(keyCode, event, context, param)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle KEY_DOWN event.
|
||||
*
|
||||
* Key insight from Xposed Edge Pro:
|
||||
* - Use repeatCount to detect if this is a new press or a repeat
|
||||
* - repeatCount == 0 means first press
|
||||
* - repeatCount > 0 means key is being held down
|
||||
*/
|
||||
private fun handleKeyDown(keyCode: Int, event: KeyEvent, context: Context, param: XC_MethodHook.MethodHookParam): Boolean {
|
||||
val repeatCount = event.repeatCount
|
||||
val currentState = keyStates[keyCode] ?: STATE_IDLE
|
||||
|
||||
// If this is a repeat event (key held down)
|
||||
if (repeatCount > 0) {
|
||||
// If we're already tracking, keep consuming
|
||||
if (currentState == STATE_PRESSED) {
|
||||
return true
|
||||
}
|
||||
// If we're not tracking but this is the first event we see (missed repeat=0),
|
||||
// treat the first repeat as a new press
|
||||
if (currentState == STATE_IDLE) {
|
||||
return startNewPress(keyCode, event, context, param)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// First press (repeatCount == 0)
|
||||
when (currentState) {
|
||||
STATE_IDLE -> {
|
||||
return startNewPress(keyCode, event, context, param)
|
||||
}
|
||||
STATE_WAITING_DOUBLE -> {
|
||||
// Second press within double-tap window
|
||||
cancelDoubleTapTimeout(keyCode)
|
||||
|
||||
// Check if event times match double-tap timing
|
||||
val firstUpEvent = pendingUpEvents[keyCode]
|
||||
val timeDiff = if (firstUpEvent != null) event.eventTime - firstUpEvent.eventTime else Long.MAX_VALUE
|
||||
|
||||
if (timeDiff < doubleTapTimeout && hasAction(keyCode, MODE_DOUBLE_CLICK)) {
|
||||
// Execute double-click action
|
||||
keyStates[keyCode] = STATE_PRESSED
|
||||
keyConsumed[keyCode] = true
|
||||
pendingDownEvents.remove(keyCode)
|
||||
pendingUpEvents.remove(keyCode)
|
||||
|
||||
val action = getAction(keyCode, MODE_DOUBLE_CLICK)
|
||||
XposedBridge.log("$TAG: Key $keyCode double-click -> $action")
|
||||
executeAction(action, context, keyCode)
|
||||
return true
|
||||
} else {
|
||||
// No double-click action or timing didn't match
|
||||
// Forward the pending events via injection and start new press
|
||||
forwardKeyEvents(keyCode, context)
|
||||
return startNewPress(keyCode, event, context, param)
|
||||
}
|
||||
}
|
||||
STATE_PRESSED -> {
|
||||
// Still in pressed state, probably a duplicate event
|
||||
return true
|
||||
}
|
||||
else -> return keyConsumed[keyCode] == true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking a new key press.
|
||||
*/
|
||||
private fun startNewPress(keyCode: Int, event: KeyEvent, context: Context, param: XC_MethodHook.MethodHookParam): Boolean {
|
||||
keyStates[keyCode] = STATE_PRESSED
|
||||
// Use downTime for more accurate timing - this is the time the key was originally pressed
|
||||
keyDownTimes[keyCode] = event.downTime
|
||||
// Save param for potential forwarding (like Xposed Edge Pro's H)
|
||||
pendingDownEvents[keyCode] = copyKeyEvent(event)
|
||||
keyConsumed[keyCode] = false
|
||||
|
||||
// Start long-press timeout if long-press action exists
|
||||
if (hasAction(keyCode, MODE_LONG_PRESS)) {
|
||||
startLongPressTimeout(keyCode, context)
|
||||
}
|
||||
|
||||
// Always intercept initially to detect the gesture type
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle KEY_UP event.
|
||||
* Following Xposed Edge Pro's v() method logic.
|
||||
*/
|
||||
private fun handleKeyUp(keyCode: Int, event: KeyEvent, context: Context, param: XC_MethodHook.MethodHookParam): Boolean {
|
||||
val currentState = keyStates[keyCode] ?: STATE_IDLE
|
||||
|
||||
if (currentState != STATE_PRESSED) {
|
||||
return keyConsumed[keyCode] == true
|
||||
}
|
||||
|
||||
cancelLongPressTimeout(keyCode)
|
||||
|
||||
val downTime = keyDownTimes[keyCode] ?: event.eventTime
|
||||
val pressDuration = event.eventTime - downTime
|
||||
|
||||
// Check if it was a long press (timeout would have fired)
|
||||
if (keyConsumed[keyCode] == true) {
|
||||
// Long press already executed
|
||||
keyStates[keyCode] = STATE_IDLE
|
||||
pendingDownEvents.remove(keyCode)
|
||||
pendingUpEvents.remove(keyCode)
|
||||
return true
|
||||
}
|
||||
|
||||
// Short press - check if we need to wait for double-tap
|
||||
if (pressDuration < longPressTimeout) {
|
||||
// Check if double-click action exists (like Xposed Edge Pro's D(keyCode).z != 0)
|
||||
if (hasAction(keyCode, MODE_DOUBLE_CLICK)) {
|
||||
// Wait for potential double-tap - save UP event param (like Xposed Edge Pro's I)
|
||||
keyStates[keyCode] = STATE_WAITING_DOUBLE
|
||||
pendingUpEvents[keyCode] = copyKeyEvent(event)
|
||||
startDoubleTapTimeout(keyCode, context)
|
||||
return true
|
||||
}
|
||||
|
||||
// No double-click action - check for click action (like Xposed Edge Pro's q(keyCode, 0).z != 0)
|
||||
if (hasAction(keyCode, MODE_CLICK)) {
|
||||
// Execute click immediately
|
||||
keyStates[keyCode] = STATE_IDLE
|
||||
pendingDownEvents.remove(keyCode)
|
||||
pendingUpEvents.remove(keyCode)
|
||||
|
||||
val action = getAction(keyCode, MODE_CLICK)
|
||||
XposedBridge.log("$TAG: Key $keyCode click -> $action")
|
||||
executeAction(action, context, keyCode)
|
||||
return true
|
||||
}
|
||||
|
||||
pendingUpEvents[keyCode] = copyKeyEvent(event)
|
||||
forwardKeyEvents(keyCode, context)
|
||||
keyStates[keyCode] = STATE_IDLE
|
||||
return true
|
||||
} else {
|
||||
// Key was held past long-press threshold but long-press timeout didn't consume
|
||||
// If the DOWN event was already injected (because no long press action),
|
||||
// we should NOT execute the click action on release to avoid stuck keys.
|
||||
if (hasAction(keyCode, MODE_CLICK) && pendingDownEvents.containsKey(keyCode)) {
|
||||
// Execute click on release
|
||||
keyStates[keyCode] = STATE_IDLE
|
||||
pendingDownEvents.remove(keyCode)
|
||||
pendingUpEvents.remove(keyCode)
|
||||
|
||||
val action = getAction(keyCode, MODE_CLICK)
|
||||
executeAction(action, context, keyCode)
|
||||
return true
|
||||
}
|
||||
pendingUpEvents[keyCode] = copyKeyEvent(event)
|
||||
forwardKeyEvents(keyCode, context)
|
||||
keyStates[keyCode] = STATE_IDLE
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start long-press timeout.
|
||||
*/
|
||||
private fun startLongPressTimeout(keyCode: Int, context: Context) {
|
||||
cancelLongPressTimeout(keyCode)
|
||||
|
||||
val runnable = Runnable {
|
||||
synchronized(this) {
|
||||
if (keyStates[keyCode] == STATE_PRESSED && keyConsumed[keyCode] != true) {
|
||||
if (hasAction(keyCode, MODE_LONG_PRESS)) {
|
||||
keyConsumed[keyCode] = true
|
||||
pendingDownEvents.remove(keyCode)
|
||||
|
||||
val action = getAction(keyCode, MODE_LONG_PRESS)
|
||||
XposedBridge.log("$TAG: Key $keyCode long-press -> $action")
|
||||
executeAction(action, context, keyCode)
|
||||
} else {
|
||||
val downEvent = pendingDownEvents.remove(keyCode)
|
||||
if (downEvent != null) {
|
||||
injectKeyEvent(downEvent, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
longPressRunnables[keyCode] = runnable
|
||||
handler.postDelayed(runnable, longPressTimeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel long-press timeout.
|
||||
*/
|
||||
private fun cancelLongPressTimeout(keyCode: Int) {
|
||||
longPressRunnables[keyCode]?.let { handler.removeCallbacks(it) }
|
||||
longPressRunnables.remove(keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start double-tap timeout.
|
||||
* Like Xposed Edge Pro's e() callback - when timeout fires, execute click or forward.
|
||||
*/
|
||||
private fun startDoubleTapTimeout(keyCode: Int, context: Context) {
|
||||
cancelDoubleTapTimeout(keyCode)
|
||||
|
||||
val runnable = Runnable {
|
||||
synchronized(this) {
|
||||
if (keyStates[keyCode] == STATE_WAITING_DOUBLE) {
|
||||
keyStates[keyCode] = STATE_IDLE
|
||||
|
||||
if (hasAction(keyCode, MODE_CLICK)) {
|
||||
// Double-tap timed out, execute single click
|
||||
pendingDownEvents.remove(keyCode)
|
||||
pendingUpEvents.remove(keyCode)
|
||||
|
||||
val action = getAction(keyCode, MODE_CLICK)
|
||||
XposedBridge.log("$TAG: Key $keyCode click (after double-tap timeout) -> $action")
|
||||
executeAction(action, context, keyCode)
|
||||
} else {
|
||||
forwardKeyEvents(keyCode, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
doubleTapRunnables[keyCode] = runnable
|
||||
handler.postDelayed(runnable, doubleTapTimeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel double-tap timeout.
|
||||
*/
|
||||
private fun cancelDoubleTapTimeout(keyCode: Int) {
|
||||
doubleTapRunnables[keyCode]?.let { handler.removeCallbacks(it) }
|
||||
doubleTapRunnables.remove(keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute action (delegate to GestureManager's action system).
|
||||
*/
|
||||
private fun executeAction(action: String, context: Context, keyCode: Int = -1) {
|
||||
if (action.isEmpty() || action == "none") return
|
||||
|
||||
GestureManager.executeKeyAction(action, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state (e.g., on screen off or config change).
|
||||
* Ensures no stale key tracking survives a lock/unlock cycle.
|
||||
*/
|
||||
fun reset() {
|
||||
for (keyCode in SUPPORTED_KEYS.keys) {
|
||||
cancelLongPressTimeout(keyCode)
|
||||
cancelDoubleTapTimeout(keyCode)
|
||||
}
|
||||
keyStates.clear()
|
||||
keyDownTimes.clear()
|
||||
pendingDownEvents.clear()
|
||||
pendingUpEvents.clear()
|
||||
keyConsumed.clear()
|
||||
injectedEventTimes.clear()
|
||||
volumePassthroughUntil = 0L
|
||||
}
|
||||
}
|
||||
106
app/src/main/java/com/fan/edgex/hook/LocalOverlayRuntime.kt
Normal file
106
app/src/main/java/com/fan/edgex/hook/LocalOverlayRuntime.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.LinearInterpolator
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.overlay.EdgeLightingView
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Local (non-premium) implementation of Edge Lighting overlay.
|
||||
* Used when the premium DEX plugin is not active.
|
||||
*/
|
||||
internal object LocalOverlayRuntime {
|
||||
private const val TAG = "EdgeX"
|
||||
|
||||
private val handler by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
// ── Edge Lighting ──────────────────────────────────────────────────────
|
||||
|
||||
private var edgeLightingView: EdgeLightingView? = null
|
||||
private var edgeLightingAnimator: ValueAnimator? = null
|
||||
|
||||
fun showEdgeLighting(
|
||||
context: Context,
|
||||
effect: String,
|
||||
color: Int,
|
||||
durationMs: Int,
|
||||
widthDp: Int,
|
||||
alpha: Float,
|
||||
): Boolean {
|
||||
dismissEdgeLighting()
|
||||
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val density = context.resources.displayMetrics.density
|
||||
|
||||
val view = EdgeLightingView(context).apply {
|
||||
glowColor = color
|
||||
glowWidthPx = widthDp.coerceIn(1, 20) * density
|
||||
this.effect = effect
|
||||
glowAlpha = alpha
|
||||
}
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
2027, // TYPE_ACCESSIBILITY_MAGNIFICATION_OVERLAY
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
}
|
||||
|
||||
wm.addView(view, params)
|
||||
edgeLightingView = view
|
||||
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
this.duration = durationMs.toLong()
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.RESTART
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener {
|
||||
val progress = it.animatedValue as Float
|
||||
view.flowProgress = progress
|
||||
view.glowAlpha = if (effect == AppConfig.EDGE_LIGHTING_EFFECT_BREATHING) {
|
||||
val pulse = 0.4f + 0.6f * ((sin(progress * PI * 4.0) + 1.0) / 2.0).toFloat()
|
||||
alpha * pulse
|
||||
} else {
|
||||
alpha
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
edgeLightingAnimator = animator
|
||||
|
||||
// Auto-dismiss after duration
|
||||
handler.postDelayed({ dismissEdgeLighting() }, durationMs.toLong())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun dismissEdgeLighting() {
|
||||
edgeLightingAnimator?.cancel()
|
||||
edgeLightingAnimator = null
|
||||
edgeLightingView?.let { view ->
|
||||
try {
|
||||
val wm = view.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
wm.removeView(view)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
edgeLightingView = null
|
||||
}
|
||||
|
||||
fun onScreenOff() {
|
||||
dismissEdgeLighting()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
internal object LockscreenActionPolicy {
|
||||
fun requiresUnlock(action: String): Boolean = when {
|
||||
action == "home" -> true
|
||||
action == "recent" || action == "recents" -> true
|
||||
action == "kill_app" -> true
|
||||
action == "prev_app" || action == "next_app" -> true
|
||||
action == "clipboard" || action == "universal_copy" -> true
|
||||
action.startsWith("fast_scroll:") -> true
|
||||
action.startsWith("launch_app:") -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
391
app/src/main/java/com/fan/edgex/hook/MainHook.kt
Normal file
391
app/src/main/java/com/fan/edgex/hook/MainHook.kt
Normal file
@@ -0,0 +1,391 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.InputEvent
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import com.fan.edgex.config.ModuleActivationState
|
||||
import de.robv.android.xposed.IXposedHookLoadPackage
|
||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import java.lang.reflect.Method
|
||||
|
||||
class MainHook : IXposedHookLoadPackage, IXposedHookZygoteInit {
|
||||
|
||||
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
|
||||
ModuleRes.init(startupParam.modulePath)
|
||||
ScrollHook.install()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EdgeX"
|
||||
|
||||
/**
|
||||
* Check if the current call was initiated by our own code.
|
||||
* This is used to detect injected events and skip processing them.
|
||||
* Following Xposed Edge Pro's approach.
|
||||
*/
|
||||
fun isCalledByUs(): Boolean {
|
||||
val stackTrace = Throwable().stackTrace
|
||||
for (i in 2 until stackTrace.size) {
|
||||
// Check if our package is in the call stack
|
||||
if (stackTrace[i].className.startsWith("com.fan.edgex")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
|
||||
when (lpparam.packageName) {
|
||||
"android" -> {
|
||||
PremiumPluginLoader.tryLoad()
|
||||
hookInputManager(lpparam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyModuleLoaded(context: android.content.Context) {
|
||||
runCatching {
|
||||
context.sendBroadcast(ModuleActivationState.responseIntent(System.currentTimeMillis()))
|
||||
}.onFailure {
|
||||
XposedBridge.log("$TAG: Failed to notify module loaded: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook InputManagerService.filterInputEvent in system_server
|
||||
* to intercept touch events at the input pipeline level.
|
||||
*
|
||||
* Also enables InputFilter via nativeSetInputFilterEnabled so that
|
||||
* the native InputDispatcher actually calls filterInputEvent.
|
||||
*/
|
||||
private fun hookInputManager(lpparam: XC_LoadPackage.LoadPackageParam) {
|
||||
try {
|
||||
val inputManagerService = XposedHelpers.findClass(
|
||||
"com.android.server.input.InputManagerService", lpparam.classLoader
|
||||
)
|
||||
|
||||
// Hook interceptKeyBeforeDispatching for key event interception
|
||||
// This is the primary method called by the input dispatcher for key events
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
inputManagerService, "interceptKeyBeforeDispatching",
|
||||
"android.os.IBinder",
|
||||
KeyEvent::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
// Check if this is our own injected event
|
||||
if (isCalledByUs()) {
|
||||
return // Let original method handle it
|
||||
}
|
||||
|
||||
val keyEvent = param.args[1] as KeyEvent
|
||||
|
||||
// Process key through KeyManager
|
||||
val context = XposedHelpers.getObjectField(param.thisObject, "mContext") as android.content.Context
|
||||
val consumed = GestureManager.handleKeyEvent(keyEvent, context, param)
|
||||
if (consumed) {
|
||||
// Return non-zero to consume the key (prevent system handling)
|
||||
param.result = -1L
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: interceptKeyBeforeDispatching hook failed: ${t.message}")
|
||||
}
|
||||
|
||||
// 2) Hook filterInputEvent to intercept touch and key events
|
||||
val hook = object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
if (isCalledByUs()) return
|
||||
|
||||
val event = param.args[0] as InputEvent
|
||||
val context = XposedHelpers.getObjectField(param.thisObject, "mContext")
|
||||
as android.content.Context
|
||||
|
||||
when (event) {
|
||||
is MotionEvent -> {
|
||||
if (GestureManager.handleMotionEvent(event, context)) {
|
||||
param.setResult(false)
|
||||
}
|
||||
}
|
||||
is KeyEvent -> {
|
||||
val policyFlags = if (param.args.size > 1 && param.args[1] is Int) {
|
||||
param.args[1] as Int
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (GestureManager.handleKeyEvent(event, context, param, policyFlags)) {
|
||||
param.setResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hooked = false
|
||||
|
||||
// Attempt 1: filterInputEvent(InputEvent, int)
|
||||
if (!hooked) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
inputManagerService, "filterInputEvent",
|
||||
InputEvent::class.java, Int::class.javaPrimitiveType, hook
|
||||
)
|
||||
hooked = true
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 2: filterInputEvent(InputEvent)
|
||||
if (!hooked) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
inputManagerService, "filterInputEvent",
|
||||
InputEvent::class.java, hook
|
||||
)
|
||||
hooked = true
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 3: Reflective fallback
|
||||
if (!hooked) {
|
||||
for (m: Method in inputManagerService.declaredMethods) {
|
||||
if (m.name == "filterInputEvent") {
|
||||
try {
|
||||
XposedBridge.hookMethod(m, hook)
|
||||
hooked = true
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Reflection hook failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hooked) {
|
||||
XposedBridge.log("$TAG: ERROR - Failed to hook filterInputEvent with any method signature")
|
||||
}
|
||||
|
||||
// 2) Enable InputFilter so native InputDispatcher calls filterInputEvent
|
||||
enableInputFilter(inputManagerService, lpparam.classLoader)
|
||||
UniversalCopyManager.installHooks(lpparam.classLoader)
|
||||
ClipboardHook.installHook(lpparam.classLoader)
|
||||
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Error during InputManagerService hook: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable InputFilter so that the native InputDispatcher calls filterInputEvent.
|
||||
* Without this, filterInputEvent is never invoked because InputFilterEnabled defaults to false.
|
||||
*
|
||||
* Android 16+: NativeInputManagerService$NativeImpl.setInputFilterEnabled(boolean)
|
||||
* Legacy: InputManagerService.nativeSetInputFilterEnabled(long, boolean)
|
||||
*/
|
||||
private fun enableInputFilter(inputManagerService: Class<*>, classLoader: ClassLoader) {
|
||||
// Store mNative reference to enable filter after InputManagerService is instantiated
|
||||
var mNativeInstance: Any? = null
|
||||
var inputManagerServiceInstance: Any? = null
|
||||
|
||||
// Hook setInputFilter: when a real filter is set (e.g. accessibility service),
|
||||
// our fake filter is not needed. When the real filter is removed (set to null),
|
||||
// re-register our fake filter so filterInputEvent keeps firing.
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
inputManagerService, "setInputFilter",
|
||||
"android.view.IInputFilter",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
if (param.args[0] == null) {
|
||||
registerFakeInputFilter(param.thisObject, inputManagerService.classLoader!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Failed to hook setInputFilter: ${t.message}")
|
||||
}
|
||||
|
||||
// Android 16+: NativeInputManagerService$NativeImpl
|
||||
try {
|
||||
val nativeImplClass = XposedHelpers.findClass(
|
||||
"com.android.server.input.NativeInputManagerService\$NativeImpl",
|
||||
classLoader
|
||||
)
|
||||
|
||||
// Force InputFilter always enabled — prevents accessibility/system from disabling it
|
||||
XposedHelpers.findAndHookMethod(nativeImplClass, "setInputFilterEnabled",
|
||||
Boolean::class.javaPrimitiveType,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
param.args[0] = true
|
||||
}
|
||||
})
|
||||
|
||||
// Hook InputManagerService constructor to get mNative field reference
|
||||
XposedHelpers.findAndHookConstructor(
|
||||
inputManagerService,
|
||||
android.content.Context::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
inputManagerServiceInstance = param.thisObject
|
||||
mNativeInstance = XposedHelpers.getObjectField(param.thisObject, "mNative")
|
||||
}
|
||||
})
|
||||
|
||||
// Hook start() to enable InputFilter after native layer is ready
|
||||
XposedHelpers.findAndHookMethod(inputManagerService, "start",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
try {
|
||||
val context = XposedHelpers.getObjectField(param.thisObject, "mContext")
|
||||
as android.content.Context
|
||||
GestureManager.initSystemServer(context)
|
||||
PremiumPluginLoader.verifyDeviceBinding(context)
|
||||
notifyModuleLoaded(context)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Failed to initialize GestureManager in start(): ${t.message}")
|
||||
}
|
||||
|
||||
try {
|
||||
val native = mNativeInstance
|
||||
if (native != null) {
|
||||
XposedHelpers.callMethod(native, "setInputFilterEnabled", true)
|
||||
} else {
|
||||
XposedBridge.log("$TAG: mNative is null at start(), cannot enable InputFilter")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Failed to enable InputFilter in start(): ${t.message}")
|
||||
}
|
||||
|
||||
// Register a fake IInputFilter only if no filter is already active.
|
||||
// On physical devices, accessibility services register their own
|
||||
// IInputFilter which already activates the filterInputEvent path.
|
||||
// On AVD (no accessibility services), no filter is ever registered,
|
||||
// so filterInputEvent is never called without this.
|
||||
val ims = inputManagerServiceInstance
|
||||
if (ims != null) {
|
||||
val existingFilter = try {
|
||||
XposedHelpers.getObjectField(ims, "mInputFilter")
|
||||
} catch (_: Throwable) { null }
|
||||
if (existingFilter == null) {
|
||||
registerFakeInputFilter(ims, classLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
|
||||
// Legacy: nativeSetInputFilterEnabled(long ptr, boolean enable)
|
||||
try {
|
||||
val nativeMethod = inputManagerService.getDeclaredMethod(
|
||||
"nativeSetInputFilterEnabled",
|
||||
Long::class.javaPrimitiveType,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
nativeMethod.isAccessible = true
|
||||
|
||||
XposedBridge.hookMethod(nativeMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
param.args[param.args.size - 1] = true
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(inputManagerService, "start",
|
||||
object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
try {
|
||||
val context = XposedHelpers.getObjectField(param.thisObject, "mContext")
|
||||
as android.content.Context
|
||||
GestureManager.initSystemServer(context)
|
||||
PremiumPluginLoader.verifyDeviceBinding(context)
|
||||
notifyModuleLoaded(context)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Failed to initialize GestureManager in start(): ${t.message}")
|
||||
}
|
||||
|
||||
try {
|
||||
val ptr = XposedHelpers.getLongField(param.thisObject, "mPtr")
|
||||
nativeMethod.invoke(null, ptr, true)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Legacy InputFilter enable failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: All InputFilter enable approaches failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a fake IInputFilter so the native InputDispatcher activates the Java
|
||||
* filterInputEvent path. Without a registered IInputFilter, filterInputEvent is
|
||||
* never called even when InputFilterEnabled=true (happens on AVD with no
|
||||
* accessibility services active).
|
||||
*
|
||||
* The filter immediately forwards every event via IInputFilterHost.sendInputEvent
|
||||
* to avoid blocking dispatch. Our InputManagerService.filterInputEvent hook
|
||||
* observes each event for gesture detection before this forwarding happens.
|
||||
*/
|
||||
private fun registerFakeInputFilter(imsInstance: Any, classLoader: ClassLoader) {
|
||||
try {
|
||||
val iInputFilterClass = XposedHelpers.findClass("android.view.IInputFilter", classLoader)
|
||||
val iInputFilterHostClass = XposedHelpers.findClass("android.view.IInputFilterHost", classLoader)
|
||||
val sendInputEvent = iInputFilterHostClass.getMethod(
|
||||
"sendInputEvent",
|
||||
android.view.InputEvent::class.java,
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
|
||||
var hostRef: Any? = null
|
||||
|
||||
val filterProxy = java.lang.reflect.Proxy.newProxyInstance(
|
||||
classLoader,
|
||||
arrayOf(iInputFilterClass),
|
||||
java.lang.reflect.InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"install" -> {
|
||||
hostRef = args?.get(0)
|
||||
}
|
||||
"filterInputEvent" -> {
|
||||
val host = hostRef
|
||||
val event = args?.get(0) as? android.view.InputEvent
|
||||
val policyFlags = args?.get(1) as? Int ?: 0
|
||||
if (host != null && event != null) {
|
||||
try {
|
||||
sendInputEvent.invoke(host, event, policyFlags)
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$TAG: sendInputEvent failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
"asBinder" -> android.os.Binder()
|
||||
else -> null
|
||||
}
|
||||
null
|
||||
}
|
||||
)
|
||||
|
||||
XposedHelpers.callMethod(imsInstance, "setInputFilter", filterProxy)
|
||||
} catch (e: Exception) {
|
||||
XposedBridge.log("$TAG: registerFakeInputFilter failed: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
29
app/src/main/java/com/fan/edgex/hook/ModuleRes.kt
Normal file
29
app/src/main/java/com/fan/edgex/hook/ModuleRes.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.content.res.XModuleResources
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
object ModuleRes {
|
||||
private var res: XModuleResources? = null
|
||||
|
||||
fun init(modulePath: String) {
|
||||
res = XModuleResources.createInstance(modulePath, null)
|
||||
}
|
||||
|
||||
fun getString(@StringRes id: Int, vararg args: Any?): String {
|
||||
val r = res ?: return ""
|
||||
val raw = r.getString(id)
|
||||
return if (args.isEmpty()) raw else String.format(raw, *args)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun getDrawable(@DrawableRes id: Int, tint: Int = Color.WHITE): Drawable? {
|
||||
val r = res ?: return null
|
||||
return try {
|
||||
r.getDrawable(id).also { it.setTint(tint) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
87
app/src/main/java/com/fan/edgex/hook/NativeTouchHandoff.kt
Normal file
87
app/src/main/java/com/fan/edgex/hook/NativeTouchHandoff.kt
Normal file
@@ -0,0 +1,87 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
|
||||
internal class NativeTouchHandoff(
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
data class Session(
|
||||
val savedDownEvent: MotionEvent,
|
||||
val consumeStream: Boolean = true,
|
||||
var nativeStreamCancelled: Boolean = false,
|
||||
var nativeDownInjected: Boolean = false,
|
||||
)
|
||||
|
||||
private var injectMethod: java.lang.reflect.Method? = null
|
||||
|
||||
fun begin(event: MotionEvent): Session =
|
||||
Session(savedDownEvent = MotionEvent.obtain(event))
|
||||
|
||||
fun cancel(session: Session, context: Context) {
|
||||
if (!session.nativeStreamCancelled) {
|
||||
injectEvent(context, session.savedDownEvent, MotionEvent.ACTION_CANCEL)
|
||||
session.nativeStreamCancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatchSavedDownIfNeeded(session: Session, context: Context) {
|
||||
if (!session.nativeDownInjected && !session.nativeStreamCancelled) {
|
||||
injectEvent(context, session.savedDownEvent)
|
||||
session.nativeDownInjected = true
|
||||
}
|
||||
}
|
||||
|
||||
fun resume(session: Session, context: Context, currentEvent: MotionEvent) {
|
||||
if (!session.nativeStreamCancelled) {
|
||||
dispatchSavedDownIfNeeded(session, context)
|
||||
injectEvent(context, currentEvent)
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldProxyToNative(session: Session): Boolean =
|
||||
session.consumeStream && session.nativeDownInjected && !session.nativeStreamCancelled
|
||||
|
||||
fun forwardToNative(session: Session, context: Context, event: MotionEvent) {
|
||||
if (shouldProxyToNative(session)) {
|
||||
injectEvent(context, event)
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose(session: Session) {
|
||||
session.savedDownEvent.recycle()
|
||||
}
|
||||
|
||||
private fun injectEvent(context: Context, event: MotionEvent, action: Int? = null) {
|
||||
try {
|
||||
val inputManager =
|
||||
context.getSystemService(Context.INPUT_SERVICE) as android.hardware.input.InputManager
|
||||
if (injectMethod == null) {
|
||||
injectMethod = inputManager.javaClass.getMethod(
|
||||
"injectInputEvent",
|
||||
android.view.InputEvent::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
}
|
||||
|
||||
val injected = if (action != null) {
|
||||
MotionEvent.obtain(
|
||||
event.downTime,
|
||||
SystemClock.uptimeMillis(),
|
||||
action,
|
||||
event.rawX,
|
||||
event.rawY,
|
||||
event.metaState,
|
||||
)
|
||||
} else {
|
||||
MotionEvent.obtain(event)
|
||||
}
|
||||
|
||||
injectMethod?.invoke(inputManager, injected, 0)
|
||||
injected.recycle()
|
||||
} catch (e: Exception) {
|
||||
log("Proxy injection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
968
app/src/main/java/com/fan/edgex/hook/PartialScreenshotOverlay.kt
Normal file
968
app/src/main/java/com/fan/edgex/hook/PartialScreenshotOverlay.kt
Normal file
@@ -0,0 +1,968 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.hardware.HardwareBuffer
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import androidx.core.graphics.toColorInt
|
||||
import com.fan.edgex.BuildConfig
|
||||
import com.fan.edgex.IShellCallback
|
||||
import com.fan.edgex.IShellExecutor
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import java.io.IOException
|
||||
import java.lang.ref.WeakReference
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal object PartialScreenshotOverlay {
|
||||
|
||||
private const val TAG = "EdgeX:PartialSS"
|
||||
private enum class PanelPos { BOTTOM, TOP }
|
||||
|
||||
private var overlayRef: WeakReference<View>? = null
|
||||
private var wmRef: WeakReference<WindowManager>? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun show(context: Context) {
|
||||
if (overlayRef?.get() != null) return
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
wmRef = WeakReference(wm)
|
||||
Thread {
|
||||
val bitmap = captureDisplayBitmap(context)
|
||||
if (bitmap == null) {
|
||||
XposedBridge.log("$TAG captureDisplayBitmap returned null")
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_failed))
|
||||
wmRef = null
|
||||
return@Thread
|
||||
}
|
||||
handler.post {
|
||||
val root = buildRoot(context, bitmap, wm)
|
||||
overlayRef = WeakReference(root)
|
||||
@Suppress("DEPRECATION")
|
||||
wm.addView(root, WindowManager.LayoutParams().apply {
|
||||
type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
|
||||
layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
|
||||
})
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun dismiss() {
|
||||
val wm = wmRef?.get() ?: return
|
||||
val view = overlayRef?.get() ?: return
|
||||
try { wm.removeView(view) } catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG dismiss failed: ${t.message}")
|
||||
}
|
||||
overlayRef = null
|
||||
wmRef = null
|
||||
}
|
||||
|
||||
// ---- Overlay ----
|
||||
|
||||
private fun buildRoot(context: Context, bitmap: Bitmap, wm: WindowManager): FrameLayout {
|
||||
val dp = context.resources.displayMetrics.density
|
||||
val mp = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
val wc = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
val accentColor = readAccentColor(context)
|
||||
val combinedView = CombinedView(context, bitmap)
|
||||
|
||||
// ── Shared state ──────────────────────────────────────────────────
|
||||
val brushColors = listOf(
|
||||
Color.BLACK, Color.RED, Color.YELLOW, Color.GREEN,
|
||||
Color.BLUE, Color.parseColor("#E040FB"), Color.WHITE
|
||||
)
|
||||
var currentMode = CombinedView.Mode.SELECT
|
||||
var hasSelection = false
|
||||
combinedView.setBrushColor(brushColors[1])
|
||||
combinedView.setMode(CombinedView.Mode.SELECT)
|
||||
|
||||
val circleSize = (52 * dp).toInt()
|
||||
|
||||
// ── View factories (views can't be shared between two parents) ────
|
||||
fun makeCancel() = FrameLayout(context).apply {
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL; setColor(Color.argb(220, 40, 40, 46))
|
||||
}
|
||||
addView(TextView(context).apply {
|
||||
text = "✕"; textSize = 19f; gravity = Gravity.CENTER; setTextColor(Color.WHITE)
|
||||
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize)
|
||||
})
|
||||
setOnClickListener { combinedView.release(); dismiss() }
|
||||
}
|
||||
|
||||
fun makeSave() = FrameLayout(context).apply {
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL; setColor(accentColor)
|
||||
}
|
||||
addView(object : View(context) {
|
||||
private val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE; style = Paint.Style.STROKE
|
||||
strokeWidth = 2.5f * dp; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val w = width.toFloat(); val h = height.toFloat()
|
||||
val path = Path()
|
||||
path.moveTo(w * 0.28f, h * 0.52f)
|
||||
path.lineTo(w * 0.44f, h * 0.67f)
|
||||
path.lineTo(w * 0.72f, h * 0.36f)
|
||||
canvas.drawPath(path, p)
|
||||
}
|
||||
}.apply { layoutParams = FrameLayout.LayoutParams(circleSize, circleSize) })
|
||||
setOnClickListener {
|
||||
val finalBitmap = combinedView.getFinalBitmap()
|
||||
combinedView.release(); dismiss()
|
||||
Thread {
|
||||
try { saveToGallery(context, finalBitmap) }
|
||||
catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG save failed: ${t.message}")
|
||||
finalBitmap.recycle()
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_failed))
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color circles (h-panel only) ──────────────────────────────────
|
||||
val colorCircles = brushColors.mapIndexed { i, color ->
|
||||
View(context).apply {
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL; setColor(color)
|
||||
val sc = if (color == Color.WHITE || color == Color.BLACK)
|
||||
Color.argb(100, 200, 200, 200) else Color.argb(40, 255, 255, 255)
|
||||
setStroke((2 * dp).toInt(), if (i == 1) Color.WHITE else sc)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams((28 * dp).toInt(), (28 * dp).toInt()).apply {
|
||||
setMargins((4 * dp).toInt(), 0, (4 * dp).toInt(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color pill ────────────────────────────────────────────────────
|
||||
val colorCirclesLayout = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL
|
||||
colorCircles.forEach { addView(it) }
|
||||
}
|
||||
val colorPill = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL
|
||||
background = GradientDrawable().apply {
|
||||
setColor(Color.argb(220, 28, 28, 34)); cornerRadius = 40 * dp
|
||||
}
|
||||
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
|
||||
addView(TextView(context).apply {
|
||||
text = "✏"; textSize = 20f; gravity = Gravity.CENTER; setTextColor(Color.WHITE)
|
||||
layoutParams = LinearLayout.LayoutParams(wc, wc).apply { setMargins(0, 0, (8 * dp).toInt(), 0) }
|
||||
})
|
||||
addView(colorCirclesLayout)
|
||||
}
|
||||
val colorPillWrapper = FrameLayout(context).apply {
|
||||
setPadding(0, (6 * dp).toInt(), 0, (6 * dp).toInt())
|
||||
addView(colorPill, FrameLayout.LayoutParams(wc, wc).apply { gravity = Gravity.CENTER_HORIZONTAL })
|
||||
visibility = View.GONE
|
||||
}
|
||||
colorCircles.forEachIndexed { i, v ->
|
||||
v.setOnClickListener {
|
||||
if (!hasSelection) return@setOnClickListener
|
||||
combinedView.setBrushColor(brushColors[i])
|
||||
colorCircles.forEachIndexed { j, cv ->
|
||||
val c = brushColors[j]
|
||||
val sc = when {
|
||||
j == i -> Color.WHITE
|
||||
c == Color.WHITE || c == Color.BLACK -> Color.argb(100, 200, 200, 200)
|
||||
else -> Color.argb(40, 255, 255, 255)
|
||||
}
|
||||
(cv.background as? GradientDrawable)?.setStroke((2 * dp).toInt(), sc)
|
||||
}
|
||||
if (currentMode != CombinedView.Mode.BRUSH) {
|
||||
currentMode = CombinedView.Mode.BRUSH
|
||||
combinedView.setMode(CombinedView.Mode.BRUSH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab factory ───────────────────────────────────────────────────
|
||||
fun makeTab(label: String, size: Float, padH: Int, padV: Int) = TextView(context).apply {
|
||||
text = label; textSize = size; gravity = Gravity.CENTER
|
||||
setTextColor(Color.argb(140, 255, 255, 255))
|
||||
setPadding(padH, padV, padH, padV)
|
||||
}
|
||||
|
||||
// ── h-panel tabs ──────────────────────────────────────────────────
|
||||
val hSelTab = makeTab(ModuleRes.getString(R.string.partial_screenshot_tool_select), 15f, (20*dp).toInt(), (12*dp).toInt())
|
||||
val hBrushTab = makeTab(ModuleRes.getString(R.string.partial_screenshot_tool_brush), 15f, (20*dp).toInt(), (12*dp).toInt())
|
||||
val hMosaicTab = makeTab(ModuleRes.getString(R.string.partial_screenshot_tool_mosaic), 15f, (20*dp).toInt(), (12*dp).toInt())
|
||||
|
||||
// ── Center-row var (assigned after panel construction) ───────────
|
||||
var hCenterRow: LinearLayout? = null
|
||||
|
||||
// ── Shared style updater ──────────────────────────────────────────
|
||||
fun tabColor(isActive: Boolean, avail: Boolean) = when {
|
||||
isActive -> Color.WHITE
|
||||
avail -> Color.argb(180, 255, 255, 255)
|
||||
else -> Color.argb(70, 255, 255, 255)
|
||||
}
|
||||
fun updateStyles() {
|
||||
val isBrush = currentMode == CombinedView.Mode.BRUSH
|
||||
val isMosaic = currentMode == CombinedView.Mode.MOSAIC
|
||||
val isSel = currentMode == CombinedView.Mode.SELECT
|
||||
hSelTab.setTextColor(tabColor(isSel, true))
|
||||
hBrushTab.setTextColor(tabColor(isBrush, hasSelection))
|
||||
hMosaicTab.setTextColor(tabColor(isMosaic, hasSelection))
|
||||
colorPillWrapper.visibility =
|
||||
if (hasSelection && isBrush) View.VISIBLE else View.GONE
|
||||
hCenterRow?.visibility = if (isBrush || isMosaic) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
updateStyles()
|
||||
|
||||
// ── Mode switch ───────────────────────────────────────────────────
|
||||
fun switchMode(m: CombinedView.Mode) {
|
||||
if (m != CombinedView.Mode.SELECT && !hasSelection) return
|
||||
currentMode = m; combinedView.setMode(m); updateStyles()
|
||||
}
|
||||
hSelTab.setOnClickListener { switchMode(CombinedView.Mode.SELECT) }
|
||||
hBrushTab.setOnClickListener { switchMode(CombinedView.Mode.BRUSH) }
|
||||
hMosaicTab.setOnClickListener{ switchMode(CombinedView.Mode.MOSAIC) }
|
||||
|
||||
combinedView.onSelectionChanged = { sel ->
|
||||
hasSelection = sel
|
||||
if (!sel && currentMode != CombinedView.Mode.SELECT) switchMode(CombinedView.Mode.SELECT)
|
||||
else updateStyles()
|
||||
}
|
||||
|
||||
// ── h-panel center row ────────────────────────────────────────────
|
||||
val undoBtn = TextView(context).apply {
|
||||
text = "↩"; textSize = 33f; gravity = Gravity.CENTER; setTextColor(Color.WHITE)
|
||||
setPadding((12*dp).toInt(), (10*dp).toInt(), (12*dp).toInt(), (10*dp).toInt())
|
||||
setOnClickListener { combinedView.undo() }
|
||||
}
|
||||
val redoBtn = TextView(context).apply {
|
||||
text = "↪"; textSize = 33f; gravity = Gravity.CENTER; setTextColor(Color.WHITE)
|
||||
setPadding((12*dp).toInt(), (10*dp).toInt(), (12*dp).toInt(), (10*dp).toInt())
|
||||
setOnClickListener { combinedView.redo() }
|
||||
}
|
||||
|
||||
fun updateUndoRedoState() {
|
||||
val canUndo = combinedView.canUndo()
|
||||
val canRedo = combinedView.canRedo()
|
||||
undoBtn.alpha = if (canUndo) 1f else 0.35f
|
||||
undoBtn.isClickable = canUndo
|
||||
redoBtn.alpha = if (canRedo) 1f else 0.35f
|
||||
redoBtn.isClickable = canRedo
|
||||
}
|
||||
updateUndoRedoState()
|
||||
combinedView.onStackChanged = { updateUndoRedoState() }
|
||||
|
||||
hCenterRow = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER
|
||||
visibility = View.INVISIBLE
|
||||
addView(undoBtn)
|
||||
addView(TextView(context).apply {
|
||||
text = "Reset"; textSize = 17f; gravity = Gravity.CENTER; setTextColor(Color.WHITE)
|
||||
setPadding((6*dp).toInt(), 0, (6*dp).toInt(), 0)
|
||||
setOnClickListener { combinedView.resetAnnotations() }
|
||||
})
|
||||
addView(redoBtn)
|
||||
}
|
||||
|
||||
// ── Horizontal panel ──────────────────────────────────────────────
|
||||
val hPanelBg = GradientDrawable().apply {
|
||||
setColor(Color.argb(230, 18, 18, 20))
|
||||
cornerRadii = floatArrayOf(20*dp, 20*dp, 20*dp, 20*dp, 0f, 0f, 0f, 0f)
|
||||
}
|
||||
val hPanel = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL; background = hPanelBg
|
||||
addView(colorPillWrapper, LinearLayout.LayoutParams(mp, wc))
|
||||
addView(LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER
|
||||
setPadding(0, (10*dp).toInt(), 0, (2*dp).toInt())
|
||||
addView(hSelTab); addView(hBrushTab); addView(hMosaicTab)
|
||||
}, LinearLayout.LayoutParams(mp, wc))
|
||||
addView(View(context).apply { setBackgroundColor(Color.argb(35, 255, 255, 255)) },
|
||||
LinearLayout.LayoutParams(mp, 1))
|
||||
addView(LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding((24*dp).toInt(), (8*dp).toInt(), (24*dp).toInt(), (24*dp).toInt())
|
||||
addView(makeCancel().apply { layoutParams = LinearLayout.LayoutParams(circleSize, circleSize) })
|
||||
addView(hCenterRow!!, LinearLayout.LayoutParams(0, wc, 1f))
|
||||
addView(makeSave().apply { layoutParams = LinearLayout.LayoutParams(circleSize, circleSize) })
|
||||
}, LinearLayout.LayoutParams(mp, wc))
|
||||
}
|
||||
|
||||
// ── Root ──────────────────────────────────────────────────────────
|
||||
val hPanelLp = FrameLayout.LayoutParams(mp, wc, Gravity.BOTTOM)
|
||||
val root = object : FrameLayout(context) {
|
||||
init {
|
||||
addView(combinedView, LayoutParams(mp, mp))
|
||||
addView(hPanel, hPanelLp)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Panel repositioning ───────────────────────────────────────────
|
||||
var currentPos = PanelPos.BOTTOM
|
||||
val topInset = wm.currentWindowMetrics.windowInsets
|
||||
.getInsetsIgnoringVisibility(
|
||||
android.view.WindowInsets.Type.statusBars() or
|
||||
android.view.WindowInsets.Type.displayCutout()
|
||||
).top
|
||||
|
||||
fun applyPos(pos: PanelPos) {
|
||||
if (pos == currentPos) return
|
||||
currentPos = pos
|
||||
when (pos) {
|
||||
PanelPos.BOTTOM -> {
|
||||
root.updateViewLayout(hPanel, hPanelLp.also { it.gravity = Gravity.BOTTOM })
|
||||
hPanelBg.cornerRadii = floatArrayOf(20*dp, 20*dp, 20*dp, 20*dp, 0f, 0f, 0f, 0f)
|
||||
hPanel.setPadding(0, 0, 0, 0)
|
||||
// color pill above tool row
|
||||
hPanel.removeView(colorPillWrapper)
|
||||
hPanel.addView(colorPillWrapper, 0)
|
||||
}
|
||||
PanelPos.TOP -> {
|
||||
root.updateViewLayout(hPanel, hPanelLp.also { it.gravity = Gravity.TOP })
|
||||
hPanelBg.cornerRadii = floatArrayOf(0f, 0f, 0f, 0f, 20*dp, 20*dp, 20*dp, 20*dp)
|
||||
hPanel.setPadding(0, topInset, 0, 0)
|
||||
// color pill below tool row (expands downward, away from screen edge)
|
||||
hPanel.removeView(colorPillWrapper)
|
||||
hPanel.addView(colorPillWrapper, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reposition(selRect: RectF?) {
|
||||
if (!hasSelection || selRect == null) { applyPos(PanelPos.BOTTOM); return }
|
||||
val screenH = root.height.toFloat().takeIf { it > 0 }
|
||||
?: wm.currentWindowMetrics.bounds.height().toFloat()
|
||||
val panelH = hPanel.height.toFloat().takeIf { it > 0 } ?: (130 * dp)
|
||||
val margin = 32 * dp
|
||||
val pos = if (selRect.bottom < screenH - panelH - margin) PanelPos.BOTTOM else PanelPos.TOP
|
||||
applyPos(pos)
|
||||
}
|
||||
|
||||
combinedView.onSelectionUpdated = { rect -> reposition(rect) }
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// ---- Theme ----
|
||||
|
||||
private fun readAccentColor(context: Context): Int {
|
||||
val snapshot = HookConfigSnapshot.readFromHookFile()
|
||||
val presetId = snapshot[AppConfig.THEME_PRESET] ?: ""
|
||||
return when (presetId) {
|
||||
"custom" -> runCatching {
|
||||
(snapshot[AppConfig.THEME_CUSTOM_COLOR] ?: "").toColorInt()
|
||||
}.getOrElse { "#326D32".toColorInt() }
|
||||
"classic" -> "#00796B".toColorInt()
|
||||
"cedar" -> "#496B3D".toColorInt()
|
||||
"ocean" -> "#2F6F8F".toColorInt()
|
||||
"ember" -> "#C56B2A".toColorInt()
|
||||
else -> "#326D32".toColorInt()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Screen capture ----
|
||||
|
||||
private fun captureDisplayBitmap(context: Context): Bitmap? {
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val bounds = wm.currentWindowMetrics.bounds
|
||||
val w = bounds.width(); val h = bounds.height()
|
||||
|
||||
val scClass = runCatching { Class.forName("android.window.ScreenCapture") }.getOrNull() ?: return null
|
||||
|
||||
runCatching {
|
||||
val paramsClass = scClass.declaredClasses.firstOrNull { "ScreenCaptureParams" in it.simpleName }
|
||||
?: Class.forName("android.window.ScreenCaptureParams")
|
||||
val builderClass = paramsClass.declaredClasses.firstOrNull { "Builder" in it.simpleName }!!
|
||||
val builder = builderClass.getConstructor(Int::class.javaPrimitiveType)
|
||||
.newInstance(android.view.Display.DEFAULT_DISPLAY)
|
||||
val params = XposedHelpers.callMethod(builder, "build")
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var captureResultObj: Any? = null
|
||||
val receiver = java.lang.reflect.Proxy.newProxyInstance(
|
||||
scClass.classLoader, arrayOf(Class.forName("android.os.OutcomeReceiver"))
|
||||
) { _, method, args ->
|
||||
when (method.name) {
|
||||
"onResult" -> { captureResultObj = args?.get(0); latch.countDown() }
|
||||
"onError" -> { XposedBridge.log("$TAG onError: ${args?.get(0)}"); latch.countDown() }
|
||||
}
|
||||
null
|
||||
}
|
||||
XposedHelpers.callStaticMethod(scClass, "capture", params,
|
||||
java.util.concurrent.Executors.newSingleThreadExecutor(), receiver)
|
||||
if (!latch.await(5, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
XposedBridge.log("$TAG capture timed out"); return null
|
||||
}
|
||||
return captureResultObj?.let { hwBufToBitmap(it) }
|
||||
}.onFailure { XposedBridge.log("$TAG Android16 capture failed: ${it.message}") }
|
||||
|
||||
val token = resolveDisplayToken(context) ?: return null
|
||||
runCatching {
|
||||
val bc = Class.forName("android.window.ScreenCapture\$DisplayCaptureArgs\$Builder")
|
||||
val builder = bc.getConstructor(android.os.IBinder::class.java).newInstance(token)
|
||||
XposedHelpers.callMethod(builder, "setSize", w, h)
|
||||
val args = XposedHelpers.callMethod(builder, "build")
|
||||
return hwBufToBitmap(XposedHelpers.callStaticMethod(scClass, "captureDisplay", args) ?: return null)
|
||||
}.onFailure { XposedBridge.log("$TAG captureDisplay(IBinder) failed: ${it.message}") }
|
||||
|
||||
runCatching {
|
||||
val sc2 = Class.forName("android.view.SurfaceControl")
|
||||
val bc = Class.forName("android.view.SurfaceControl\$DisplayCaptureArgs\$Builder")
|
||||
val builder = bc.getConstructor(android.os.IBinder::class.java).newInstance(token)
|
||||
XposedHelpers.callMethod(builder, "setSize", w, h)
|
||||
val args = XposedHelpers.callMethod(builder, "build")
|
||||
return hwBufToBitmap(XposedHelpers.callStaticMethod(sc2, "captureDisplay", args) ?: return null)
|
||||
}.onFailure { XposedBridge.log("$TAG SurfaceControl fallback failed: ${it.message}") }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hwBufToBitmap(screenshotHwBuf: Any): Bitmap? {
|
||||
val hwBuf = XposedHelpers.callMethod(screenshotHwBuf, "getHardwareBuffer") as? HardwareBuffer ?: return null
|
||||
val colorSpace = runCatching {
|
||||
XposedHelpers.callMethod(screenshotHwBuf, "getColorSpace") as? ColorSpace
|
||||
}.getOrNull()
|
||||
val hw = Bitmap.wrapHardwareBuffer(hwBuf, colorSpace)
|
||||
hwBuf.close(); hw ?: return null
|
||||
val soft = hw.copy(Bitmap.Config.ARGB_8888, false)
|
||||
hw.recycle(); return soft
|
||||
}
|
||||
|
||||
private fun resolveDisplayToken(context: Context): android.os.IBinder? {
|
||||
runCatching {
|
||||
val dmg = Class.forName("android.hardware.display.DisplayManagerGlobal")
|
||||
val instance = XposedHelpers.callStaticMethod(dmg, "getInstance")
|
||||
val info = XposedHelpers.callMethod(instance, "getDisplayInfo", android.view.Display.DEFAULT_DISPLAY)
|
||||
if (info != null)
|
||||
(XposedHelpers.getObjectField(info, "displayToken") as? android.os.IBinder)?.let { return it }
|
||||
}
|
||||
runCatching {
|
||||
val sc = Class.forName("android.view.SurfaceControl")
|
||||
val ids = XposedHelpers.callStaticMethod(sc, "getPhysicalDisplayIds") as? LongArray
|
||||
if (ids != null && ids.isNotEmpty())
|
||||
return XposedHelpers.callStaticMethod(sc, "getPhysicalDisplayToken", ids[0]) as? android.os.IBinder
|
||||
}
|
||||
runCatching {
|
||||
return XposedHelpers.callStaticMethod(
|
||||
Class.forName("android.view.SurfaceControl"), "getInternalDisplayToken"
|
||||
) as? android.os.IBinder
|
||||
}
|
||||
runCatching {
|
||||
val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
return XposedHelpers.callMethod(
|
||||
dm.getDisplay(android.view.Display.DEFAULT_DISPLAY), "getDisplayToken"
|
||||
) as? android.os.IBinder
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun saveToGallery(context: Context, bitmap: Bitmap) {
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val displayName = "Screenshot_$timestamp.png"
|
||||
if (saveToGalleryViaAppProcess(context, bitmap, displayName)) return
|
||||
|
||||
saveToGalleryDirect(context, bitmap, displayName)
|
||||
}
|
||||
|
||||
private fun saveToGalleryViaAppProcess(context: Context, bitmap: Bitmap, displayName: String): Boolean {
|
||||
val completed = AtomicBoolean(false)
|
||||
val bitmapRecycled = AtomicBoolean(false)
|
||||
var readSide: ParcelFileDescriptor? = null
|
||||
var writeSide: ParcelFileDescriptor? = null
|
||||
lateinit var connection: ServiceConnection
|
||||
|
||||
fun recycleBitmapOnce() {
|
||||
if (bitmapRecycled.compareAndSet(false, true)) {
|
||||
runCatching { bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
|
||||
fun closePipe() {
|
||||
runCatching { readSide?.close() }
|
||||
runCatching { writeSide?.close() }
|
||||
readSide = null
|
||||
writeSide = null
|
||||
}
|
||||
|
||||
fun finish(success: Boolean, message: String?) {
|
||||
if (!completed.compareAndSet(false, true)) return
|
||||
handler.removeCallbacksAndMessages(connection)
|
||||
runCatching { context.unbindService(connection) }
|
||||
if (success) {
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_saved))
|
||||
} else {
|
||||
if (!message.isNullOrBlank()) XposedBridge.log("$TAG app save failed: $message")
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_failed))
|
||||
}
|
||||
}
|
||||
|
||||
val timeout = Runnable {
|
||||
if (!completed.compareAndSet(false, true)) return@Runnable
|
||||
XposedBridge.log("$TAG app save timed out")
|
||||
closePipe()
|
||||
recycleBitmapOnce()
|
||||
runCatching { context.unbindService(connection) }
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_failed))
|
||||
}
|
||||
|
||||
val callback = object : IShellCallback.Stub() {
|
||||
override fun onResult(success: Boolean, output: String?) {
|
||||
finish(success, output)
|
||||
}
|
||||
}
|
||||
|
||||
connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
try {
|
||||
val executor = IShellExecutor.Stub.asInterface(binder)
|
||||
val pipe = ParcelFileDescriptor.createPipe()
|
||||
readSide = pipe[0]
|
||||
writeSide = pipe[1]
|
||||
executor.savePngToGallery(readSide, displayName, callback)
|
||||
runCatching { readSide?.close() }
|
||||
readSide = null
|
||||
Thread {
|
||||
try {
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeSide).use { output ->
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) {
|
||||
throw IOException("Bitmap compression returned false")
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG pipe write failed: ${t.message}")
|
||||
finish(false, t.message)
|
||||
} finally {
|
||||
writeSide = null
|
||||
recycleBitmapOnce()
|
||||
}
|
||||
}.start()
|
||||
} catch (t: Throwable) {
|
||||
closePipe()
|
||||
recycleBitmapOnce()
|
||||
finish(false, t.message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
finish(false, "ShellExecutorService disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent().apply {
|
||||
component = ComponentName(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
"${BuildConfig.APPLICATION_ID}.config.ShellExecutorService",
|
||||
)
|
||||
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
|
||||
}
|
||||
|
||||
return try {
|
||||
val bound = context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
if (bound) {
|
||||
handler.postDelayed(timeout, connection, 15_000L)
|
||||
} else {
|
||||
XposedBridge.log("$TAG app save bindService returned false")
|
||||
}
|
||||
bound
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG app save bind failed: ${t.message}")
|
||||
closePipe()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToGalleryDirect(context: Context, bitmap: Bitmap, displayName: String) {
|
||||
var uri: android.net.Uri? = null
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Screenshots")
|
||||
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
||||
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
try {
|
||||
val resolver = context.contentResolver
|
||||
uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IOException("MediaStore insert returned null")
|
||||
val output = resolver.openOutputStream(uri, "w")
|
||||
?: throw IOException("MediaStore output stream is null")
|
||||
output.use {
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) {
|
||||
throw IOException("Bitmap compression returned false")
|
||||
}
|
||||
}
|
||||
resolver.update(
|
||||
uri,
|
||||
ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) },
|
||||
null,
|
||||
null,
|
||||
)
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_saved))
|
||||
} catch (t: Throwable) {
|
||||
uri?.let { runCatching { context.contentResolver.delete(it, null, null) } }
|
||||
XposedBridge.log("$TAG saveToGallery failed: ${t.message}")
|
||||
showToast(context, ModuleRes.getString(R.string.partial_screenshot_failed))
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(context: Context, text: String) {
|
||||
handler.post {
|
||||
try { Toast.makeText(context, text, Toast.LENGTH_SHORT).show() } catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Combined view ----
|
||||
|
||||
private class CombinedView(context: Context, sourceBitmap: Bitmap) : View(context) {
|
||||
|
||||
enum class Mode { SELECT, BRUSH, MOSAIC }
|
||||
|
||||
private var mode = Mode.SELECT
|
||||
private var brushColor = Color.RED
|
||||
|
||||
var onSelectionChanged: ((Boolean) -> Unit)? = null
|
||||
var onSelectionUpdated: ((RectF?) -> Unit)? = null
|
||||
var onStackChanged: (() -> Unit)? = null
|
||||
|
||||
fun canUndo() = undoStack.isNotEmpty()
|
||||
fun canRedo() = redoStack.isNotEmpty()
|
||||
|
||||
private val originalBitmap: Bitmap = sourceBitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
private val editBitmap: Bitmap = sourceBitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
private val editCanvas = Canvas(editBitmap)
|
||||
|
||||
private val displayMatrix = Matrix()
|
||||
private val inverseMatrix = Matrix()
|
||||
private var displayScale = 1f
|
||||
|
||||
private val undoStack = ArrayDeque<Bitmap>()
|
||||
private val redoStack = ArrayDeque<Bitmap>()
|
||||
private val MAX_UNDO = 5
|
||||
|
||||
private enum class TouchMode { NONE, DRAW, MOVE }
|
||||
private var touchMode = TouchMode.NONE
|
||||
private var startX = 0f; private var startY = 0f
|
||||
private var endX = 0f; private var endY = 0f
|
||||
private var hasSelection = false
|
||||
set(value) {
|
||||
if (field != value) { field = value; onSelectionChanged?.invoke(value) }
|
||||
}
|
||||
private var dragAnchorX = 0f; private var dragAnchorY = 0f
|
||||
private var moveBaseStartX = 0f; private var moveBaseStartY = 0f
|
||||
private var moveBaseEndX = 0f; private var moveBaseEndY = 0f
|
||||
|
||||
private var currentPath: Path? = null
|
||||
private var lastBitmapX = 0f; private var lastBitmapY = 0f
|
||||
|
||||
private val brushStrokeWidthBitmap get() = (8f * resources.displayMetrics.density) / displayScale
|
||||
|
||||
private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
private val darkPaint = Paint().apply { color = Color.argb(155, 0, 0, 0); style = Paint.Style.FILL }
|
||||
private val borderPaint = Paint().apply {
|
||||
color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 2f; isAntiAlias = true
|
||||
}
|
||||
private val handlePaint = Paint().apply { color = Color.WHITE; style = Paint.Style.FILL; isAntiAlias = true }
|
||||
private val brushPaint = Paint().apply {
|
||||
isAntiAlias = true; style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
private val dimRect = RectF()
|
||||
private val selRect = RectF()
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
if (w <= 0 || h <= 0) return
|
||||
displayScale = minOf(w / editBitmap.width.toFloat(), h / editBitmap.height.toFloat())
|
||||
.coerceAtLeast(0.001f)
|
||||
val dx = (w - editBitmap.width * displayScale) / 2f
|
||||
val dy = (h - editBitmap.height * displayScale) / 2f
|
||||
displayMatrix.reset()
|
||||
displayMatrix.postScale(displayScale, displayScale)
|
||||
displayMatrix.postTranslate(dx, dy)
|
||||
displayMatrix.invert(inverseMatrix)
|
||||
}
|
||||
|
||||
fun setMode(m: Mode) { mode = m; invalidate() }
|
||||
fun setBrushColor(color: Int) { brushColor = color }
|
||||
|
||||
private fun pushToUndo() {
|
||||
if (undoStack.size >= MAX_UNDO) undoStack.removeFirst().recycle()
|
||||
undoStack.addLast(editBitmap.copy(Bitmap.Config.ARGB_8888, false))
|
||||
}
|
||||
|
||||
private fun clearRedo() {
|
||||
redoStack.forEach { if (!it.isRecycled) it.recycle() }
|
||||
redoStack.clear()
|
||||
}
|
||||
|
||||
private fun pushUndo() { pushToUndo(); clearRedo(); onStackChanged?.invoke() }
|
||||
|
||||
fun undo() {
|
||||
if (undoStack.isEmpty()) return
|
||||
if (redoStack.size >= MAX_UNDO) redoStack.removeFirst().recycle()
|
||||
redoStack.addLast(editBitmap.copy(Bitmap.Config.ARGB_8888, false))
|
||||
val prev = undoStack.removeLast()
|
||||
editCanvas.drawBitmap(prev, 0f, 0f,
|
||||
Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) })
|
||||
if (!prev.isRecycled) prev.recycle()
|
||||
invalidate()
|
||||
onStackChanged?.invoke()
|
||||
}
|
||||
|
||||
fun redo() {
|
||||
if (redoStack.isEmpty()) return
|
||||
pushToUndo()
|
||||
val next = redoStack.removeLast()
|
||||
editCanvas.drawBitmap(next, 0f, 0f,
|
||||
Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) })
|
||||
if (!next.isRecycled) next.recycle()
|
||||
invalidate()
|
||||
onStackChanged?.invoke()
|
||||
}
|
||||
|
||||
fun resetAnnotations() {
|
||||
clearRedo()
|
||||
undoStack.forEach { if (!it.isRecycled) it.recycle() }
|
||||
undoStack.clear()
|
||||
editCanvas.drawBitmap(originalBitmap, 0f, 0f,
|
||||
Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) })
|
||||
invalidate()
|
||||
onStackChanged?.invoke()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (!editBitmap.isRecycled) editBitmap.recycle()
|
||||
if (!originalBitmap.isRecycled) originalBitmap.recycle()
|
||||
undoStack.forEach { if (!it.isRecycled) it.recycle() }
|
||||
undoStack.clear(); clearRedo()
|
||||
}
|
||||
|
||||
fun getFinalBitmap(): Bitmap {
|
||||
commitBrushPath()
|
||||
val sel = selectionBitmapRect() ?: return editBitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
val w = sel.width().toInt().coerceAtLeast(1)
|
||||
val h = sel.height().toInt().coerceAtLeast(1)
|
||||
return Bitmap.createBitmap(editBitmap, sel.left.toInt(), sel.top.toInt(), w, h)
|
||||
}
|
||||
|
||||
private fun normalizedRect() = RectF(
|
||||
minOf(startX, endX), minOf(startY, endY),
|
||||
maxOf(startX, endX), maxOf(startY, endY)
|
||||
)
|
||||
|
||||
private fun selectionBitmapRect(): RectF? {
|
||||
if (!hasSelection) return null
|
||||
val vr = normalizedRect()
|
||||
val pts = floatArrayOf(vr.left, vr.top, vr.right, vr.bottom)
|
||||
inverseMatrix.mapPoints(pts)
|
||||
return RectF(
|
||||
pts[0].coerceIn(0f, editBitmap.width.toFloat()),
|
||||
pts[1].coerceIn(0f, editBitmap.height.toFloat()),
|
||||
pts[2].coerceIn(0f, editBitmap.width.toFloat()),
|
||||
pts[3].coerceIn(0f, editBitmap.height.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
private fun viewToBitmap(vx: Float, vy: Float): FloatArray {
|
||||
val pts = floatArrayOf(vx, vy)
|
||||
inverseMatrix.mapPoints(pts)
|
||||
return pts
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (mode) {
|
||||
Mode.SELECT -> handleSelectTouch(event)
|
||||
Mode.BRUSH -> handleBrushTouch(event)
|
||||
Mode.MOSAIC -> handleMosaicTouch(event)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleSelectTouch(event: MotionEvent) {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (hasSelection && normalizedRect().contains(event.x, event.y)) {
|
||||
touchMode = TouchMode.MOVE
|
||||
dragAnchorX = event.x; dragAnchorY = event.y
|
||||
moveBaseStartX = startX; moveBaseStartY = startY
|
||||
moveBaseEndX = endX; moveBaseEndY = endY
|
||||
} else {
|
||||
touchMode = TouchMode.DRAW
|
||||
startX = event.x; startY = event.y
|
||||
endX = event.x; endY = event.y
|
||||
hasSelection = false
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
when (touchMode) {
|
||||
TouchMode.MOVE -> {
|
||||
val dx = event.x - dragAnchorX; val dy = event.y - dragAnchorY
|
||||
startX = moveBaseStartX + dx; startY = moveBaseStartY + dy
|
||||
endX = moveBaseEndX + dx; endY = moveBaseEndY + dy
|
||||
}
|
||||
TouchMode.DRAW -> { endX = event.x; endY = event.y; hasSelection = true }
|
||||
TouchMode.NONE -> {}
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
when (touchMode) {
|
||||
TouchMode.MOVE -> {
|
||||
val dx = event.x - dragAnchorX; val dy = event.y - dragAnchorY
|
||||
startX = moveBaseStartX + dx; startY = moveBaseStartY + dy
|
||||
endX = moveBaseEndX + dx; endY = moveBaseEndY + dy
|
||||
}
|
||||
TouchMode.DRAW -> {
|
||||
endX = event.x; endY = event.y
|
||||
hasSelection = normalizedRect().let { it.width() > 10f && it.height() > 10f }
|
||||
}
|
||||
TouchMode.NONE -> {}
|
||||
}
|
||||
touchMode = TouchMode.NONE
|
||||
invalidate()
|
||||
onSelectionUpdated?.invoke(if (hasSelection) RectF(normalizedRect()) else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBrushTouch(event: MotionEvent) {
|
||||
val pts = viewToBitmap(event.x, event.y)
|
||||
val bx = pts[0].coerceIn(0f, editBitmap.width.toFloat())
|
||||
val by = pts[1].coerceIn(0f, editBitmap.height.toFloat())
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
pushUndo()
|
||||
currentPath = Path().apply { moveTo(bx, by) }
|
||||
lastBitmapX = bx; lastBitmapY = by
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val midX = (bx + lastBitmapX) / 2f; val midY = (by + lastBitmapY) / 2f
|
||||
currentPath?.quadTo(lastBitmapX, lastBitmapY, midX, midY)
|
||||
lastBitmapX = bx; lastBitmapY = by
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
currentPath?.lineTo(bx, by)
|
||||
commitBrushPath()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun commitBrushPath() {
|
||||
val path = currentPath ?: return
|
||||
val sel = selectionBitmapRect()
|
||||
brushPaint.color = brushColor
|
||||
brushPaint.strokeWidth = brushStrokeWidthBitmap
|
||||
editCanvas.save()
|
||||
if (sel != null) editCanvas.clipRect(sel)
|
||||
editCanvas.drawPath(path, brushPaint)
|
||||
editCanvas.restore()
|
||||
currentPath = null
|
||||
}
|
||||
|
||||
private fun handleMosaicTouch(event: MotionEvent) {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> { pushUndo(); applyMosaic(event.x, event.y) }
|
||||
MotionEvent.ACTION_MOVE -> applyMosaic(event.x, event.y)
|
||||
MotionEvent.ACTION_UP -> invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyMosaic(vx: Float, vy: Float) {
|
||||
val sel = selectionBitmapRect() ?: return
|
||||
val pts = viewToBitmap(vx, vy)
|
||||
val bx = pts[0].toInt(); val by = pts[1].toInt()
|
||||
val blockSize = (20f / displayScale).toInt().coerceAtLeast(4)
|
||||
val halfBrush = (30f / displayScale).toInt().coerceAtLeast(4)
|
||||
val left = (bx - halfBrush).toFloat().coerceAtLeast(sel.left).toInt().coerceIn(0, editBitmap.width)
|
||||
val top = (by - halfBrush).toFloat().coerceAtLeast(sel.top).toInt().coerceIn(0, editBitmap.height)
|
||||
val right = (bx + halfBrush).toFloat().coerceAtMost(sel.right).toInt().coerceIn(0, editBitmap.width)
|
||||
val bottom = (by + halfBrush).toFloat().coerceAtMost(sel.bottom).toInt().coerceIn(0, editBitmap.height)
|
||||
val rw = right - left; val rh = bottom - top
|
||||
if (rw <= 0 || rh <= 0) return
|
||||
val region = Bitmap.createBitmap(editBitmap, left, top, rw, rh)
|
||||
val pixelated = pixelateBitmap(region, blockSize)
|
||||
region.recycle()
|
||||
editCanvas.drawBitmap(pixelated, left.toFloat(), top.toFloat(), null)
|
||||
pixelated.recycle()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun pixelateBitmap(src: Bitmap, blockSize: Int): Bitmap {
|
||||
val smallW = (src.width / blockSize).coerceAtLeast(1)
|
||||
val smallH = (src.height / blockSize).coerceAtLeast(1)
|
||||
val small = Bitmap.createScaledBitmap(src, smallW, smallH, false)
|
||||
val result = Bitmap.createScaledBitmap(small, src.width, src.height, false)
|
||||
small.recycle(); return result
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
canvas.drawBitmap(editBitmap, displayMatrix, bitmapPaint)
|
||||
|
||||
val path = currentPath
|
||||
if (path != null && mode == Mode.BRUSH) {
|
||||
val previewPath = Path(path).also { it.transform(displayMatrix) }
|
||||
canvas.save()
|
||||
if (hasSelection) canvas.clipRect(normalizedRect())
|
||||
canvas.drawPath(previewPath, Paint(brushPaint).apply {
|
||||
color = brushColor; strokeWidth = brushStrokeWidthBitmap * displayScale
|
||||
})
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
if (!hasSelection) {
|
||||
canvas.drawColor(Color.argb(155, 0, 0, 0))
|
||||
} else {
|
||||
selRect.set(normalizedRect())
|
||||
val vw = width.toFloat(); val vh = height.toFloat()
|
||||
val l = selRect.left; val t = selRect.top; val r = selRect.right; val b = selRect.bottom
|
||||
dimRect.set(0f, 0f, vw, t); canvas.drawRect(dimRect, darkPaint)
|
||||
dimRect.set(0f, b, vw, vh); canvas.drawRect(dimRect, darkPaint)
|
||||
dimRect.set(0f, t, l, b); canvas.drawRect(dimRect, darkPaint)
|
||||
dimRect.set(r, t, vw, b); canvas.drawRect(dimRect, darkPaint)
|
||||
}
|
||||
|
||||
if (hasSelection) {
|
||||
selRect.set(normalizedRect())
|
||||
val l = selRect.left; val t = selRect.top; val r = selRect.right; val b = selRect.bottom
|
||||
canvas.drawRect(selRect, borderPaint)
|
||||
val hs = 18f
|
||||
canvas.drawRect(l - 2f, t - 2f, l + hs, t + 2f, handlePaint)
|
||||
canvas.drawRect(l - 2f, t - 2f, l + 2f, t + hs, handlePaint)
|
||||
canvas.drawRect(r - hs, t - 2f, r + 2f, t + 2f, handlePaint)
|
||||
canvas.drawRect(r - 2f, t - 2f, r + 2f, t + hs, handlePaint)
|
||||
canvas.drawRect(l - 2f, b - 2f, l + hs, b + 2f, handlePaint)
|
||||
canvas.drawRect(l - 2f, b - hs, l + 2f, b + 2f, handlePaint)
|
||||
canvas.drawRect(r - hs, b - 2f, r + 2f, b + 2f, handlePaint)
|
||||
canvas.drawRect(r - 2f, b - hs, r + 2f, b + 2f, handlePaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import com.fan.edgex.premium.PremiumInstall
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.Base64
|
||||
import java.util.Properties
|
||||
|
||||
internal object PremiumInstallMetadata {
|
||||
private val sha256Pattern = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
fun verify(dex: File, meta: File, allowLocalDebug: Boolean): InstallMeta {
|
||||
val properties = Properties()
|
||||
FileInputStream(meta).use(properties::load)
|
||||
|
||||
val version = properties.getProperty("version")?.toIntOrNull()
|
||||
?: error("missing version")
|
||||
require(version == PremiumInstall.SUPPORTED_API_VERSION) {
|
||||
"unsupported version=$version"
|
||||
}
|
||||
|
||||
val expectedSize = properties.getProperty("size")?.toLongOrNull()
|
||||
?: error("missing size")
|
||||
require(dex.length() == expectedSize) {
|
||||
"size mismatch expected=$expectedSize actual=${dex.length()}"
|
||||
}
|
||||
|
||||
val expectedHash = properties.getProperty("sha256")?.trim()
|
||||
?: error("missing sha256")
|
||||
require(sha256Pattern.matches(expectedHash)) { "invalid sha256" }
|
||||
require(PremiumSignatureVerifier.sha256Hex(dex).equals(expectedHash, ignoreCase = true)) {
|
||||
"sha256 mismatch"
|
||||
}
|
||||
|
||||
val devicePubkeyHex = properties.getProperty("device_pubkey")?.trim()
|
||||
?: error("missing device_pubkey")
|
||||
require(devicePubkeyHex.length >= 100 && devicePubkeyHex.length % 2 == 0) {
|
||||
"invalid device_pubkey"
|
||||
}
|
||||
|
||||
val localDebug = properties.getProperty("local_debug")?.trim() == "true"
|
||||
require(!localDebug || allowLocalDebug) { "local_debug requires a debug build" }
|
||||
val signature = if (localDebug) {
|
||||
ByteArray(0)
|
||||
} else {
|
||||
val encoded = properties.getProperty("device_sig")?.trim()
|
||||
?: error("missing device_sig")
|
||||
Base64.getDecoder().decode(encoded)
|
||||
}
|
||||
|
||||
return InstallMeta(
|
||||
sha256 = expectedHash.lowercase(),
|
||||
devicePubkeyHex = devicePubkeyHex,
|
||||
deviceSigBytes = signature,
|
||||
localDebug = localDebug,
|
||||
)
|
||||
}
|
||||
|
||||
data class InstallMeta(
|
||||
val sha256: String,
|
||||
val devicePubkeyHex: String,
|
||||
val deviceSigBytes: ByteArray,
|
||||
val localDebug: Boolean,
|
||||
)
|
||||
}
|
||||
263
app/src/main/java/com/fan/edgex/hook/PremiumPluginLoader.kt
Normal file
263
app/src/main/java/com/fan/edgex/hook/PremiumPluginLoader.kt
Normal file
@@ -0,0 +1,263 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.fan.edgex.BuildConfig
|
||||
import com.fan.edgex.IKeystoreVerifier
|
||||
import com.fan.edgex.premium.IPremiumPlugin
|
||||
import com.fan.edgex.premium.PremiumInstall
|
||||
import dalvik.system.DexClassLoader
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import java.io.File
|
||||
import java.security.KeyFactory
|
||||
import java.security.SecureRandom
|
||||
import java.security.Signature
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
||||
object PremiumPluginLoader {
|
||||
private const val PLUGIN_CLASS = "com.fan.edgex.premium.PremiumPluginImpl"
|
||||
private const val VERIFIER_PKG = "com.fan.edgex"
|
||||
private const val VERIFIER_SVC = "com.fan.edgex.license.KeystoreVerifierService"
|
||||
private const val INITIAL_DELAY_MS = 15_000L
|
||||
private const val RETRY_DELAY_MS = 10_000L
|
||||
private const val MAX_ATTEMPTS = 8
|
||||
|
||||
@Volatile private var disabledForProcess = false
|
||||
@Volatile private var pendingPlugin: IPremiumPlugin? = null
|
||||
@Volatile private var storedPubKeyBytes: ByteArray? = null
|
||||
@Volatile private var challengeAttempt = 0
|
||||
@Volatile private var activeConnection: ServiceConnection? = null
|
||||
|
||||
@Volatile var plugin: IPremiumPlugin? = null
|
||||
private set
|
||||
|
||||
private val handler by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
/**
|
||||
* Stage 1: hash-verify and server-signature-verify the DEX before loading it.
|
||||
* Called from handleLoadPackage (no Context yet). Sets pendingPlugin on success;
|
||||
* plugin remains null until verifyDeviceBinding() passes.
|
||||
*/
|
||||
fun tryLoad() {
|
||||
if (disabledForProcess || plugin != null || pendingPlugin != null) return
|
||||
|
||||
val dex = File(PremiumInstall.DEX_PATH)
|
||||
val meta = File(PremiumInstall.META_PATH)
|
||||
if (!dex.isFile || !meta.isFile) return
|
||||
|
||||
runCatching {
|
||||
val installMeta = PremiumInstallMetadata.verify(dex, meta, BuildConfig.DEBUG)
|
||||
if (!installMeta.localDebug) {
|
||||
require(PremiumSignatureVerifier.verifyInstallationSignature(
|
||||
dex = dex,
|
||||
expectedDexHash = installMeta.sha256,
|
||||
devicePubkeyHex = installMeta.devicePubkeyHex,
|
||||
sigBytes = installMeta.deviceSigBytes,
|
||||
)) {
|
||||
"installation signature invalid"
|
||||
}
|
||||
}
|
||||
storedPubKeyBytes = installMeta.devicePubkeyHex.hexToByteArray()
|
||||
val parent = IPremiumPlugin::class.java.classLoader
|
||||
?: ClassLoader.getSystemClassLoader()
|
||||
val loader = object : DexClassLoader(dex.absolutePath, null, null, parent) {
|
||||
override fun loadClass(name: String, resolve: Boolean): Class<*> {
|
||||
findLoadedClass(name)?.let { return it }
|
||||
return try {
|
||||
findClass(name)
|
||||
} catch (_: ClassNotFoundException) {
|
||||
super.loadClass(name, resolve)
|
||||
}
|
||||
}
|
||||
}
|
||||
val instance = loader.loadClass(PLUGIN_CLASS)
|
||||
.getDeclaredConstructor()
|
||||
.newInstance() as IPremiumPlugin
|
||||
require(instance.apiVersion == PremiumInstall.SUPPORTED_API_VERSION) {
|
||||
"unsupported apiVersion=${instance.apiVersion}"
|
||||
}
|
||||
require(
|
||||
instance.verifyInstallation(
|
||||
dexPath = dex.absolutePath,
|
||||
devicePubkeyHex = installMeta.devicePubkeyHex,
|
||||
sigBytes = installMeta.deviceSigBytes,
|
||||
localDebug = installMeta.localDebug,
|
||||
),
|
||||
) {
|
||||
"plugin installation verification failed"
|
||||
}
|
||||
pendingPlugin = instance
|
||||
XposedBridge.log("EdgeX: premium plugin verified and loaded, pending device binding")
|
||||
}.onFailure {
|
||||
pendingPlugin = null
|
||||
storedPubKeyBytes = null
|
||||
disabledForProcess = true
|
||||
markBad(dex, meta)
|
||||
XposedBridge.log("EdgeX: premium plugin load failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2: schedule the Keystore challenge after the DEX has already passed
|
||||
* host-side server signature verification.
|
||||
* Called once a Context is available (InputManagerService.start()).
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun verifyDeviceBinding(context: Context) {
|
||||
val pending = pendingPlugin ?: return
|
||||
|
||||
val dex = File(PremiumInstall.DEX_PATH)
|
||||
val meta = File(PremiumInstall.META_PATH)
|
||||
|
||||
runCatching {
|
||||
val metaHash = meta.readLines()
|
||||
.firstOrNull { it.startsWith("sha256=") }
|
||||
?.substringAfter("=")?.trim()?.take(8) ?: "?"
|
||||
val cl = pending.javaClass.classLoader
|
||||
XposedBridge.log("EdgeX: verifyDeviceBinding dex=$metaHash cl=${cl?.javaClass?.simpleName}")
|
||||
val intrinsicsOk = runCatching {
|
||||
cl?.loadClass("kotlin.jvm.internal.Intrinsics"); true
|
||||
}.getOrDefault(false)
|
||||
XposedBridge.log("EdgeX: Intrinsics via plugin CL: $intrinsicsOk")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
require(storedPubKeyBytes != null) {
|
||||
"missing verified device pubkey"
|
||||
}
|
||||
XposedBridge.log("EdgeX: host binding verified, scheduling keystore challenge")
|
||||
scheduleChallenge(context)
|
||||
}.onFailure {
|
||||
pendingPlugin = null
|
||||
storedPubKeyBytes = null
|
||||
disabledForProcess = true
|
||||
markBad(dex, meta)
|
||||
XposedBridge.log("EdgeX: premium device binding failed (${it.javaClass.simpleName}): ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun disableForCurrentProcess(cause: Throwable) {
|
||||
plugin = null
|
||||
pendingPlugin = null
|
||||
disabledForProcess = true
|
||||
XposedBridge.log("EdgeX: premium plugin disabled for current process: ${cause.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 3: ECDSA challenge-response via AIDL to prove the device holds the Keystore
|
||||
* private key corresponding to device_pubkey in the META file.
|
||||
* Scheduled after static binding succeeds; retried up to MAX_ATTEMPTS times.
|
||||
*/
|
||||
private fun scheduleChallenge(context: Context) {
|
||||
challengeAttempt = 0
|
||||
handler.postDelayed({ attemptChallenge(context) }, INITIAL_DELAY_MS)
|
||||
}
|
||||
|
||||
fun retryChallengeIfNeeded(context: Context) {
|
||||
if (plugin != null || pendingPlugin == null || disabledForProcess) return
|
||||
XposedBridge.log("EdgeX: retrying keystore challenge from broadcast trigger")
|
||||
attemptChallenge(context)
|
||||
}
|
||||
|
||||
private fun attemptChallenge(context: Context) {
|
||||
if (pendingPlugin == null || disabledForProcess) return
|
||||
|
||||
// Safely unbind any existing connection to avoid leaks or duplicate binders
|
||||
activeConnection?.let {
|
||||
runCatching { context.unbindService(it) }
|
||||
activeConnection = null
|
||||
}
|
||||
|
||||
val intent = Intent().apply {
|
||||
component = ComponentName(VERIFIER_PKG, VERIFIER_SVC)
|
||||
addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
|
||||
}
|
||||
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
if (activeConnection != this) return
|
||||
activeConnection = null
|
||||
|
||||
val pubKeyBytes = storedPubKeyBytes
|
||||
val success = if (pubKeyBytes == null) {
|
||||
XposedBridge.log("EdgeX: challenge aborted — no stored pubkey")
|
||||
false
|
||||
} else {
|
||||
runCatching {
|
||||
val verifier = IKeystoreVerifier.Stub.asInterface(binder)
|
||||
val challenge = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val sig = verifier.sign(challenge) ?: error("null response from verifier")
|
||||
verifyEcSig(challenge, sig, pubKeyBytes)
|
||||
}.onFailure {
|
||||
XposedBridge.log("EdgeX: keystore challenge error: ${it.message}")
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
runCatching { context.unbindService(this) }
|
||||
|
||||
if (success) {
|
||||
plugin = pendingPlugin
|
||||
pendingPlugin = null
|
||||
XposedBridge.log("EdgeX: keystore challenge passed — premium active")
|
||||
} else {
|
||||
pendingPlugin = null
|
||||
disabledForProcess = true
|
||||
markBad(File(PremiumInstall.DEX_PATH), File(PremiumInstall.META_PATH))
|
||||
XposedBridge.log("EdgeX: keystore challenge failed — premium disabled")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
if (activeConnection == this) {
|
||||
activeConnection = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeConnection = connection
|
||||
|
||||
val bound = runCatching {
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (!bound) {
|
||||
activeConnection = null
|
||||
challengeAttempt++
|
||||
if (challengeAttempt < MAX_ATTEMPTS) {
|
||||
XposedBridge.log("EdgeX: KeystoreVerifierService bind failed (attempt $challengeAttempt/$MAX_ATTEMPTS), retrying")
|
||||
handler.postDelayed({ attemptChallenge(context) }, RETRY_DELAY_MS)
|
||||
} else {
|
||||
XposedBridge.log("EdgeX: keystore challenge exhausted retries — premium remains inactive")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyEcSig(challenge: ByteArray, sig: ByteArray, pubKeyBytes: ByteArray): Boolean =
|
||||
runCatching {
|
||||
val pubKey = KeyFactory.getInstance("EC")
|
||||
.generatePublic(X509EncodedKeySpec(pubKeyBytes))
|
||||
Signature.getInstance("SHA256withECDSA").run {
|
||||
initVerify(pubKey)
|
||||
update(challenge)
|
||||
verify(sig)
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun markBad(dex: File, meta: File) {
|
||||
val suffix = ".bad.${System.currentTimeMillis()}"
|
||||
runCatching {
|
||||
if (dex.exists()) dex.renameTo(File(dex.parentFile, dex.name + suffix))
|
||||
if (meta.exists()) meta.renameTo(File(meta.parentFile, meta.name + suffix))
|
||||
}.onFailure {
|
||||
XposedBridge.log("EdgeX: failed to mark premium dex bad: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
43
app/src/main/java/com/fan/edgex/hook/PremiumRuntime.kt
Normal file
43
app/src/main/java/com/fan/edgex/hook/PremiumRuntime.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object PremiumRuntime {
|
||||
fun isActive(): Boolean =
|
||||
PremiumPluginLoader.plugin != null
|
||||
|
||||
fun showEdgeLighting(
|
||||
context: Context,
|
||||
effect: String,
|
||||
color: Int,
|
||||
durationMs: Int,
|
||||
widthDp: Int,
|
||||
alpha: Float,
|
||||
): Boolean {
|
||||
val plugin = PremiumPluginLoader.plugin ?: return false
|
||||
return runCatching {
|
||||
plugin.onEdgeLightingShow(context, effect, color, durationMs, widthDp, alpha)
|
||||
}.getOrElse {
|
||||
PremiumPluginLoader.disableForCurrentProcess(it)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun onScreenOff() {
|
||||
val plugin = PremiumPluginLoader.plugin ?: return
|
||||
runCatching {
|
||||
plugin.onScreenOff()
|
||||
}.onFailure {
|
||||
PremiumPluginLoader.disableForCurrentProcess(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissEdgeLighting() {
|
||||
val plugin = PremiumPluginLoader.plugin ?: return
|
||||
runCatching {
|
||||
plugin.onEdgeLightingDismiss()
|
||||
}.onFailure {
|
||||
PremiumPluginLoader.disableForCurrentProcess(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.KeyFactory
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
||||
object PremiumSignatureVerifier {
|
||||
private const val RSA_PUBLIC_KEY_DER_HEX =
|
||||
"30820122300d06092a864886f70d01010105000382010f003082010a0282010100" +
|
||||
"ce29f8eed32e307c8d8f1012925c7a5ca1a9046dbdbd45f95e20e6c019c8fb" +
|
||||
"8f774be35a42470bea7b45c1fa2e6c484984f7338d92ee0dcd3676d65c9a212" +
|
||||
"9f1c32b1aabd3c4f99828752bba2c66c62d2b3d05984f73f13bf4ed24e184bc" +
|
||||
"89c4cc1710dad90cfebd72775821cc38d732c68b17a023909b87c11df0de6ae" +
|
||||
"80e617c63268a7c768194dea6447afb095d3356bd8cf2978878f672576000daf" +
|
||||
"e64cb684e361cd6019fbb6d33521cd9d2b2e56940ac4edb97fc2730485e659f" +
|
||||
"098f8c551065ef0675c904d3c8b1a3ca7f74d787c7f381f3f9fe367c5c302ae" +
|
||||
"b3cdcc537f2be506e9a50b9ab1048915d719968c799874b0f6c946006ede4c" +
|
||||
"0f21b761b0203010001"
|
||||
|
||||
fun verifyInstallationSignature(
|
||||
dex: File,
|
||||
expectedDexHash: String,
|
||||
devicePubkeyHex: String,
|
||||
sigBytes: ByteArray,
|
||||
): Boolean = verifyInstallationSignature(
|
||||
dex = dex,
|
||||
expectedDexHash = expectedDexHash,
|
||||
devicePubkeyHex = devicePubkeyHex,
|
||||
sigBytes = sigBytes,
|
||||
publicKeyDerHex = RSA_PUBLIC_KEY_DER_HEX,
|
||||
)
|
||||
|
||||
internal fun verifyInstallationSignature(
|
||||
dex: File,
|
||||
expectedDexHash: String,
|
||||
devicePubkeyHex: String,
|
||||
sigBytes: ByteArray,
|
||||
publicKeyDerHex: String,
|
||||
): Boolean = runCatching {
|
||||
val actualHash = sha256Hex(dex)
|
||||
require(actualHash.equals(expectedDexHash, ignoreCase = true)) {
|
||||
"sha256 mismatch"
|
||||
}
|
||||
|
||||
val message = "${actualHash.lowercase()}|$devicePubkeyHex"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
Signature.getInstance("SHA256withRSA").run {
|
||||
initVerify(rsaPublicKey(publicKeyDerHex))
|
||||
update(message)
|
||||
verify(sigBytes)
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun sha256Hex(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
FileInputStream(file).use { input ->
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun rsaPublicKey(publicKeyDerHex: String) = KeyFactory.getInstance("RSA").generatePublic(
|
||||
X509EncodedKeySpec(publicKeyDerHex.hexToByteArray()),
|
||||
)
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
110
app/src/main/java/com/fan/edgex/hook/ScrollHook.kt
Normal file
110
app/src/main/java/com/fan/edgex/hook/ScrollHook.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.view.MotionEvent
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
|
||||
object ScrollHook {
|
||||
private const val TAG = "EdgeX.ScrollHook"
|
||||
|
||||
fun install() {
|
||||
try {
|
||||
// Hook ScrollView.onGenericMotionEvent
|
||||
val scrollViewClass = XposedHelpers.findClass("android.widget.ScrollView", null)
|
||||
XposedHelpers.findAndHookMethod(
|
||||
scrollViewClass,
|
||||
"onGenericMotionEvent",
|
||||
MotionEvent::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val event = param.args[0] as MotionEvent
|
||||
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (axisValue == 100000.0f || axisValue == -100000.0f) {
|
||||
val scrollView = param.thisObject
|
||||
if (axisValue == 100000.0f) {
|
||||
XposedHelpers.callMethod(scrollView, "smoothScrollTo", 0, 0)
|
||||
} else {
|
||||
val range = XposedHelpers.callMethod(scrollView, "computeVerticalScrollRange") as Int
|
||||
XposedHelpers.callMethod(scrollView, "smoothScrollTo", 0, range)
|
||||
}
|
||||
param.result = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: ScrollView hook failed: ${t.message}")
|
||||
}
|
||||
|
||||
try {
|
||||
// Hook AbsListView.onGenericMotionEvent
|
||||
val absListViewClass = XposedHelpers.findClass("android.widget.AbsListView", null)
|
||||
val gridViewClass = XposedHelpers.findClass("android.widget.GridView", null)
|
||||
XposedHelpers.findAndHookMethod(
|
||||
absListViewClass,
|
||||
"onGenericMotionEvent",
|
||||
MotionEvent::class.java,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val event = param.args[0] as MotionEvent
|
||||
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (axisValue == 100000.0f || axisValue == -100000.0f) {
|
||||
val absListView = param.thisObject
|
||||
if (axisValue != 100000.0f) {
|
||||
val count = XposedHelpers.callMethod(absListView, "getCount") as Int
|
||||
XposedHelpers.callMethod(absListView, "smoothScrollToPosition", count - 1)
|
||||
} else if (gridViewClass.isInstance(absListView)) {
|
||||
XposedHelpers.callMethod(absListView, "smoothScrollToPositionFromTop", 0, 0)
|
||||
} else {
|
||||
XposedHelpers.callMethod(absListView, "smoothScrollToPosition", 0)
|
||||
}
|
||||
param.result = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: AbsListView hook failed: ${t.message}")
|
||||
}
|
||||
|
||||
try {
|
||||
// Hook WebView.onGenericMotionEvent
|
||||
val webViewClass = XposedHelpers.findClass("android.webkit.WebView", null)
|
||||
XposedHelpers.findAndHookMethod(
|
||||
webViewClass,
|
||||
"onGenericMotionEvent",
|
||||
MotionEvent::class.java,
|
||||
object : XC_MethodHook() {
|
||||
private fun computeWebViewScroll(delta: Int): Int {
|
||||
if (delta != 0) {
|
||||
return (delta * 200) / Math.sqrt(delta.toDouble()).toInt()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val event = param.args[0] as MotionEvent
|
||||
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (axisValue == 100000.0f || axisValue == -100000.0f) {
|
||||
val webView = param.thisObject
|
||||
val offset = XposedHelpers.callMethod(webView, "computeVerticalScrollOffset") as Int
|
||||
val range = if (axisValue == 100000.0f) {
|
||||
-computeWebViewScroll(offset)
|
||||
} else {
|
||||
val totalRange = XposedHelpers.callMethod(webView, "computeVerticalScrollRange") as Int
|
||||
computeWebViewScroll(totalRange - offset)
|
||||
}
|
||||
if (range != 0) {
|
||||
XposedHelpers.callMethod(webView, "flingScroll", 0, range)
|
||||
}
|
||||
param.result = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: WebView hook failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
414
app/src/main/java/com/fan/edgex/hook/UniversalCopyManager.kt
Normal file
414
app/src/main/java/com/fan/edgex/hook/UniversalCopyManager.kt
Normal file
@@ -0,0 +1,414 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
|
||||
object UniversalCopyManager {
|
||||
private const val TAG = "EdgeX"
|
||||
private const val STATE_ENABLED = 1
|
||||
private const val RETRY_COUNT = 3
|
||||
private const val RETRY_DELAY_MS = 150L
|
||||
|
||||
enum class CollectStatus {
|
||||
FOUND,
|
||||
NO_TEXT,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
data class TextBlock(
|
||||
val text: String,
|
||||
val bounds: Rect
|
||||
)
|
||||
|
||||
data class CollectResult(
|
||||
val status: CollectStatus,
|
||||
val blocks: List<TextBlock> = emptyList()
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var hooksInstalled = false
|
||||
|
||||
@Volatile
|
||||
private var fakeAccessibilityEnabled = false
|
||||
|
||||
@Volatile
|
||||
private var service: BridgeAccessibilityService? = null
|
||||
|
||||
fun installHooks(classLoader: ClassLoader) {
|
||||
if (hooksInstalled) return
|
||||
synchronized(this) {
|
||||
if (hooksInstalled) return
|
||||
hookClientState(classLoader, "com.android.server.accessibility.AccessibilityUserState", "getClientStateLocked")
|
||||
hookClientState(classLoader, "com.android.server.accessibility.AccessibilityManagerService\$UserState", "getClientState")
|
||||
hooksInstalled = true
|
||||
}
|
||||
}
|
||||
|
||||
fun collectAllTexts(context: Context, onResult: (CollectResult) -> Unit) {
|
||||
val bridge = getOrCreateService(context)
|
||||
if (bridge == null) {
|
||||
onResult(CollectResult(CollectStatus.UNAVAILABLE))
|
||||
return
|
||||
}
|
||||
bridge.collectAll(onResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert [text] at the cursor of the currently focused input field via
|
||||
* AccessibilityNodeInfo ACTION_SET_TEXT. Used for Unicode (e.g. Chinese)
|
||||
* where KeyCharacterMap-based key-event injection is not viable.
|
||||
*/
|
||||
fun injectIntoFocusedField(context: Context, text: String, onComplete: (Boolean) -> Unit) {
|
||||
val bridge = getOrCreateService(context)
|
||||
if (bridge == null) {
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
bridge.injectIntoFocused(text, onComplete)
|
||||
}
|
||||
|
||||
private fun getOrCreateService(context: Context): BridgeAccessibilityService? {
|
||||
service?.let { return it }
|
||||
return synchronized(this) {
|
||||
service?.let { return@synchronized it }
|
||||
try {
|
||||
BridgeAccessibilityService(context.applicationContext).also {
|
||||
service = it
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Universal copy init failed: ${t.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookClientState(classLoader: ClassLoader, className: String, methodName: String) {
|
||||
try {
|
||||
val targetClass = XposedHelpers.findClass(className, classLoader)
|
||||
val hookedAny = targetClass.declaredMethods
|
||||
.filter { it.name == methodName }
|
||||
.map { method ->
|
||||
runCatching {
|
||||
XposedBridge.hookMethod(method, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
if (!fakeAccessibilityEnabled) return
|
||||
for (index in param.args.indices) {
|
||||
if (param.args[index] is Boolean) {
|
||||
param.args[index] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
if (!fakeAccessibilityEnabled) return
|
||||
val result = param.result as? Int ?: return
|
||||
if ((result and STATE_ENABLED) == 0) {
|
||||
param.result = result or STATE_ENABLED
|
||||
}
|
||||
}
|
||||
})
|
||||
}.isSuccess
|
||||
}
|
||||
.any { it }
|
||||
|
||||
if (hookedAny) {
|
||||
XposedBridge.log("$TAG: Universal copy hooked $className#$methodName")
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private class BridgeAccessibilityService(context: Context) : AccessibilityService() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val accessibilityManager =
|
||||
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
private val managerService = resolveManagerService(accessibilityManager)
|
||||
|
||||
init {
|
||||
val connectionId = resolveConnectionId(managerService)
|
||||
XposedHelpers.setIntField(this, "mConnectionId", connectionId)
|
||||
attachBaseContext(context)
|
||||
}
|
||||
|
||||
fun collectAll(onResult: (CollectResult) -> Unit) {
|
||||
try {
|
||||
if (!accessibilityManager.isEnabled) {
|
||||
setAccessibilityEnabled(true)
|
||||
retryCollect(RETRY_COUNT, true, onResult)
|
||||
return
|
||||
}
|
||||
finishCollect(queryAllTexts(), false, onResult)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Universal copy failed: ${t.message}")
|
||||
onResult(CollectResult(CollectStatus.UNAVAILABLE))
|
||||
}
|
||||
}
|
||||
|
||||
fun injectIntoFocused(text: String, onComplete: (Boolean) -> Unit) {
|
||||
try {
|
||||
if (!accessibilityManager.isEnabled) {
|
||||
setAccessibilityEnabled(true)
|
||||
handler.postDelayed({
|
||||
runInjection(text, disableAfter = true, onComplete)
|
||||
}, RETRY_DELAY_MS)
|
||||
return
|
||||
}
|
||||
runInjection(text, disableAfter = false, onComplete)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: text injection failed: ${t.message}")
|
||||
onComplete(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runInjection(text: String, disableAfter: Boolean, onComplete: (Boolean) -> Unit) {
|
||||
var success = false
|
||||
try {
|
||||
val root = try { getRootInActiveWindow() } catch (_: Throwable) { null }
|
||||
val focused = try {
|
||||
root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||
} catch (_: Throwable) { null }
|
||||
if (focused != null) {
|
||||
success = insertAtSelection(focused, text)
|
||||
}
|
||||
} finally {
|
||||
if (disableAfter) setAccessibilityEnabled(false)
|
||||
onComplete(success)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAtSelection(node: AccessibilityNodeInfo, text: String): Boolean {
|
||||
return try {
|
||||
val nodeText = node.text?.toString().orEmpty()
|
||||
val hintText = node.hintText?.toString().orEmpty()
|
||||
val current = if (node.isShowingHintText || (hintText.isNotEmpty() && nodeText == hintText)) {
|
||||
""
|
||||
} else {
|
||||
nodeText
|
||||
}
|
||||
var selStart = node.textSelectionStart
|
||||
var selEnd = node.textSelectionEnd
|
||||
if (selStart < 0) selStart = current.length
|
||||
if (selEnd < selStart) selEnd = selStart
|
||||
selStart = selStart.coerceAtMost(current.length)
|
||||
selEnd = selEnd.coerceAtMost(current.length)
|
||||
|
||||
val newText = current.substring(0, selStart) + text + current.substring(selEnd)
|
||||
val args = android.os.Bundle().apply {
|
||||
putCharSequence(
|
||||
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText
|
||||
)
|
||||
}
|
||||
if (!node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)) return false
|
||||
|
||||
// Move cursor to end of inserted text
|
||||
val newCursor = selStart + text.length
|
||||
val selArgs = android.os.Bundle().apply {
|
||||
putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursor)
|
||||
putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursor)
|
||||
}
|
||||
node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selArgs)
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: insertAtSelection failed: ${t.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun retryCollect(
|
||||
attemptsLeft: Int,
|
||||
disableAfter: Boolean,
|
||||
onResult: (CollectResult) -> Unit
|
||||
) {
|
||||
val result = queryAllTexts()
|
||||
if (result.rootAvailable || attemptsLeft <= 1) {
|
||||
finishCollect(result, disableAfter, onResult)
|
||||
return
|
||||
}
|
||||
handler.postDelayed(
|
||||
{ retryCollect(attemptsLeft - 1, disableAfter, onResult) },
|
||||
RETRY_DELAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
private fun finishCollect(
|
||||
result: QueryResult,
|
||||
disableAfter: Boolean,
|
||||
onResult: (CollectResult) -> Unit
|
||||
) {
|
||||
try {
|
||||
val callbackResult = when {
|
||||
!result.rootAvailable -> CollectResult(CollectStatus.UNAVAILABLE)
|
||||
result.items.isEmpty() -> CollectResult(CollectStatus.NO_TEXT)
|
||||
else -> CollectResult(CollectStatus.FOUND, result.items)
|
||||
}
|
||||
XposedBridge.log("$TAG: Universal copy collected ${result.items.size} text blocks, status=${callbackResult.status}")
|
||||
onResult(callbackResult)
|
||||
} finally {
|
||||
if (disableAfter) {
|
||||
setAccessibilityEnabled(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryAllTexts(): QueryResult {
|
||||
val root = try {
|
||||
getRootInActiveWindow()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return QueryResult(rootAvailable = false, items = emptyList())
|
||||
|
||||
return QueryResult(
|
||||
rootAvailable = true,
|
||||
items = PageTextCollector.collectAll(root)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setAccessibilityEnabled(enabled: Boolean) {
|
||||
fakeAccessibilityEnabled = enabled
|
||||
updateUiAutomationFlags(enabled)
|
||||
try {
|
||||
val currentUserState = XposedHelpers.callMethod(managerService, "getCurrentUserState")
|
||||
XposedHelpers.callMethod(managerService, "scheduleUpdateClientsIfNeeded", currentUserState)
|
||||
} catch (_: Throwable) {
|
||||
try {
|
||||
val lock = XposedHelpers.getObjectField(managerService, "mLock")
|
||||
synchronized(lock) {
|
||||
val currentUserState =
|
||||
XposedHelpers.callMethod(managerService, "getCurrentUserStateLocked")
|
||||
XposedHelpers.callMethod(
|
||||
managerService,
|
||||
"scheduleUpdateClientsIfNeededLocked",
|
||||
currentUserState
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("$TAG: Universal copy state update failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUiAutomationFlags(enabled: Boolean) {
|
||||
try {
|
||||
val uiAutomationManager = XposedHelpers.getObjectField(managerService, "mUiAutomationManager")
|
||||
val flagsField = XposedHelpers.findField(uiAutomationManager.javaClass, "mUiAutomationFlags")
|
||||
val currentFlags = flagsField.getInt(uiAutomationManager)
|
||||
val updatedFlags = if (enabled) {
|
||||
currentFlags or 0x2
|
||||
} else {
|
||||
currentFlags and 0x2.inv()
|
||||
}
|
||||
flagsField.setInt(uiAutomationManager, updatedFlags)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit
|
||||
|
||||
override fun onInterrupt() = Unit
|
||||
|
||||
companion object {
|
||||
private fun resolveManagerService(accessibilityManager: AccessibilityManager): Any {
|
||||
return try {
|
||||
XposedHelpers.getObjectField(accessibilityManager, "mService")
|
||||
} catch (_: Throwable) {
|
||||
val lock = XposedHelpers.getObjectField(accessibilityManager, "mLock")
|
||||
synchronized(lock) {
|
||||
XposedHelpers.callMethod(accessibilityManager, "getServiceLocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveConnectionId(managerService: Any): Int {
|
||||
val bridge = try {
|
||||
XposedHelpers.callMethod(managerService, "getInteractionBridge")
|
||||
} catch (_: Throwable) {
|
||||
val lock = XposedHelpers.getObjectField(managerService, "mLock")
|
||||
synchronized(lock) {
|
||||
XposedHelpers.callMethod(managerService, "getInteractionBridgeLocked")
|
||||
}
|
||||
}
|
||||
return XposedHelpers.getIntField(bridge, "mConnectionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class QueryResult(
|
||||
val rootAvailable: Boolean,
|
||||
val items: List<TextBlock>
|
||||
)
|
||||
|
||||
/**
|
||||
* Traverses the entire accessibility tree and collects all visible text,
|
||||
* ordered top-to-bottom by screen position.
|
||||
*/
|
||||
private object PageTextCollector {
|
||||
private val tempRect = Rect()
|
||||
|
||||
fun collectAll(root: AccessibilityNodeInfo): List<TextBlock> {
|
||||
val items = mutableListOf<TextItem>()
|
||||
traverse(root, items)
|
||||
items.sortWith(compareBy({ it.bounds.top }, { it.bounds.left }))
|
||||
return deduplicate(items).map { TextBlock(it.text, it.bounds) }
|
||||
}
|
||||
|
||||
private fun traverse(node: AccessibilityNodeInfo, items: MutableList<TextItem>) {
|
||||
if (!node.isVisibleToUser) return
|
||||
|
||||
val className = node.className?.toString().orEmpty()
|
||||
if (className.contains("Image", ignoreCase = true)) return
|
||||
|
||||
// Try children first — prefer leaf-level text
|
||||
var childrenHadText = false
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
val sizeBefore = items.size
|
||||
traverse(child, items)
|
||||
if (items.size > sizeBefore) childrenHadText = true
|
||||
}
|
||||
|
||||
// Only add this node's text if no children contributed text
|
||||
if (childrenHadText) return
|
||||
|
||||
val text = normalizeText(node.text) ?: normalizeText(node.contentDescription) ?: return
|
||||
node.getBoundsInScreen(tempRect)
|
||||
items += TextItem(text, Rect(tempRect))
|
||||
}
|
||||
|
||||
private fun deduplicate(items: List<TextItem>): List<TextItem> {
|
||||
if (items.size <= 1) return items
|
||||
val seen = LinkedHashSet<String>()
|
||||
val result = mutableListOf<TextItem>()
|
||||
for (item in items) {
|
||||
if (seen.add(item.text)) {
|
||||
result += item
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeText(text: CharSequence?): String? {
|
||||
if (text.isNullOrEmpty()) return null
|
||||
val str = if (text.length > 65536) text.subSequence(0, 65536).toString() else text.toString()
|
||||
val trimmed = str.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
if (trimmed.length == 1) {
|
||||
val c = trimmed[0]
|
||||
if (!((c > ' ' && c <= '~') || c.code > 160)) return null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private data class TextItem(val text: String, val bounds: Rect)
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/fan/edgex/hook/XposedInit.kt
Normal file
13
app/src/main/java/com/fan/edgex/hook/XposedInit.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.fan.edgex.hook
|
||||
|
||||
/**
|
||||
* Legacy entry point — delegates to MainHook.
|
||||
* Kept for backward compatibility with any references.
|
||||
*/
|
||||
class XposedInit : de.robv.android.xposed.IXposedHookLoadPackage {
|
||||
private val delegate = MainHook()
|
||||
|
||||
override fun handleLoadPackage(lpparam: de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam) {
|
||||
delegate.handleLoadPackage(lpparam)
|
||||
}
|
||||
}
|
||||
64
app/src/main/java/com/fan/edgex/license/DeviceKeystore.kt
Normal file
64
app/src/main/java/com/fan/edgex/license/DeviceKeystore.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
package com.fan.edgex.license
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
object DeviceKeystore {
|
||||
private const val TAG = "EdgeX.DeviceKeystore"
|
||||
private const val KEY_ALIAS = "edgex_premium_key"
|
||||
private const val PROVIDER = "AndroidKeyStore"
|
||||
|
||||
fun getOrCreatePublicKeyBytes(): ByteArray {
|
||||
val ks = KeyStore.getInstance(PROVIDER).also { it.load(null) }
|
||||
if (!ks.containsAlias(KEY_ALIAS)) generate(ks)
|
||||
val certificate = ks.getCertificate(KEY_ALIAS)
|
||||
if (certificate == null) {
|
||||
ks.deleteEntry(KEY_ALIAS)
|
||||
generate(ks)
|
||||
}
|
||||
return checkNotNull(ks.getCertificate(KEY_ALIAS)) {
|
||||
"Android Keystore did not return the generated certificate"
|
||||
}.publicKey.encoded
|
||||
}
|
||||
|
||||
fun sign(challenge: ByteArray): ByteArray {
|
||||
val ks = KeyStore.getInstance(PROVIDER).also { it.load(null) }
|
||||
val privateKey = ks.getKey(KEY_ALIAS, null) as? PrivateKey
|
||||
?: error("Keystore key not found — re-activate premium")
|
||||
return Signature.getInstance("SHA256withECDSA").run {
|
||||
initSign(privateKey)
|
||||
update(challenge)
|
||||
sign()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(ks: KeyStore) {
|
||||
// Prefer StrongBox (physically isolated secure element); fall back to TEE.
|
||||
try {
|
||||
generateKeyPair(strongBoxBacked = true)
|
||||
} catch (strongBoxError: Exception) {
|
||||
Log.w(TAG, "StrongBox key generation failed; falling back to TEE", strongBoxError)
|
||||
runCatching { ks.deleteEntry(KEY_ALIAS) }
|
||||
.onFailure { Log.w(TAG, "Failed to remove partial StrongBox key", it) }
|
||||
generateKeyPair(strongBoxBacked = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateKeyPair(strongBoxBacked: Boolean) {
|
||||
val spec = KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_SIGN)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setIsStrongBoxBacked(strongBoxBacked)
|
||||
.build()
|
||||
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, PROVIDER).run {
|
||||
initialize(spec)
|
||||
generateKeyPair()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fan.edgex.license
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import com.fan.edgex.IKeystoreVerifier
|
||||
|
||||
class KeystoreVerifierService : Service() {
|
||||
|
||||
private val stub = object : IKeystoreVerifier.Stub() {
|
||||
override fun sign(challenge: ByteArray?): ByteArray? {
|
||||
if (Binder.getCallingUid() != Process.SYSTEM_UID) return null
|
||||
if (challenge == null || challenge.size < 16) return null
|
||||
return runCatching { DeviceKeystore.sign(challenge) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = stub
|
||||
}
|
||||
366
app/src/main/java/com/fan/edgex/license/PremiumActivator.kt
Normal file
366
app/src/main/java/com/fan/edgex/license/PremiumActivator.kt
Normal file
@@ -0,0 +1,366 @@
|
||||
package com.fan.edgex.license
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import com.fan.edgex.BuildConfig
|
||||
import com.fan.edgex.premium.PremiumInstall
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
|
||||
object PremiumActivator {
|
||||
private const val PREFS_NAME = "premium_activation"
|
||||
private const val KEY_ACTIVATION_CODE = "activation_code"
|
||||
private const val KEY_INSTALLED = "installed"
|
||||
private const val KEY_INSTALLED_AT_MS = "installed_at_ms"
|
||||
private const val KEY_INSTALL_BOOT_COUNT = "install_boot_count"
|
||||
private const val KEY_INSTALLED_DEX_HASH = "installed_dex_hash"
|
||||
private const val KEY_INSTALLED_DEX_VERSION = "installed_dex_version"
|
||||
private const val KEY_DEACTIVATED = "deactivated"
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 30_000
|
||||
|
||||
fun activate(context: Context, code: String): Result<Unit> = runCatching {
|
||||
val normalizedCode = normalizeCode(code)
|
||||
require(normalizedCode.isNotEmpty()) { "Activation code is empty" }
|
||||
|
||||
val activation = requestActivation(normalizedCode)
|
||||
downloadAndInstall(context, dexBytes = downloadDex(activation), activation)
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_ACTIVATION_CODE, normalizedCode)
|
||||
.putBoolean(KEY_INSTALLED, true)
|
||||
.putLong(KEY_INSTALLED_AT_MS, System.currentTimeMillis())
|
||||
.putInt(KEY_INSTALL_BOOT_COUNT, bootCount(context))
|
||||
.putString(KEY_INSTALLED_DEX_HASH, activation.dexHash)
|
||||
.putInt(KEY_INSTALLED_DEX_VERSION, activation.dexVersion)
|
||||
.putBoolean(KEY_DEACTIVATED, false)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun deactivate(context: Context): Result<Unit> = runCatching {
|
||||
val code = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_ACTIVATION_CODE, null)
|
||||
|
||||
if (apiBaseUrls().isNotEmpty() && !code.isNullOrEmpty()) {
|
||||
val body = JSONObject()
|
||||
.put("code", code)
|
||||
.put("device_pubkey", devicePubkeyHex())
|
||||
.toString()
|
||||
withApiFallback { baseUrl ->
|
||||
postOk("$baseUrl/api/unbind", body)
|
||||
}
|
||||
}
|
||||
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_ACTIVATION_CODE)
|
||||
.putBoolean(KEY_INSTALLED, false)
|
||||
.remove(KEY_INSTALLED_AT_MS)
|
||||
.putBoolean(KEY_DEACTIVATED, true)
|
||||
.apply()
|
||||
|
||||
Shell.cmd(
|
||||
"rm -f ${PremiumInstall.DEX_PATH} ${PremiumInstall.META_PATH} ${PremiumInstall.LEGACY_DEVICE_ID_PATH}"
|
||||
).exec()
|
||||
}
|
||||
|
||||
fun getActivationCode(context: Context): String? =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_ACTIVATION_CODE, null)
|
||||
|
||||
data class DexInfo(val apiVersion: Int, val hashPrefix: String, val installedAtMs: Long)
|
||||
|
||||
fun getDexInfo(context: Context): DexInfo? {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val hash = prefs.getString(KEY_INSTALLED_DEX_HASH, null)?.take(8) ?: return null
|
||||
val apiVersion = prefs.getInt(KEY_INSTALLED_DEX_VERSION, PremiumInstall.SUPPORTED_API_VERSION)
|
||||
val ts = prefs.getLong(KEY_INSTALLED_AT_MS, 0L).takeIf { it > 0 } ?: return null
|
||||
return DexInfo(apiVersion, hash, ts)
|
||||
}
|
||||
|
||||
data class DexUpdateInfo(val apiVersion: Int, val hashPrefix: String)
|
||||
|
||||
sealed class DexUpdateStatus {
|
||||
data object NotInstalled : DexUpdateStatus()
|
||||
data object MissingActivationCode : DexUpdateStatus()
|
||||
data object UpToDate : DexUpdateStatus()
|
||||
data class Available(val info: DexUpdateInfo) : DexUpdateStatus()
|
||||
}
|
||||
|
||||
fun checkInstalledDexUpdate(context: Context): Result<DexUpdateStatus> = runCatching {
|
||||
if (!isInstalled(context)) return@runCatching DexUpdateStatus.NotInstalled
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val code = prefs.getString(KEY_ACTIVATION_CODE, null)?.let(::normalizeCode).orEmpty()
|
||||
if (code.isEmpty()) return@runCatching DexUpdateStatus.MissingActivationCode
|
||||
|
||||
val activation = requestActivation(code)
|
||||
val installedHash = prefs.getString(KEY_INSTALLED_DEX_HASH, null)
|
||||
if (installedHash.equals(activation.dexHash, ignoreCase = true)) {
|
||||
DexUpdateStatus.UpToDate
|
||||
} else {
|
||||
DexUpdateStatus.Available(
|
||||
DexUpdateInfo(
|
||||
apiVersion = activation.dexVersion,
|
||||
hashPrefix = activation.dexHash.take(8),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateInstalledDexIfNeeded(context: Context): Result<UpdateResult> = runCatching {
|
||||
if (!isInstalled(context)) return@runCatching UpdateResult.NotInstalled
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val code = prefs.getString(KEY_ACTIVATION_CODE, null)?.let(::normalizeCode).orEmpty()
|
||||
if (code.isEmpty()) return@runCatching UpdateResult.SkippedMissingActivationCode
|
||||
|
||||
val activation = requestActivation(code)
|
||||
val installedHash = prefs.getString(KEY_INSTALLED_DEX_HASH, null)
|
||||
if (installedHash.equals(activation.dexHash, ignoreCase = true)) {
|
||||
return@runCatching UpdateResult.UpToDate
|
||||
}
|
||||
|
||||
downloadAndInstall(context, downloadDex(activation), activation)
|
||||
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_INSTALLED, true)
|
||||
.putLong(KEY_INSTALLED_AT_MS, System.currentTimeMillis())
|
||||
.putInt(KEY_INSTALL_BOOT_COUNT, bootCount(context))
|
||||
.putString(KEY_INSTALLED_DEX_HASH, activation.dexHash)
|
||||
.putInt(KEY_INSTALLED_DEX_VERSION, activation.dexVersion)
|
||||
.apply()
|
||||
UpdateResult.Updated
|
||||
}
|
||||
|
||||
fun isInstalled(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_DEACTIVATED, false)) return false
|
||||
return prefs.getBoolean(KEY_INSTALLED, false) || File(PremiumInstall.META_PATH).isFile
|
||||
}
|
||||
|
||||
fun status(context: Context): Status {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_DEACTIVATED, false)) return Status.NotActivated
|
||||
val installed = prefs.getBoolean(KEY_INSTALLED, false) || File(PremiumInstall.META_PATH).isFile
|
||||
if (!installed) return Status.NotActivated
|
||||
|
||||
val installedAtMs = prefs.getLong(KEY_INSTALLED_AT_MS, 0L)
|
||||
if (installedAtMs <= 0L) return Status.Installed
|
||||
|
||||
val savedBootCount = prefs.getInt(KEY_INSTALL_BOOT_COUNT, -1)
|
||||
if (savedBootCount >= 0) {
|
||||
return if (bootCount(context) > savedBootCount) Status.Installed else Status.RebootRequired
|
||||
}
|
||||
|
||||
val bootWallClockMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||
return if (installedAtMs > bootWallClockMs) Status.RebootRequired else Status.Installed
|
||||
}
|
||||
|
||||
enum class Status { NotActivated, RebootRequired, Installed }
|
||||
|
||||
enum class UpdateResult { NotInstalled, SkippedMissingActivationCode, UpToDate, Updated }
|
||||
|
||||
private data class ActivationResponse(
|
||||
val token: String,
|
||||
val dexHash: String,
|
||||
val dexVersion: Int,
|
||||
val deviceSig: String,
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
private fun requestActivation(code: String): ActivationResponse {
|
||||
require(apiBaseUrls().isNotEmpty()) { "Premium API URL is not configured" }
|
||||
|
||||
val activateBody = JSONObject()
|
||||
.put("code", code)
|
||||
.put("device_pubkey", devicePubkeyHex())
|
||||
.toString()
|
||||
|
||||
val (baseUrl, activateResponse) = withApiFallbackWithBase { baseUrl ->
|
||||
postJson("$baseUrl/api/activate", activateBody)
|
||||
}
|
||||
val token = activateResponse.getString("token")
|
||||
val expectedHash = activateResponse.getString("dex_hash").lowercase()
|
||||
val dexVersion = activateResponse.optInt("dex_version", PremiumInstall.SUPPORTED_API_VERSION)
|
||||
val deviceSig = activateResponse.getString("device_sig")
|
||||
|
||||
require(dexVersion == PremiumInstall.SUPPORTED_API_VERSION) {
|
||||
"Unsupported premium version: $dexVersion"
|
||||
}
|
||||
|
||||
return ActivationResponse(token, expectedHash, dexVersion, deviceSig, baseUrl)
|
||||
}
|
||||
|
||||
private fun downloadDex(activation: ActivationResponse): ByteArray {
|
||||
val dexBytes = withApiFallback(preferredBaseUrl = activation.baseUrl) { baseUrl ->
|
||||
getBytes("$baseUrl/api/download/dex?token=${urlEncode(activation.token)}")
|
||||
}
|
||||
val actualHash = sha256Hex(dexBytes)
|
||||
require(actualHash == activation.dexHash) { "Downloaded premium DEX hash mismatch" }
|
||||
return dexBytes
|
||||
}
|
||||
|
||||
private fun downloadAndInstall(context: Context, dexBytes: ByteArray, activation: ActivationResponse) {
|
||||
val tempDex = File(context.cacheDir, "premium.dex.tmp")
|
||||
val tempMeta = File(context.cacheDir, "premium.meta.tmp")
|
||||
val pubkeyHex = devicePubkeyHex()
|
||||
|
||||
tempDex.writeBytes(dexBytes)
|
||||
tempMeta.writeText(
|
||||
buildString {
|
||||
appendLine("version=${activation.dexVersion}")
|
||||
appendLine("sha256=${activation.dexHash}")
|
||||
appendLine("size=${dexBytes.size}")
|
||||
appendLine("installed_at=${Instant.now()}")
|
||||
appendLine("device_pubkey=$pubkeyHex")
|
||||
appendLine("device_sig=${activation.deviceSig}")
|
||||
},
|
||||
)
|
||||
|
||||
val installScript = """
|
||||
set -e
|
||||
mkdir -p ${PremiumInstall.DIR_PATH}
|
||||
cp ${shellQuote(tempDex.absolutePath)} ${PremiumInstall.DEX_PATH}.tmp
|
||||
cp ${shellQuote(tempMeta.absolutePath)} ${PremiumInstall.META_PATH}.tmp
|
||||
chown system:system ${PremiumInstall.DIR_PATH}
|
||||
chown system:system ${PremiumInstall.DEX_PATH}.tmp ${PremiumInstall.META_PATH}.tmp
|
||||
chmod 0755 ${PremiumInstall.DIR_PATH}
|
||||
chmod 0444 ${PremiumInstall.DEX_PATH}.tmp ${PremiumInstall.META_PATH}.tmp
|
||||
mv -f ${PremiumInstall.DEX_PATH}.tmp ${PremiumInstall.DEX_PATH}
|
||||
mv -f ${PremiumInstall.META_PATH}.tmp ${PremiumInstall.META_PATH}
|
||||
chown system:system ${PremiumInstall.DEX_PATH} ${PremiumInstall.META_PATH}
|
||||
chmod 0444 ${PremiumInstall.DEX_PATH} ${PremiumInstall.META_PATH}
|
||||
rm -f ${PremiumInstall.LEGACY_DEVICE_ID_PATH}
|
||||
""".trimIndent()
|
||||
val result = Shell.cmd("sh -c ${shellQuote(installScript)}").exec()
|
||||
|
||||
tempDex.delete()
|
||||
tempMeta.delete()
|
||||
|
||||
check(result.isSuccess) {
|
||||
result.err.joinToString("\n").ifBlank { "Root install failed" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun devicePubkeyHex(): String =
|
||||
DeviceKeystore.getOrCreatePublicKeyBytes()
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun bootCount(context: Context): Int =
|
||||
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, 0)
|
||||
|
||||
private fun apiBaseUrls(preferredBaseUrl: String? = null): List<String> {
|
||||
val configured = BuildConfig.PREMIUM_API_URLS
|
||||
.split(',')
|
||||
.map { it.trim().trimEnd('/') }
|
||||
.filter { it.isNotEmpty() }
|
||||
val ordered = buildList {
|
||||
preferredBaseUrl?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() }?.let(::add)
|
||||
addAll(configured)
|
||||
}
|
||||
return ordered.distinct()
|
||||
}
|
||||
|
||||
private inline fun <T> withApiFallback(
|
||||
preferredBaseUrl: String? = null,
|
||||
block: (String) -> T,
|
||||
): T =
|
||||
withApiFallbackWithBase(preferredBaseUrl, block).second
|
||||
|
||||
private inline fun <T> withApiFallbackWithBase(
|
||||
preferredBaseUrl: String? = null,
|
||||
block: (String) -> T,
|
||||
): Pair<String, T> {
|
||||
val urls = apiBaseUrls(preferredBaseUrl)
|
||||
require(urls.isNotEmpty()) { "Premium API URL is not configured" }
|
||||
|
||||
var fallbackFailure: Throwable? = null
|
||||
for (baseUrl in urls) {
|
||||
try {
|
||||
return baseUrl to block(baseUrl)
|
||||
} catch (throwable: Throwable) {
|
||||
if (!shouldTryNextApi(throwable)) throw throwable
|
||||
if (fallbackFailure == null) fallbackFailure = throwable
|
||||
}
|
||||
}
|
||||
throw fallbackFailure ?: error("Premium API URL is not configured")
|
||||
}
|
||||
|
||||
private fun shouldTryNextApi(throwable: Throwable): Boolean =
|
||||
throwable is IOException ||
|
||||
(throwable is HttpStatusException && throwable.statusCode >= 500)
|
||||
|
||||
private fun postJson(url: String, body: String): JSONObject {
|
||||
val connection = openConnection(url).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
return JSONObject(readResponse(connection))
|
||||
}
|
||||
|
||||
private fun postOk(url: String, body: String) {
|
||||
val connection = openConnection(url).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
val message = connection.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
throw HttpStatusException(code, "Request failed ($code): $message")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBytes(url: String): ByteArray {
|
||||
val connection = openConnection(url).apply { requestMethod = "GET" }
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
val message = connection.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
throw HttpStatusException(code, "Download failed ($code): $message")
|
||||
}
|
||||
return connection.inputStream.use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun readResponse(connection: HttpURLConnection): String {
|
||||
val code = connection.responseCode
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
if (code !in 200..299) throw HttpStatusException(code, "Activation failed ($code): $body")
|
||||
return body
|
||||
}
|
||||
|
||||
private fun openConnection(url: String): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(bytes)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun shellQuote(value: String): String =
|
||||
"'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
private fun normalizeCode(code: String): String = code.trim().uppercase()
|
||||
|
||||
private fun urlEncode(value: String): String =
|
||||
java.net.URLEncoder.encode(value, Charsets.UTF_8.name())
|
||||
|
||||
private class HttpStatusException(
|
||||
val statusCode: Int,
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
}
|
||||
221
app/src/main/java/com/fan/edgex/overlay/ArcLayoutView.kt
Normal file
221
app/src/main/java/com/fan/edgex/overlay/ArcLayoutView.kt
Normal file
@@ -0,0 +1,221 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.OverScroller
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Custom ViewGroup that positions children along an arc path
|
||||
* Supports vertical scrolling to navigate through items
|
||||
*/
|
||||
class ArcLayoutView(context: Context) : ViewGroup(context) {
|
||||
|
||||
// Arc configuration - true semicircle layout
|
||||
private val arcRadiusDp = 180f // Radius for semicircle
|
||||
private val itemSpacingAngle = 32f // Larger angle to span ~180 degrees with 6 items
|
||||
private val itemSizeDp = 65f // Size of each item (smaller icons)
|
||||
|
||||
// Convert to pixels
|
||||
private val arcRadius = dpToPx(arcRadiusDp)
|
||||
private val itemSize = dpToPx(itemSizeDp).toInt()
|
||||
|
||||
// Scrolling
|
||||
private val scroller = OverScroller(context)
|
||||
private var scrollAngle = 0f // Scroll in degrees
|
||||
private var lastTouchY = 0f
|
||||
private var isDragging = false
|
||||
private var velocityTracker: android.view.VelocityTracker? = null
|
||||
|
||||
// Number of visible items
|
||||
private val visibleItems = 6
|
||||
|
||||
private fun dpToPx(dp: Float): Float {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
dp,
|
||||
resources.displayMetrics
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
// Measure all children with fixed size
|
||||
val childSpec = MeasureSpec.makeMeasureSpec(itemSize, MeasureSpec.EXACTLY)
|
||||
for (i in 0 until childCount) {
|
||||
getChildAt(i).measure(childSpec, childSpec)
|
||||
}
|
||||
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
if (childCount == 0) return
|
||||
|
||||
val centerY = height / 2
|
||||
|
||||
// Arc center is on the right edge of the view
|
||||
val arcCenterX = width.toFloat()
|
||||
val arcCenterY = centerY.toFloat()
|
||||
|
||||
// Total angle range for circular scrolling
|
||||
val totalAngleRange = childCount * itemSpacingAngle
|
||||
val normalizedScrollAngle = ((scrollAngle % totalAngleRange) + totalAngleRange) % totalAngleRange
|
||||
|
||||
for (i in 0 until childCount) {
|
||||
val child = getChildAt(i)
|
||||
|
||||
// Calculate angle for this item with circular wrapping
|
||||
var itemAngle = i * itemSpacingAngle - normalizedScrollAngle
|
||||
|
||||
// Wrap around for circular effect
|
||||
if (itemAngle < -totalAngleRange / 2) {
|
||||
itemAngle += totalAngleRange
|
||||
} else if (itemAngle > totalAngleRange / 2) {
|
||||
itemAngle -= totalAngleRange
|
||||
}
|
||||
|
||||
// Convert to radians
|
||||
// At itemAngle = 0 (middle), angleRad = 0 -> cos=1, sin=0
|
||||
// x = arcCenterX - radius * cos(0) = width - radius (bulge left)
|
||||
// y = arcCenterY + radius * sin(0) = centerY (centered)
|
||||
val angleRad = Math.toRadians(itemAngle.toDouble())
|
||||
|
||||
// Calculate position on arc
|
||||
val x = (arcCenterX - arcRadius * Math.cos(angleRad) - itemSize / 2).toInt()
|
||||
val y = (arcCenterY + arcRadius * Math.sin(angleRad) - itemSize / 2).toInt()
|
||||
|
||||
// Only show items within visible range (~180 degrees)
|
||||
val visibleAngleRange = 100f // 100 degrees each way equals ~200 degrees total visibility
|
||||
if (Math.abs(itemAngle) > visibleAngleRange) {
|
||||
child.visibility = View.GONE
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if item is within screen bounds
|
||||
if (y < -itemSize || y > height) {
|
||||
child.visibility = View.GONE
|
||||
continue
|
||||
}
|
||||
|
||||
child.visibility = View.VISIBLE
|
||||
|
||||
child.layout(
|
||||
x,
|
||||
y,
|
||||
x + itemSize,
|
||||
y + itemSize
|
||||
)
|
||||
|
||||
// Apply scale and alpha based on distance from center
|
||||
val distanceFromCenter = Math.min(1.0, Math.abs(itemAngle) / 90.0)
|
||||
val scale = 1.0f - (distanceFromCenter * 0.2).toFloat()
|
||||
val alpha = 1.0f - (distanceFromCenter * 0.5).toFloat()
|
||||
|
||||
child.scaleX = Math.max(0.7f, scale)
|
||||
child.scaleY = Math.max(0.7f, scale)
|
||||
child.alpha = Math.max(0.3f, alpha)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
when (ev.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastTouchY = ev.y
|
||||
isDragging = false
|
||||
scroller.forceFinished(true)
|
||||
velocityTracker?.clear()
|
||||
velocityTracker = android.view.VelocityTracker.obtain()
|
||||
velocityTracker?.addMovement(ev)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
velocityTracker?.addMovement(ev)
|
||||
val deltaY = abs(ev.y - lastTouchY)
|
||||
if (deltaY > 10) { // Touch slop threshold
|
||||
isDragging = true
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var onEmptySpaceClick: (() -> Unit)? = null
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
velocityTracker?.addMovement(event)
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downX = event.x
|
||||
downY = event.y
|
||||
lastTouchY = event.y
|
||||
scroller.forceFinished(true)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val deltaY = lastTouchY - event.y
|
||||
lastTouchY = event.y
|
||||
|
||||
// Convert pixel movement to angle change
|
||||
val angleChange = deltaY / 8f // Higher sensitivity for larger semicircle
|
||||
scrollAngle += angleChange
|
||||
|
||||
requestLayout()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
// Check for click (tap)
|
||||
val moveX = abs(event.x - downX)
|
||||
val moveY = abs(event.y - downY)
|
||||
if (moveX < 10 && moveY < 10) { // Simple touch slop
|
||||
onEmptySpaceClick?.invoke()
|
||||
}
|
||||
|
||||
velocityTracker?.computeCurrentVelocity(1000)
|
||||
val velocityY = velocityTracker?.yVelocity ?: 0f
|
||||
|
||||
if (abs(velocityY) > 500) {
|
||||
// Fling without bounds (circular)
|
||||
val angleVelocity = -velocityY / 10f
|
||||
scroller.fling(
|
||||
0, scrollAngle.toInt(),
|
||||
0, angleVelocity.toInt(),
|
||||
0, 0,
|
||||
Int.MIN_VALUE, Int.MAX_VALUE
|
||||
)
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = null
|
||||
isDragging = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun computeScroll() {
|
||||
if (scroller.computeScrollOffset()) {
|
||||
scrollAngle = scroller.currY.toFloat()
|
||||
requestLayout()
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a child view to the arc layout
|
||||
*/
|
||||
fun addItem(view: View) {
|
||||
addView(view)
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/com/fan/edgex/overlay/DrawerManager.kt
Normal file
32
app/src/main/java/com/fan/edgex/overlay/DrawerManager.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object DrawerManager {
|
||||
private var activeDrawer: DrawerWindow? = null
|
||||
|
||||
fun showDrawer(context: Context, resolveConfig: (String) -> String) {
|
||||
if (activeDrawer?.isShowing() == true) return
|
||||
val drawer = DrawerWindow(context, resolveConfig) { activeDrawer = null }
|
||||
activeDrawer = drawer
|
||||
drawer.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-dismiss the active drawer, e.g. on SCREEN_OFF.
|
||||
* Prevents drawer overlay from blocking touch after unlock.
|
||||
*/
|
||||
fun dismissDrawer() {
|
||||
try {
|
||||
activeDrawer?.let {
|
||||
if (it.isShowing()) {
|
||||
it.forceDismiss()
|
||||
}
|
||||
activeDrawer = null
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: DrawerManager.dismissDrawer failed: ${t.message}")
|
||||
activeDrawer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
647
app/src/main/java/com/fan/edgex/overlay/DrawerWindow.kt
Normal file
647
app/src/main/java/com/fan/edgex/overlay/DrawerWindow.kt
Normal file
@@ -0,0 +1,647 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import androidx.core.graphics.toColorInt
|
||||
import android.graphics.Outline
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.text.TextUtils
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import com.fan.edgex.hook.ModuleRes
|
||||
|
||||
class DrawerWindow(
|
||||
private val context: Context,
|
||||
private val resolveConfig: (String) -> String,
|
||||
private val onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
|
||||
private data class AppEntry(val resolveInfo: android.content.pm.ResolveInfo, val isFrozen: Boolean)
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var rootView: FrameLayout? = null
|
||||
private var drawerPanel: View? = null
|
||||
private var contentLeftBound = 0
|
||||
private var drawerPanelWidth = 0
|
||||
private var configReceiver: android.content.BroadcastReceiver? = null
|
||||
|
||||
private val MOCK_MODE = false
|
||||
|
||||
private val isDarkMode: Boolean
|
||||
get() {
|
||||
val darkSetting = resolveConfig(AppConfig.UI_DARK_MODE).ifBlank { "system" }
|
||||
return when (darkSetting) {
|
||||
"dark", "true" -> true
|
||||
"light", "false" -> false
|
||||
"system" -> {
|
||||
(context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
else -> {
|
||||
darkSetting.toBooleanStrictOrNull() ?: ((context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (rootView != null) return
|
||||
|
||||
registerConfigReceiver()
|
||||
|
||||
val useArcDrawer = resolveConfig(AppConfig.FREEZER_ARC_DRAWER).toBoolean()
|
||||
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
|
||||
rootView = object : FrameLayout(context) {
|
||||
override fun dispatchTouchEvent(ev: android.view.MotionEvent): Boolean {
|
||||
if (ev.action == android.view.MotionEvent.ACTION_DOWN && ev.x < contentLeftBound) {
|
||||
animateOut()
|
||||
return true
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
|
||||
animateOut()
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}.apply {
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
val pm = context.packageManager
|
||||
val displayApps: List<AppEntry> = if (MOCK_MODE) {
|
||||
loadMockApps(pm)
|
||||
} else {
|
||||
loadConfiguredApps(pm)
|
||||
}
|
||||
|
||||
if (useArcDrawer) {
|
||||
setupArcLayout(displayApps, pm, displayMetrics)
|
||||
contentLeftBound = displayMetrics.widthPixels - (200 * displayMetrics.density).toInt()
|
||||
} else {
|
||||
val isLandscape = context.resources.configuration.orientation ==
|
||||
android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
// Portrait needs a wider panel (70%) to comfortably fit 3 columns
|
||||
val panelFraction = if (isLandscape) 0.62f else 0.70f
|
||||
val panelWidth = (displayMetrics.widthPixels * panelFraction).toInt()
|
||||
contentLeftBound = displayMetrics.widthPixels - panelWidth
|
||||
drawerPanelWidth = panelWidth
|
||||
setupModernLayout(displayApps, pm, panelWidth)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val params = WindowManager.LayoutParams().apply {
|
||||
type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
// FLAG_DIM_BEHIND (0x2) | FLAG_BLUR_BEHIND (0x4)
|
||||
flags = WindowManager.LayoutParams.FLAG_DIM_BEHIND or WindowManager.LayoutParams.FLAG_BLUR_BEHIND
|
||||
dimAmount = 0.25f
|
||||
blurBehindRadius = 36
|
||||
}
|
||||
|
||||
try {
|
||||
windowManager.addView(rootView, params)
|
||||
if (!useArcDrawer) {
|
||||
drawerPanel?.let { panel ->
|
||||
panel.translationX = drawerPanelWidth.toFloat()
|
||||
ValueAnimator.ofFloat(drawerPanelWidth.toFloat(), 0f).apply {
|
||||
duration = 340
|
||||
interpolator = DecelerateInterpolator(2.2f)
|
||||
addUpdateListener { panel.translationX = it.animatedValue as Float }
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupModernLayout(
|
||||
displayApps: List<AppEntry>,
|
||||
pm: android.content.pm.PackageManager,
|
||||
panelWidth: Int
|
||||
) {
|
||||
val dp = context.resources.displayMetrics.density
|
||||
val dark = isDarkMode
|
||||
|
||||
val surfaceBg = if (dark) OverlayTheme.SURFACE_BG_DARK else OverlayTheme.SURFACE_BG_LIGHT
|
||||
val textPrimary = if (dark) OverlayTheme.TEXT_PRIMARY_DARK else OverlayTheme.TEXT_PRIMARY_LIGHT
|
||||
val textMuted = if (dark) OverlayTheme.TEXT_SECONDARY_DARK else OverlayTheme.TEXT_SECONDARY_LIGHT
|
||||
val cardBg = if (dark) OverlayTheme.CARD_BG_DARK else OverlayTheme.CARD_BG_LIGHT
|
||||
val dividerColor = if (dark) OverlayTheme.DIVIDER_DARK else OverlayTheme.DIVIDER_LIGHT
|
||||
val frozenBadgeBg = OverlayTheme.FROZEN_BADGE_BG
|
||||
val cornerRad = OverlayTheme.CORNER_SHEET_DP * dp
|
||||
|
||||
val panel = FrameLayout(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(panelWidth, ViewGroup.LayoutParams.MATCH_PARENT).apply {
|
||||
gravity = Gravity.END
|
||||
}
|
||||
background = GradientDrawable().apply {
|
||||
setColor(surfaceBg)
|
||||
// Only left corners rounded; right edge is off-screen
|
||||
cornerRadii = floatArrayOf(cornerRad, cornerRad, 0f, 0f, 0f, 0f, cornerRad, cornerRad)
|
||||
}
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width + cornerRad.toInt(), view.height, cornerRad)
|
||||
}
|
||||
}
|
||||
clipToOutline = true
|
||||
elevation = OverlayTheme.ELEVATION_DP * dp
|
||||
isClickable = true
|
||||
}
|
||||
drawerPanel = panel
|
||||
|
||||
val root = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
// Header
|
||||
root.addView(LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((20 * dp).toInt(), (52 * dp).toInt(), (16 * dp).toInt(), (14 * dp).toInt())
|
||||
addView(TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.freezer_drawer_title)
|
||||
textSize = 22f
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
setTextColor(textPrimary)
|
||||
letterSpacing = -0.02f
|
||||
})
|
||||
val frozenCount = displayApps.count { it.isFrozen }
|
||||
addView(TextView(context).apply {
|
||||
text = ModuleRes.getString(
|
||||
R.string.freezer_drawer_summary,
|
||||
displayApps.size,
|
||||
frozenCount,
|
||||
)
|
||||
textSize = 12.5f
|
||||
setTextColor(textMuted)
|
||||
setPadding(0, (4 * dp).toInt(), 0, 0)
|
||||
})
|
||||
})
|
||||
|
||||
root.addView(View(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1)
|
||||
setBackgroundColor(dividerColor)
|
||||
})
|
||||
|
||||
val scrollView = ScrollView(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
isVerticalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
isFillViewport = true
|
||||
}
|
||||
|
||||
if (displayApps.isEmpty()) {
|
||||
scrollView.addView(LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
addView(TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.label_empty_drawer)
|
||||
textSize = 14f
|
||||
setTextColor(textMuted)
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
})
|
||||
} else {
|
||||
val isLandscape = context.resources.configuration.orientation ==
|
||||
android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
val columns = if (isLandscape) 4 else 3
|
||||
val gap = (8 * dp).toInt()
|
||||
val hPad = (12 * dp).toInt()
|
||||
|
||||
// Pre-compute card dimensions so content never overflows the card boundary
|
||||
val cardWidth = (panelWidth - 2 * hPad - columns * 2 * gap) / columns
|
||||
// Icon occupies at most 56% of card width, hard-capped at 52dp
|
||||
val iconSize = (cardWidth * 0.56f).toInt().coerceIn((32 * dp).toInt(), (52 * dp).toInt())
|
||||
// Equal horizontal padding so icon is centered with room to spare
|
||||
val innerPadH = ((cardWidth - iconSize) / 2).coerceAtLeast((4 * dp).toInt())
|
||||
// Vertical: fixed padding values, card height = icon + label row + padding
|
||||
val innerPadTop = (10 * dp).toInt()
|
||||
val innerPadBot = (8 * dp).toInt()
|
||||
val labelTopPad = (5 * dp).toInt()
|
||||
val labelHeightPx = (24 * dp).toInt()
|
||||
val cardHeight = iconSize + innerPadTop + labelTopPad + labelHeightPx + innerPadBot
|
||||
val cardCorner = (cardWidth * 0.22f).coerceAtMost(20f * dp)
|
||||
|
||||
val gridContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(hPad, (8 * dp).toInt(), hPad, (28 * dp).toInt())
|
||||
}
|
||||
|
||||
val grayscaleFilter = android.graphics.ColorMatrixColorFilter(
|
||||
android.graphics.ColorMatrix().apply { setSaturation(0f) }
|
||||
)
|
||||
val shownPackages = mutableSetOf<String>()
|
||||
var currentRow: LinearLayout? = null
|
||||
var col = 0
|
||||
|
||||
for ((ri, frozen) in displayApps) {
|
||||
val pkg = ri.activityInfo.applicationInfo.packageName
|
||||
if (!shownPackages.add(pkg)) continue
|
||||
|
||||
if (col % columns == 0) {
|
||||
currentRow = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
gridContainer.addView(currentRow)
|
||||
}
|
||||
|
||||
val card = FrameLayout(context).apply {
|
||||
// Use explicit cardHeight so card is always square-ish regardless of orientation
|
||||
layoutParams = LinearLayout.LayoutParams(0, cardHeight, 1f).apply {
|
||||
setMargins(gap, gap, gap, gap)
|
||||
}
|
||||
|
||||
background = GradientDrawable().apply {
|
||||
setColor(if (dark) Color.argb(22, 255, 255, 255) else Color.argb(145, 255, 255, 255))
|
||||
cornerRadius = cardCorner
|
||||
// Delicate reflection edge stroke for glassmorphism
|
||||
setStroke(
|
||||
(1 * dp).toInt(),
|
||||
if (dark) Color.argb(40, 255, 255, 255) else Color.argb(80, 255, 255, 255)
|
||||
)
|
||||
}
|
||||
|
||||
// Frosted card click ripple feedback using foreground
|
||||
val outValue = android.util.TypedValue()
|
||||
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true)
|
||||
foreground = context.getDrawable(outValue.resourceId)
|
||||
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, cardCorner)
|
||||
}
|
||||
}
|
||||
clipToOutline = true
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener {
|
||||
if (frozen) threadUnfreeze(pkg, ri.loadLabel(pm).toString(), pm)
|
||||
else launchApp(context, pm, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
// inner fills the card and centers content vertically
|
||||
val inner = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(innerPadH, innerPadTop, innerPadH, innerPadBot)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
val iconFrame = FrameLayout(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
}
|
||||
iconFrame.addView(ImageView(context).apply {
|
||||
setImageDrawable(ri.loadIcon(pm))
|
||||
layoutParams = FrameLayout.LayoutParams(iconSize, iconSize)
|
||||
if (frozen) { colorFilter = grayscaleFilter; alpha = 0.55f }
|
||||
|
||||
// Clip the icon to a modern rounded squircle shape, perfectly covering adaptive icon backgrounds
|
||||
val iconCorner = iconSize * 0.2f
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, iconCorner)
|
||||
}
|
||||
}
|
||||
clipToOutline = true
|
||||
})
|
||||
if (frozen) {
|
||||
val badgeSize = (iconSize * 0.38f).toInt()
|
||||
iconFrame.addView(TextView(context).apply {
|
||||
text = "❄"
|
||||
textSize = (badgeSize / dp * 0.62f)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor("#90CAF9".toColorInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(frozenBadgeBg)
|
||||
cornerRadius = badgeSize * 0.38f
|
||||
}
|
||||
setPadding((2 * dp).toInt(), (1 * dp).toInt(), (2 * dp).toInt(), (1 * dp).toInt())
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.BOTTOM or Gravity.END }
|
||||
})
|
||||
}
|
||||
|
||||
inner.addView(iconFrame)
|
||||
inner.addView(TextView(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
text = ri.loadLabel(pm)
|
||||
textSize = (iconSize / dp * 0.22f).coerceIn(9f, 12f)
|
||||
gravity = Gravity.CENTER
|
||||
maxLines = 1
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
setTextColor(if (frozen) textMuted else textPrimary)
|
||||
setPadding(0, labelTopPad, 0, 0)
|
||||
})
|
||||
|
||||
card.addView(inner)
|
||||
currentRow?.addView(card)
|
||||
col++
|
||||
}
|
||||
|
||||
// Pad trailing cells so last row aligns left
|
||||
val trailing = columns - (col % columns)
|
||||
if (trailing < columns) {
|
||||
repeat(trailing) {
|
||||
currentRow?.addView(View(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(0, cardHeight, 1f).apply {
|
||||
setMargins(gap, gap, gap, gap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scrollView.addView(gridContainer)
|
||||
}
|
||||
|
||||
root.addView(scrollView)
|
||||
panel.addView(root)
|
||||
rootView?.addView(panel)
|
||||
}
|
||||
|
||||
private fun setupArcLayout(
|
||||
displayApps: List<AppEntry>,
|
||||
pm: android.content.pm.PackageManager,
|
||||
displayMetrics: android.util.DisplayMetrics
|
||||
) {
|
||||
val drawerContent = FrameLayout(context).apply {
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
).apply { gravity = Gravity.RIGHT }
|
||||
isClickable = false
|
||||
}
|
||||
|
||||
val arcLayout = ArcLayoutView(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
onEmptySpaceClick = { animateOut() }
|
||||
}
|
||||
|
||||
if (displayApps.isEmpty()) {
|
||||
drawerContent.addView(TextView(context).apply {
|
||||
text = ModuleRes.getString(R.string.label_empty_drawer)
|
||||
textSize = 16f
|
||||
setPadding(40, 40, 40, 40)
|
||||
setTextColor(Color.DKGRAY)
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
} else {
|
||||
val grayscaleFilter = android.graphics.ColorMatrixColorFilter(
|
||||
android.graphics.ColorMatrix().apply { setSaturation(0f) }
|
||||
)
|
||||
val shownPackages = mutableSetOf<String>()
|
||||
|
||||
for ((ri, frozen) in displayApps) {
|
||||
val pkg = ri.activityInfo.applicationInfo.packageName
|
||||
if (!shownPackages.add(pkg)) continue
|
||||
|
||||
try {
|
||||
arcLayout.addItem(LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(12, 12, 12, 12)
|
||||
background = GradientDrawable().apply {
|
||||
setColor("#F0F0F0".toColorInt())
|
||||
cornerRadius = 24f
|
||||
}
|
||||
addView(ImageView(context).apply {
|
||||
setImageDrawable(ri.loadIcon(pm))
|
||||
layoutParams = LinearLayout.LayoutParams(55, 55)
|
||||
if (frozen) { colorFilter = grayscaleFilter; alpha = 0.5f }
|
||||
})
|
||||
addView(TextView(context).apply {
|
||||
text = ri.loadLabel(pm)
|
||||
textSize = 9f
|
||||
gravity = Gravity.CENTER
|
||||
maxLines = 1
|
||||
setTextColor(if (frozen) Color.GRAY else Color.DKGRAY)
|
||||
})
|
||||
setOnClickListener {
|
||||
if (frozen) threadUnfreeze(pkg, ri.loadLabel(pm).toString(), pm)
|
||||
else launchApp(context, pm, pkg)
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawerContent.addView(arcLayout)
|
||||
rootView?.addView(drawerContent)
|
||||
}
|
||||
|
||||
private fun threadUnfreeze(packageName: String, label: String, pm: android.content.pm.PackageManager) {
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: DrawerWindow.threadUnfreeze - packageName: $packageName")
|
||||
Thread {
|
||||
var success = false
|
||||
try {
|
||||
pm.setApplicationEnabledSetting(
|
||||
packageName,
|
||||
android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
0
|
||||
)
|
||||
success = true
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: PM API unfreeze SUCCESS for $packageName")
|
||||
} catch (e: Exception) {
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: PM API unfreeze FAILED for $packageName: ${e.message}")
|
||||
}
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
if (success) launchApp(context, pm, packageName)
|
||||
else android.widget.Toast.makeText(
|
||||
context, ModuleRes.getString(R.string.toast_unfreeze_failed_drawer),
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun launchApp(context: Context, pm: android.content.pm.PackageManager, packageName: String) {
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
var retries = 0
|
||||
var task: Runnable? = null
|
||||
task = Runnable {
|
||||
try {
|
||||
val intent = pm.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
context.startActivity(intent)
|
||||
dismiss()
|
||||
} else if (++retries < 10) {
|
||||
handler.postDelayed(task!!, 200)
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context, ModuleRes.getString(R.string.toast_launch_timeout),
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.widget.Toast.makeText(
|
||||
context, ModuleRes.getString(R.string.toast_launch_error, e.message),
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
task.run()
|
||||
}
|
||||
|
||||
fun isShowing() = rootView != null
|
||||
|
||||
fun forceDismiss() = dismiss()
|
||||
|
||||
private fun animateOut() {
|
||||
val panel = drawerPanel
|
||||
if (panel != null) {
|
||||
val panelWidth = panel.width.takeIf { it > 0 }
|
||||
?: (context.resources.displayMetrics.widthPixels * 0.62f).toInt()
|
||||
ValueAnimator.ofFloat(0f, panelWidth.toFloat()).apply {
|
||||
duration = 260
|
||||
interpolator = DecelerateInterpolator(1.8f)
|
||||
addUpdateListener { panel.translationX = it.animatedValue as Float }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) { dismiss() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMockApps(pm: android.content.pm.PackageManager): List<AppEntry> {
|
||||
return try {
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN, null).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
pm.queryIntentActivities(mainIntent, 0)
|
||||
.sortedWith { a, b ->
|
||||
a.loadLabel(pm).toString().compareTo(b.loadLabel(pm).toString(), ignoreCase = true)
|
||||
}
|
||||
.distinctBy { it.activityInfo.applicationInfo.packageName }
|
||||
.take(20)
|
||||
.mapIndexed { index, ri -> AppEntry(ri, isFrozen = index % 4 == 3) }
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadConfiguredApps(pm: android.content.pm.PackageManager): List<AppEntry> {
|
||||
return try {
|
||||
val configuredPackages = linkedSetOf<String>()
|
||||
try {
|
||||
val listStr = HookConfigSnapshot.readFromHookFile()[AppConfig.FREEZER_APP_LIST] ?: ""
|
||||
if (listStr.isNotEmpty()) {
|
||||
configuredPackages.addAll(
|
||||
listStr.split(",").map { s -> s.trim() }.filter { s -> s.isNotEmpty() }
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: Failed to read config: ${e.message}")
|
||||
}
|
||||
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN, null).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
pm.queryIntentActivities(mainIntent, android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS)
|
||||
.filter { configuredPackages.contains(it.activityInfo.applicationInfo.packageName) }
|
||||
.sortedWith { a, b ->
|
||||
a.loadLabel(pm).toString().compareTo(b.loadLabel(pm).toString(), ignoreCase = true)
|
||||
}
|
||||
.distinctBy { it.activityInfo.applicationInfo.packageName }
|
||||
.map { ri -> AppEntry(ri, isFrozen = !ri.activityInfo.applicationInfo.enabled) }
|
||||
} catch (e: Exception) {
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: Failed to load apps: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerConfigReceiver() {
|
||||
if (configReceiver != null) return
|
||||
configReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_CONFIGURATION_CHANGED) {
|
||||
recreateOnRotation()
|
||||
}
|
||||
}
|
||||
}
|
||||
context.registerReceiver(
|
||||
configReceiver,
|
||||
android.content.IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED)
|
||||
)
|
||||
}
|
||||
|
||||
private fun unregisterConfigReceiver() {
|
||||
configReceiver?.let {
|
||||
try { context.unregisterReceiver(it) } catch (_: Exception) {}
|
||||
configReceiver = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the current window and rebuild with the new orientation config. */
|
||||
private fun recreateOnRotation() {
|
||||
// Remove the window silently — do NOT invoke onDismiss so DrawerManager
|
||||
// keeps its activeDrawer reference pointing at this instance.
|
||||
unregisterConfigReceiver()
|
||||
try { windowManager.removeView(rootView) } catch (_: Exception) {}
|
||||
rootView = null
|
||||
drawerPanel = null
|
||||
// Wait one frame for the system to finish applying the new configuration
|
||||
// so displayMetrics reflects the rotated dimensions when show() runs.
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ show() }, 80)
|
||||
}
|
||||
|
||||
private fun dismiss() {
|
||||
unregisterConfigReceiver()
|
||||
if (rootView != null) {
|
||||
try { windowManager.removeView(rootView) } catch (_: Exception) {}
|
||||
rootView = null
|
||||
drawerPanel = null
|
||||
onDismiss?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
334
app/src/main/java/com/fan/edgex/overlay/EdgeLightingView.kt
Normal file
334
app/src/main/java/com/fan/edgex/overlay/EdgeLightingView.kt
Normal file
@@ -0,0 +1,334 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import android.view.View
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class EdgeLightingView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: android.util.AttributeSet? = null,
|
||||
) : View(context, attrs) {
|
||||
|
||||
var glowColor: Int = Color.CYAN
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var glowWidthPx: Float = 5f * resources.displayMetrics.density
|
||||
set(value) {
|
||||
field = value.coerceAtLeast(1f)
|
||||
paint.strokeWidth = field * 2f
|
||||
paint.maskFilter = BlurMaskFilter(field, BlurMaskFilter.Blur.NORMAL)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var glowAlpha: Float = 0f
|
||||
set(value) {
|
||||
field = value.coerceIn(0f, 1f)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var effect: String = AppConfig.EDGE_LIGHTING_EFFECT_BASIC
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var flowProgress: Float = 0f
|
||||
set(value) {
|
||||
field = value - value.toInt()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeWidth = glowWidthPx * 2f
|
||||
maskFilter = BlurMaskFilter(glowWidthPx, BlurMaskFilter.Blur.NORMAL)
|
||||
}
|
||||
|
||||
private val rect = RectF()
|
||||
private val shaderMatrix = Matrix()
|
||||
|
||||
init {
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (glowAlpha <= 0f || width <= 0 || height <= 0) return
|
||||
|
||||
val alphaColor = Color.argb(
|
||||
(Color.alpha(glowColor) * glowAlpha).toInt().coerceIn(0, 255),
|
||||
Color.red(glowColor),
|
||||
Color.green(glowColor),
|
||||
Color.blue(glowColor),
|
||||
)
|
||||
|
||||
when (effect) {
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_FLOW -> {
|
||||
drawFlow(canvas, alphaColor, widthFactor = 0.55f)
|
||||
return
|
||||
}
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_SPOTLIGHT -> {
|
||||
drawSpotlight(canvas, alphaColor)
|
||||
return
|
||||
}
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_MULTICOLOR -> {
|
||||
drawMulticolor(canvas)
|
||||
return
|
||||
}
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_ECLIPSE -> {
|
||||
drawEclipse(canvas, alphaColor)
|
||||
return
|
||||
}
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_ECHO -> {
|
||||
drawEcho(canvas, alphaColor)
|
||||
return
|
||||
}
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_COMET -> {
|
||||
drawComet(canvas, alphaColor)
|
||||
return
|
||||
}
|
||||
AppConfig.EDGE_LIGHTING_EFFECT_RIPPLE -> {
|
||||
drawRipple(canvas, alphaColor)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
paint.shader = null
|
||||
paint.color = alphaColor
|
||||
val inset = glowWidthPx * 0.5f
|
||||
rect.set(-inset, -inset, width + inset, height + inset)
|
||||
canvas.drawRect(rect, paint)
|
||||
}
|
||||
|
||||
private fun drawFlow(canvas: Canvas, alphaColor: Int, widthFactor: Float) {
|
||||
val transparent = Color.argb(0, Color.red(glowColor), Color.green(glowColor), Color.blue(glowColor))
|
||||
val colors = intArrayOf(transparent, alphaColor, transparent)
|
||||
val positions = floatArrayOf(0f, 0.5f, 1f)
|
||||
|
||||
paint.color = Color.WHITE
|
||||
|
||||
paint.shader = movingShader(width.toFloat().coerceAtLeast(1f), horizontal = true, colors, positions, widthFactor)
|
||||
canvas.drawLine(0f, 0f, width.toFloat(), 0f, paint)
|
||||
canvas.drawLine(width.toFloat(), height.toFloat(), 0f, height.toFloat(), paint)
|
||||
|
||||
paint.shader = movingShader(height.toFloat().coerceAtLeast(1f), horizontal = false, colors, positions, widthFactor)
|
||||
canvas.drawLine(width.toFloat(), 0f, width.toFloat(), height.toFloat(), paint)
|
||||
canvas.drawLine(0f, height.toFloat(), 0f, 0f, paint)
|
||||
paint.shader = null
|
||||
}
|
||||
|
||||
private fun drawSpotlight(canvas: Canvas, alphaColor: Int) {
|
||||
paint.shader = null
|
||||
paint.color = ColorUtils.setAlphaComponent(alphaColor, (Color.alpha(alphaColor) * 0.28f).toInt().coerceIn(0, 255))
|
||||
val inset = glowWidthPx * 0.5f
|
||||
rect.set(-inset, -inset, width + inset, height + inset)
|
||||
canvas.drawRect(rect, paint)
|
||||
drawFlow(canvas, alphaColor, widthFactor = 0.22f)
|
||||
}
|
||||
|
||||
private fun drawMulticolor(canvas: Canvas) {
|
||||
val alpha = (255 * glowAlpha).toInt().coerceIn(0, 255)
|
||||
val colors = intArrayOf(
|
||||
Color.argb(alpha, 0, 255, 255),
|
||||
Color.argb(alpha, 120, 96, 255),
|
||||
Color.argb(alpha, 255, 64, 180),
|
||||
Color.argb(alpha, 255, 210, 64),
|
||||
Color.argb(alpha, 0, 255, 140),
|
||||
Color.argb(alpha, 0, 255, 255),
|
||||
)
|
||||
paint.color = Color.WHITE
|
||||
|
||||
paint.shader = multicolorShader(width.toFloat().coerceAtLeast(1f), horizontal = true, colors, phase = flowProgress)
|
||||
canvas.drawLine(0f, 0f, width.toFloat(), 0f, paint)
|
||||
|
||||
paint.shader = multicolorShader(height.toFloat().coerceAtLeast(1f), horizontal = false, colors, phase = flowProgress + 0.25f)
|
||||
canvas.drawLine(width.toFloat(), 0f, width.toFloat(), height.toFloat(), paint)
|
||||
|
||||
paint.shader = multicolorShader(width.toFloat().coerceAtLeast(1f), horizontal = true, colors, phase = 1f - flowProgress)
|
||||
canvas.drawLine(width.toFloat(), height.toFloat(), 0f, height.toFloat(), paint)
|
||||
|
||||
paint.shader = multicolorShader(height.toFloat().coerceAtLeast(1f), horizontal = false, colors, phase = 0.75f - flowProgress)
|
||||
canvas.drawLine(0f, height.toFloat(), 0f, 0f, paint)
|
||||
paint.shader = null
|
||||
}
|
||||
|
||||
private fun drawEclipse(canvas: Canvas, alphaColor: Int) {
|
||||
val alpha = Color.alpha(alphaColor)
|
||||
val transparent = Color.argb(0, Color.red(glowColor), Color.green(glowColor), Color.blue(glowColor))
|
||||
val shadow = Color.argb((alpha * 0.12f).toInt().coerceIn(0, 255), 0, 0, 0)
|
||||
val highlight = ColorUtils.blendARGB(alphaColor, Color.WHITE, 0.35f)
|
||||
val colors = intArrayOf(shadow, transparent, highlight, transparent, shadow)
|
||||
val positions = floatArrayOf(0f, 0.26f, 0.5f, 0.74f, 1f)
|
||||
paint.color = Color.WHITE
|
||||
paint.shader = movingShader(width.toFloat().coerceAtLeast(1f), horizontal = true, colors, positions, 0.8f)
|
||||
canvas.drawLine(0f, 0f, width.toFloat(), 0f, paint)
|
||||
canvas.drawLine(width.toFloat(), height.toFloat(), 0f, height.toFloat(), paint)
|
||||
paint.shader = movingShader(height.toFloat().coerceAtLeast(1f), horizontal = false, colors, positions, 0.8f)
|
||||
canvas.drawLine(width.toFloat(), 0f, width.toFloat(), height.toFloat(), paint)
|
||||
canvas.drawLine(0f, height.toFloat(), 0f, 0f, paint)
|
||||
paint.shader = null
|
||||
}
|
||||
|
||||
private fun drawEcho(canvas: Canvas, alphaColor: Int) {
|
||||
paint.shader = null
|
||||
val originalStrokeWidth = paint.strokeWidth
|
||||
val originalMaskFilter = paint.maskFilter
|
||||
val echoStrokeWidth = originalStrokeWidth * 0.52f
|
||||
val echoBlur = glowWidthPx * 0.56f
|
||||
val insetStep = glowWidthPx * 0.42f
|
||||
paint.maskFilter = BlurMaskFilter(echoBlur, BlurMaskFilter.Blur.NORMAL)
|
||||
for (index in 0 until 3) {
|
||||
val echoAlpha = (Color.alpha(alphaColor) * (0.95f - index * 0.24f)).toInt().coerceIn(0, 255)
|
||||
paint.color = ColorUtils.setAlphaComponent(alphaColor, echoAlpha)
|
||||
paint.strokeWidth = echoStrokeWidth * (1f - index * 0.18f)
|
||||
val inset = index * insetStep
|
||||
rect.set(inset, inset, width - inset, height - inset)
|
||||
canvas.drawRect(rect, paint)
|
||||
}
|
||||
paint.strokeWidth = originalStrokeWidth
|
||||
paint.maskFilter = originalMaskFilter
|
||||
}
|
||||
|
||||
private fun movingShader(
|
||||
length: Float,
|
||||
horizontal: Boolean,
|
||||
colors: IntArray,
|
||||
positions: FloatArray,
|
||||
widthFactor: Float,
|
||||
): LinearGradient {
|
||||
val gradientLength = length * widthFactor.coerceIn(0.1f, 1f)
|
||||
val shader = if (horizontal) {
|
||||
LinearGradient(0f, 0f, gradientLength, 0f, colors, positions, Shader.TileMode.MIRROR)
|
||||
} else {
|
||||
LinearGradient(0f, 0f, 0f, gradientLength, colors, positions, Shader.TileMode.MIRROR)
|
||||
}
|
||||
shaderMatrix.reset()
|
||||
val periodCount = (1f / widthFactor.coerceIn(0.1f, 1f)).roundToInt().coerceAtLeast(1)
|
||||
val offset = gradientLength * 2f * periodCount * flowProgress
|
||||
if (horizontal) {
|
||||
shaderMatrix.setTranslate(offset, 0f)
|
||||
} else {
|
||||
shaderMatrix.setTranslate(0f, offset)
|
||||
}
|
||||
shader.setLocalMatrix(shaderMatrix)
|
||||
return shader
|
||||
}
|
||||
|
||||
private fun drawComet(canvas: Canvas, alphaColor: Int) {
|
||||
val wf = width.toFloat().coerceAtLeast(1f)
|
||||
val hf = height.toFloat().coerceAtLeast(1f)
|
||||
val totalPerim = 2f * (wf + hf)
|
||||
|
||||
// Perimeter boundary fractions (clockwise: top→right→bottom→left)
|
||||
val f1 = wf / totalPerim
|
||||
val f2 = (wf + hf) / totalPerim
|
||||
val f3 = (2f * wf + hf) / totalPerim
|
||||
|
||||
val tailFrac = 0.15f
|
||||
val head = flowProgress
|
||||
val tailF0 = (head - tailFrac + 1f) % 1f
|
||||
|
||||
fun perimDist(f: Float): Float { var d = f - tailF0; if (d < 0f) d += 1f; return d }
|
||||
|
||||
fun colorAt(f: Float): Int {
|
||||
val t = (perimDist(f) / tailFrac).coerceIn(0f, 1f)
|
||||
val a = (Color.alpha(alphaColor) * t * t).toInt().coerceIn(0, 255)
|
||||
return Color.argb(a, Color.red(alphaColor), Color.green(alphaColor), Color.blue(alphaColor))
|
||||
}
|
||||
|
||||
fun drawSideSegment(
|
||||
sideF0: Float, sideF1: Float,
|
||||
x0: Float, y0: Float, x1: Float, y1: Float,
|
||||
arcStart: Float, arcEnd: Float,
|
||||
) {
|
||||
val sideLen = sideF1 - sideF0
|
||||
if (sideLen <= 0f) return
|
||||
val oStart = maxOf(arcStart, sideF0)
|
||||
val oEnd = minOf(arcEnd, sideF1)
|
||||
if (oStart >= oEnd) return
|
||||
val ls = (oStart - sideF0) / sideLen
|
||||
val le = (oEnd - sideF0) / sideLen
|
||||
val px0 = x0 + (x1 - x0) * ls; val py0 = y0 + (y1 - y0) * ls
|
||||
val px1 = x0 + (x1 - x0) * le; val py1 = y0 + (y1 - y0) * le
|
||||
paint.shader = LinearGradient(px0, py0, px1, py1, colorAt(oStart), colorAt(oEnd), Shader.TileMode.CLAMP)
|
||||
canvas.drawLine(px0, py0, px1, py1, paint)
|
||||
}
|
||||
|
||||
fun drawAllSides(arcStart: Float, arcEnd: Float) {
|
||||
drawSideSegment(0f, f1, 0f, 0f, wf, 0f, arcStart, arcEnd)
|
||||
drawSideSegment(f1, f2, wf, 0f, wf, hf, arcStart, arcEnd)
|
||||
drawSideSegment(f2, f3, wf, hf, 0f, hf, arcStart, arcEnd)
|
||||
drawSideSegment(f3, 1f, 0f, hf, 0f, 0f, arcStart, arcEnd)
|
||||
}
|
||||
|
||||
paint.color = Color.WHITE
|
||||
if (tailF0 <= head) {
|
||||
drawAllSides(tailF0, head)
|
||||
} else {
|
||||
drawAllSides(tailF0, 1f)
|
||||
drawAllSides(0f, head)
|
||||
}
|
||||
paint.shader = null
|
||||
}
|
||||
|
||||
private fun drawRipple(canvas: Canvas, alphaColor: Int) {
|
||||
paint.shader = null
|
||||
val origStrokeWidth = paint.strokeWidth
|
||||
val origMaskFilter = paint.maskFilter
|
||||
|
||||
val maxInset = glowWidthPx * 3.5f
|
||||
paint.maskFilter = BlurMaskFilter(glowWidthPx * 0.6f, BlurMaskFilter.Blur.NORMAL)
|
||||
paint.strokeWidth = glowWidthPx * 0.65f
|
||||
|
||||
for (i in 0 until 3) {
|
||||
val phase = (flowProgress + i / 3f) % 1f
|
||||
val inset = phase * maxInset
|
||||
val alpha = (Color.alpha(alphaColor) * (1f - phase)).toInt().coerceIn(0, 255)
|
||||
paint.color = ColorUtils.setAlphaComponent(alphaColor, alpha)
|
||||
rect.set(inset, inset, width - inset, height - inset)
|
||||
if (rect.width() > 0 && rect.height() > 0) canvas.drawRect(rect, paint)
|
||||
}
|
||||
|
||||
paint.strokeWidth = origStrokeWidth
|
||||
paint.maskFilter = origMaskFilter
|
||||
}
|
||||
|
||||
private fun multicolorShader(
|
||||
length: Float,
|
||||
horizontal: Boolean,
|
||||
colors: IntArray,
|
||||
phase: Float,
|
||||
): LinearGradient {
|
||||
val gradientLength = length * 0.72f
|
||||
val shader = if (horizontal) {
|
||||
LinearGradient(0f, 0f, gradientLength, 0f, colors, null, Shader.TileMode.REPEAT)
|
||||
} else {
|
||||
LinearGradient(0f, 0f, 0f, gradientLength, colors, null, Shader.TileMode.REPEAT)
|
||||
}
|
||||
val normalizedPhase = ((phase % 1f) + 1f) % 1f
|
||||
shaderMatrix.reset()
|
||||
val offset = gradientLength * 3f * normalizedPhase
|
||||
if (horizontal) {
|
||||
shaderMatrix.setTranslate(offset, 0f)
|
||||
} else {
|
||||
shaderMatrix.setTranslate(0f, offset)
|
||||
}
|
||||
shader.setLocalMatrix(shaderMatrix)
|
||||
return shader
|
||||
}
|
||||
}
|
||||
47
app/src/main/java/com/fan/edgex/overlay/OverlayTheme.kt
Normal file
47
app/src/main/java/com/fan/edgex/overlay/OverlayTheme.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.graphics.Color
|
||||
import androidx.core.graphics.toColorInt
|
||||
|
||||
/**
|
||||
* Shared visual tokens for all system overlay windows (DrawerWindow,
|
||||
* PanelOverlayWindow sidebar, PanelOverlayWindow custom panel).
|
||||
* Centralising these keeps the three surfaces visually consistent and
|
||||
* makes future theme changes a single-file edit.
|
||||
*/
|
||||
internal object OverlayTheme {
|
||||
|
||||
// ── Surface ──────────────────────────────────────────────────────────
|
||||
val SURFACE_BG_DARK = Color.argb(242, 48, 54, 68) // original sidebar/panel value
|
||||
val SURFACE_BG_LIGHT = Color.argb(238, 250, 248, 255)
|
||||
|
||||
// ── Item card (DrawerWindow app grid) ─────────────────────────────────
|
||||
val CARD_BG_DARK = Color.argb(160, 64, 68, 88) // lighter than surface for contrast
|
||||
val CARD_BG_LIGHT = Color.argb(170, 230, 226, 244)
|
||||
|
||||
// ── Text ──────────────────────────────────────────────────────────────
|
||||
val TEXT_PRIMARY_DARK = Color.WHITE
|
||||
val TEXT_PRIMARY_LIGHT get() = "#1C1B1F".toColorInt()
|
||||
val TEXT_SECONDARY_DARK get() = "#9A97AA".toColorInt()
|
||||
val TEXT_SECONDARY_LIGHT get() = "#6B6880".toColorInt()
|
||||
|
||||
// ── Divider ───────────────────────────────────────────────────────────
|
||||
val DIVIDER_DARK = Color.argb(35, 255, 255, 255)
|
||||
val DIVIDER_LIGHT = Color.argb(40, 0, 0, 0)
|
||||
|
||||
// ── Frozen-app badge (DrawerWindow) ───────────────────────────────────
|
||||
val FROZEN_BADGE_BG = Color.argb(210, 12, 18, 52)
|
||||
|
||||
// ── Corner radii (dp — multiply by displayMetrics.density) ───────────
|
||||
const val CORNER_SHEET_DP = 20f // full-height DrawerWindow panel
|
||||
const val CORNER_POPUP_DP = 18f // custom panel popup
|
||||
const val CORNER_BAR_DP = 10f // sidebar bar
|
||||
|
||||
// ── Elevation (dp) ────────────────────────────────────────────────────
|
||||
const val ELEVATION_DP = 18f
|
||||
|
||||
// ── Dim amounts ───────────────────────────────────────────────────────
|
||||
const val DIM_DRAWER = 0.25f
|
||||
const val DIM_PANEL = 0.18f
|
||||
const val DIM_SIDEBAR = 0f
|
||||
}
|
||||
505
app/src/main/java/com/fan/edgex/overlay/PanelOverlayManager.kt
Normal file
505
app/src/main/java/com/fan/edgex/overlay/PanelOverlayManager.kt
Normal file
@@ -0,0 +1,505 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.ThemeColorResolver
|
||||
import com.fan.edgex.hook.ModuleRes
|
||||
|
||||
object PanelOverlayManager {
|
||||
private var activeWindow: PanelOverlayWindow? = null
|
||||
|
||||
fun showCustomPanel(
|
||||
context: Context,
|
||||
resolveConfig: (String) -> String,
|
||||
dispatchAction: (String) -> Unit,
|
||||
) {
|
||||
show(context, resolveConfig, dispatchAction, PanelMode.Custom)
|
||||
}
|
||||
|
||||
fun showSideBar(
|
||||
context: Context,
|
||||
resolveConfig: (String) -> String,
|
||||
side: String,
|
||||
dispatchAction: (String) -> Unit,
|
||||
) {
|
||||
show(context, resolveConfig, dispatchAction, PanelMode.SideBar(side))
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
activeWindow?.forceDismiss()
|
||||
activeWindow = null
|
||||
}
|
||||
|
||||
private fun show(
|
||||
context: Context,
|
||||
resolveConfig: (String) -> String,
|
||||
dispatchAction: (String) -> Unit,
|
||||
mode: PanelMode,
|
||||
) {
|
||||
if (activeWindow?.isShowing() == true) return
|
||||
val window = PanelOverlayWindow(context, resolveConfig, dispatchAction, mode) {
|
||||
activeWindow = null
|
||||
}
|
||||
activeWindow = window
|
||||
window.show()
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PanelMode {
|
||||
object Custom : PanelMode()
|
||||
data class SideBar(val side: String) : PanelMode()
|
||||
}
|
||||
|
||||
private class PanelOverlayWindow(
|
||||
private val context: Context,
|
||||
private val resolveConfig: (String) -> String,
|
||||
private val dispatchAction: (String) -> Unit,
|
||||
private val mode: PanelMode,
|
||||
private val onDismiss: () -> Unit,
|
||||
) {
|
||||
private data class PanelItem(val action: String, val title: String)
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var rootView: FrameLayout? = null
|
||||
private var panelView: View? = null
|
||||
private val dp = context.resources.displayMetrics.density
|
||||
|
||||
fun isShowing(): Boolean = rootView != null
|
||||
|
||||
fun show() {
|
||||
if (rootView != null) return
|
||||
val items = loadItems()
|
||||
if (items.isEmpty()) return
|
||||
|
||||
rootView = object : FrameLayout(context) {
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
if (ev.action == MotionEvent.ACTION_DOWN && isOutsidePanel(ev.rawX, ev.rawY)) {
|
||||
animateOut()
|
||||
return true
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
|
||||
animateOut()
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}.apply {
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
val panel = when (mode) {
|
||||
PanelMode.Custom -> buildCustomPanel(items)
|
||||
is PanelMode.SideBar -> buildSideBar(items, mode.side)
|
||||
}
|
||||
panelView = panel
|
||||
rootView?.addView(panel)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val params = WindowManager.LayoutParams().apply {
|
||||
type = WindowManager.LayoutParams.TYPE_SYSTEM_ERROR
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
flags = WindowManager.LayoutParams.FLAG_DIM_BEHIND
|
||||
dimAmount = if (mode is PanelMode.SideBar) OverlayTheme.DIM_SIDEBAR else OverlayTheme.DIM_PANEL
|
||||
}
|
||||
|
||||
try {
|
||||
windowManager.addView(rootView, params)
|
||||
animateIn(panel)
|
||||
} catch (t: Throwable) {
|
||||
rootView = null
|
||||
de.robv.android.xposed.XposedBridge.log("EdgeX: PanelOverlay addView failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun forceDismiss() {
|
||||
val root = rootView ?: return
|
||||
try {
|
||||
windowManager.removeView(root)
|
||||
} catch (_: Throwable) {
|
||||
} finally {
|
||||
rootView = null
|
||||
panelView = null
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateIn(panel: View) {
|
||||
when (val currentMode = mode) {
|
||||
PanelMode.Custom -> {
|
||||
panel.alpha = 0f
|
||||
panel.scaleX = 0.94f
|
||||
panel.scaleY = 0.94f
|
||||
panel.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(140).start()
|
||||
}
|
||||
is PanelMode.SideBar -> {
|
||||
// measuredWidth is 0 before layout; use screen width to guarantee
|
||||
// the panel is off-screen on the very first draw frame.
|
||||
val screenWidth = (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
||||
.currentWindowMetrics.bounds.width().toFloat()
|
||||
panel.translationX = if (currentMode.side == "left") -screenWidth else screenWidth
|
||||
panel.post {
|
||||
val start = if (currentMode.side == "left") -panel.width.toFloat() else panel.width.toFloat()
|
||||
ValueAnimator.ofFloat(start, 0f).apply {
|
||||
duration = 180
|
||||
interpolator = DecelerateInterpolator(1.8f)
|
||||
addUpdateListener { panel.translationX = it.animatedValue as Float }
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateOut() {
|
||||
val panel = panelView ?: return forceDismiss()
|
||||
when (val currentMode = mode) {
|
||||
PanelMode.Custom -> panel.animate().alpha(0f).scaleX(0.94f).scaleY(0.94f)
|
||||
.setDuration(110).withEndAction { forceDismiss() }.start()
|
||||
is PanelMode.SideBar -> {
|
||||
val end = if (currentMode.side == "left") -panel.width.toFloat() else panel.width.toFloat()
|
||||
panel.animate().translationX(end).alpha(0f).setDuration(140)
|
||||
.withEndAction { forceDismiss() }.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOutsidePanel(x: Float, y: Float): Boolean {
|
||||
val panel = panelView ?: return false
|
||||
val loc = IntArray(2)
|
||||
panel.getLocationOnScreen(loc)
|
||||
return x < loc[0] || x > loc[0] + panel.width || y < loc[1] || y > loc[1] + panel.height
|
||||
}
|
||||
|
||||
private fun loadItems(): List<PanelItem> {
|
||||
return when (val currentMode = mode) {
|
||||
PanelMode.Custom -> {
|
||||
val items = mutableListOf<PanelItem>()
|
||||
repeat(AppConfig.CUSTOM_PANEL_ROWS) { row ->
|
||||
repeat(AppConfig.CUSTOM_PANEL_COLUMNS) { column ->
|
||||
AppConfig.customPanelSlot(row, column).toPanelItem()?.let(items::add)
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
is PanelMode.SideBar -> {
|
||||
(0 until AppConfig.SIDE_BAR_SLOTS).mapNotNull { index ->
|
||||
AppConfig.sideBarSlot(currentMode.side, index).toPanelItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toPanelItem(): PanelItem? {
|
||||
val action = resolveConfig(this)
|
||||
if (action.isBlank() || action == "none") return null
|
||||
val title = displayTitleForAction(action, resolveConfig("${this}_title"))
|
||||
return PanelItem(action, title)
|
||||
}
|
||||
|
||||
private fun buildCustomPanel(items: List<PanelItem>): View {
|
||||
val metrics = context.resources.displayMetrics
|
||||
val columns = 4
|
||||
val itemSize = (72 * dp).toInt()
|
||||
val gap = (10 * dp).toInt()
|
||||
val panelWidth = (columns * itemSize + (columns - 1) * gap + 28 * dp).toInt()
|
||||
.coerceAtMost((metrics.widthPixels * 0.92f).toInt())
|
||||
|
||||
val panel = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((14 * dp).toInt(), (16 * dp).toInt(), (14 * dp).toInt(), (16 * dp).toInt())
|
||||
background = roundedBg(panelBackgroundColor(), OverlayTheme.CORNER_POPUP_DP)
|
||||
elevation = OverlayTheme.ELEVATION_DP * dp
|
||||
}
|
||||
var row: LinearLayout? = null
|
||||
items.forEachIndexed { index, item ->
|
||||
if (index % columns == 0) {
|
||||
row = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
panel.addView(row, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
if (index > 0) topMargin = gap
|
||||
})
|
||||
}
|
||||
row?.addView(createActionButton(item, itemSize, showText = true), LinearLayout.LayoutParams(
|
||||
itemSize,
|
||||
itemSize,
|
||||
).apply {
|
||||
if (index % columns != 0) leftMargin = gap
|
||||
})
|
||||
}
|
||||
|
||||
return panel.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(panelWidth, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSideBar(items: List<PanelItem>, side: String): View {
|
||||
val width = (76 * dp).toInt()
|
||||
val panel = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding((10 * dp).toInt(), (16 * dp).toInt(), (10 * dp).toInt(), (16 * dp).toInt())
|
||||
background = roundedBg(panelBackgroundColor(), OverlayTheme.CORNER_BAR_DP)
|
||||
elevation = OverlayTheme.ELEVATION_DP * dp
|
||||
}
|
||||
items.forEachIndexed { index, item ->
|
||||
panel.addView(createActionButton(item, (56 * dp).toInt(), showText = false), LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(72 * dp).toInt(),
|
||||
).apply {
|
||||
if (index > 0) topMargin = (6 * dp).toInt()
|
||||
})
|
||||
}
|
||||
return panel.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
gravity = (if (side == "left") Gravity.START else Gravity.END) or Gravity.CENTER_VERTICAL
|
||||
leftMargin = if (side == "left") (8 * dp).toInt() else 0
|
||||
rightMargin = if (side == "right") (8 * dp).toInt() else 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createActionButton(item: PanelItem, size: Int, showText: Boolean): View {
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
background = roundedBg(Color.TRANSPARENT, 8f)
|
||||
setOnClickListener {
|
||||
animateOut()
|
||||
dispatchAction(item.action)
|
||||
}
|
||||
}
|
||||
val iconSize = when {
|
||||
item.action.usesAppIcon() && showText -> (42 * dp).toInt()
|
||||
item.action.usesAppIcon() -> (48 * dp).toInt()
|
||||
showText -> (30 * dp).toInt()
|
||||
else -> (34 * dp).toInt()
|
||||
}
|
||||
container.addView(ImageView(context).apply {
|
||||
val icon = drawableForAction(item.action)
|
||||
setImageDrawable(icon)
|
||||
if (!item.action.usesAppIcon()) {
|
||||
setColorFilter(Color.WHITE)
|
||||
}
|
||||
}, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
if (showText) {
|
||||
container.addView(TextView(context).apply {
|
||||
text = item.title
|
||||
textSize = 11f
|
||||
maxLines = 1
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
setTextColor(Color.WHITE)
|
||||
gravity = Gravity.CENTER
|
||||
}, LinearLayout.LayoutParams(size, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = (6 * dp).toInt()
|
||||
})
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
private fun roundedBg(color: Int, radiusDp: Float): GradientDrawable =
|
||||
GradientDrawable().apply {
|
||||
setColor(color)
|
||||
cornerRadius = radiusDp * dp
|
||||
}
|
||||
|
||||
private fun panelBackgroundColor(): Int = ThemeColorResolver.resolveConfiguredColor(
|
||||
configKey = when (mode) {
|
||||
PanelMode.Custom -> AppConfig.CUSTOM_PANEL_COLOR
|
||||
is PanelMode.SideBar -> if (mode.side == "left") {
|
||||
AppConfig.SIDE_BAR_LEFT_COLOR
|
||||
} else {
|
||||
AppConfig.SIDE_BAR_RIGHT_COLOR
|
||||
}
|
||||
},
|
||||
resolveConfig = resolveConfig,
|
||||
)
|
||||
|
||||
private fun shortTitle(action: String): String = when {
|
||||
action == "back" -> "Back"
|
||||
action == "home" -> "Home"
|
||||
action == "recent" || action == "recents" -> "Recents"
|
||||
action == "expand_notifications" -> "Notify"
|
||||
action == "clear_background" -> "Clear"
|
||||
action == "freezer_drawer" -> "Freezer"
|
||||
action == "refreeze" -> "Refreeze"
|
||||
action == "screenshot" -> "Shot"
|
||||
action == "clipboard" -> "Clipboard"
|
||||
action == "universal_copy" -> "Copy"
|
||||
action == "lock_screen" -> "Lock"
|
||||
action == "kill_app" -> "Kill"
|
||||
action == "prev_app" -> "Prev App"
|
||||
action == "next_app" -> "Next App"
|
||||
action == "brightness_up" -> "Bright+"
|
||||
action == "brightness_down" -> "Bright-"
|
||||
action == "volume_up" -> "Vol+"
|
||||
action == "volume_down" -> "Vol-"
|
||||
action == "toggle_flashlight" -> "Torch"
|
||||
action == "game_mode" -> "Game"
|
||||
action == AppConfig.PARTIAL_SCREENSHOT_ACTION -> "Crop Shot"
|
||||
action == "pie" -> "Pie"
|
||||
action == "sub_gesture" -> "SubGesture"
|
||||
action == "condition" -> "Condition"
|
||||
action == "toggle_wifi" -> "Wi-Fi"
|
||||
action == "toggle_mobile_data" -> "Data"
|
||||
action.startsWith("launch_app:") -> "App"
|
||||
action.startsWith("app_shortcut:") -> "Shortcut"
|
||||
action.startsWith("shell:") -> "Shell"
|
||||
action.startsWith("music_control:") -> "Music"
|
||||
action.startsWith("fast_scroll:") -> when (action.removePrefix("fast_scroll:")) {
|
||||
"to_top" -> "Scroll Up"
|
||||
"to_bottom" -> "Scroll Down"
|
||||
else -> "Scroll"
|
||||
}
|
||||
action.startsWith("multi_action:") -> "Multi"
|
||||
else -> action
|
||||
}
|
||||
|
||||
private fun drawableForAction(action: String): Drawable? {
|
||||
if (action.startsWith("launch_app:")) {
|
||||
val packageName = action.removePrefix("launch_app:")
|
||||
val appIcon = runCatching {
|
||||
context.packageManager.getApplicationIcon(packageName)
|
||||
}.getOrNull()
|
||||
if (appIcon != null) return appIcon.foregroundOrSelf()
|
||||
}
|
||||
if (action.startsWith("app_shortcut:")) {
|
||||
val packageName = action.removePrefix("app_shortcut:").substringBefore(":")
|
||||
val appIcon = runCatching {
|
||||
context.packageManager.getApplicationIcon(packageName)
|
||||
}.getOrNull()
|
||||
if (appIcon != null) return appIcon.foregroundOrSelf()
|
||||
}
|
||||
return ModuleRes.getDrawable(iconForAction(action))?.mutate()
|
||||
}
|
||||
|
||||
private fun displayTitleForAction(action: String, savedTitle: String): String {
|
||||
val noneStr = ModuleRes.getString(R.string.action_none)
|
||||
val title = if (savedTitle.isBlank() || savedTitle == "None" || savedTitle == "无" || savedTitle == noneStr) "" else savedTitle
|
||||
return when {
|
||||
action.startsWith("launch_app:") -> appLabel(action.removePrefix("launch_app:"))
|
||||
?: stripKnownPrefix(title, "App:", "App: ", "应用:", "应用:", "应用: ")
|
||||
?: shortTitle(action)
|
||||
action.startsWith("app_shortcut:") -> stripKnownPrefix(
|
||||
title,
|
||||
"Shortcut:",
|
||||
"Shortcut: ",
|
||||
"快捷方式:",
|
||||
"快捷方式: ",
|
||||
"快捷方式:",
|
||||
) ?: appLabel(action.removePrefix("app_shortcut:").substringBefore(":"))
|
||||
?: shortTitle(action)
|
||||
action.startsWith("shell:") -> shellCommandTitle(action, title)
|
||||
else -> title.ifBlank { shortTitle(action) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun appLabel(packageName: String): String? = runCatching {
|
||||
val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
|
||||
appInfo.loadLabel(context.packageManager).toString()
|
||||
}.getOrNull()
|
||||
|
||||
private fun shellCommandTitle(action: String, savedTitle: String): String {
|
||||
val saved = savedTitle.trim()
|
||||
if (saved.isNotBlank() && saved != "Shell" && saved != "Shell Command" && saved != "Shell 命令") {
|
||||
return saved
|
||||
}
|
||||
return action.removePrefix("shell:").split(":", limit = 2).getOrNull(1)?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: shortTitle(action)
|
||||
}
|
||||
|
||||
private fun stripKnownPrefix(value: String, vararg prefixes: String): String? {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
val match = prefixes.firstOrNull { trimmed.startsWith(it) }
|
||||
return (match?.let { trimmed.removePrefix(it).trim() } ?: trimmed).takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Drawable.foregroundOrSelf(): Drawable =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
|
||||
foreground?.mutate() ?: mutate()
|
||||
} else {
|
||||
mutate()
|
||||
}
|
||||
|
||||
private fun String.usesAppIcon(): Boolean =
|
||||
startsWith("launch_app:") || startsWith("app_shortcut:")
|
||||
|
||||
private fun iconForAction(action: String): Int = when {
|
||||
action == "back" -> R.drawable.ic_arrow_back
|
||||
action == "home" -> R.drawable.ic_home
|
||||
action == "recent" || action == "recents" -> R.drawable.ic_recents
|
||||
action == "expand_notifications" -> R.drawable.ic_notifications
|
||||
action.startsWith("shell:") -> R.drawable.ic_terminal
|
||||
action.startsWith("launch_app:") -> R.drawable.ic_launch_app
|
||||
action.startsWith("app_shortcut:") -> R.drawable.ic_app_shortcut
|
||||
action == "clear_background" -> R.drawable.ic_clear_recent
|
||||
action == "freezer_drawer" -> R.drawable.ic_freezer
|
||||
action == "refreeze" -> R.drawable.ic_refreeze
|
||||
action == "screenshot" -> R.drawable.ic_camera
|
||||
action == "clipboard" -> R.drawable.ic_paste
|
||||
action == "universal_copy" -> R.drawable.ic_content_copy
|
||||
action == "lock_screen" -> R.drawable.ic_power
|
||||
action == "kill_app" -> R.drawable.ic_kill_app
|
||||
action == "prev_app" -> R.drawable.ic_prev_app
|
||||
action == "next_app" -> R.drawable.ic_next_app
|
||||
action == "brightness_up" -> R.drawable.ic_brightness_up
|
||||
action == "brightness_down" -> R.drawable.ic_brightness_down
|
||||
action == "volume_up" -> R.drawable.ic_volume_up
|
||||
action == "volume_down" -> R.drawable.ic_volume_down
|
||||
action.startsWith("music_control:") -> R.drawable.ic_music
|
||||
action.startsWith("fast_scroll:") -> when (action.removePrefix("fast_scroll:")) {
|
||||
"to_top" -> R.drawable.ic_scroll_to_top
|
||||
"to_bottom" -> R.drawable.ic_scroll_to_bottom
|
||||
else -> R.drawable.ic_fast_scroll
|
||||
}
|
||||
action.startsWith("multi_action:") -> R.drawable.ic_multi_action
|
||||
action == "toggle_flashlight" -> R.drawable.ic_flashlight
|
||||
action == "toggle_wifi" -> R.drawable.ic_wifi
|
||||
action == "toggle_mobile_data" -> R.drawable.ic_mobile_data
|
||||
action == "game_mode" -> R.drawable.ic_game_mode
|
||||
action == AppConfig.PARTIAL_SCREENSHOT_ACTION -> R.drawable.ic_partial_screenshot
|
||||
action == "sub_gesture" -> R.drawable.ic_sub_gesture
|
||||
action == "pie" -> R.drawable.ic_pie_menu
|
||||
action == "condition" -> R.drawable.ic_condition
|
||||
action == AppConfig.CUSTOM_PANEL_ACTION -> R.drawable.ic_apps
|
||||
action == AppConfig.SIDE_BAR_LEFT_ACTION -> R.drawable.ic_side_bar_left
|
||||
action == AppConfig.SIDE_BAR_RIGHT_ACTION -> R.drawable.ic_side_bar_right
|
||||
else -> R.drawable.ic_action_dot
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/com/fan/edgex/overlay/PieManager.kt
Normal file
37
app/src/main/java/com/fan/edgex/overlay/PieManager.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.content.Context
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
object PieManager {
|
||||
private var activeWindow: PieWindow? = null
|
||||
|
||||
fun show(context: Context, anchorX: Float, anchorY: Float, edge: String, rings: List<PieView.Ring>, accentColor: Int, sizeScale: Float) {
|
||||
if (activeWindow?.isShowing() == true) return
|
||||
val window = PieWindow(context) { activeWindow = null }
|
||||
activeWindow = window
|
||||
window.show(anchorX, anchorY, edge, rings, accentColor, sizeScale)
|
||||
}
|
||||
|
||||
fun update(x: Float, y: Float) {
|
||||
activeWindow?.update(x, y)
|
||||
}
|
||||
|
||||
fun commit(): String? {
|
||||
val window = activeWindow ?: return null
|
||||
activeWindow = null
|
||||
return window.commit()
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
try {
|
||||
activeWindow?.let { w ->
|
||||
if (w.isShowing()) w.dismiss()
|
||||
activeWindow = null
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: PieManager.dismiss failed: ${t.message}")
|
||||
activeWindow = null
|
||||
}
|
||||
}
|
||||
}
|
||||
275
app/src/main/java/com/fan/edgex/overlay/PieView.kt
Normal file
275
app/src/main/java/com/fan/edgex/overlay/PieView.kt
Normal file
@@ -0,0 +1,275 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.TextPaint
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class PieView(context: Context) : View(context) {
|
||||
|
||||
data class Slot(val label: String, val action: String, val icon: Drawable? = null)
|
||||
data class Ring(val slots: List<Slot>)
|
||||
|
||||
private companion object {
|
||||
const val INNER_DEAD_ZONE_DP = 90f
|
||||
const val RING0_DRAW_OUTER = 154f // ring 0 drawn outer edge
|
||||
const val RING1_DRAW_INNER = 164f // ring 1 drawn inner edge (10dp gap)
|
||||
const val OUTER_LIMIT_DP = 250f
|
||||
const val FAN_ARC_DEG = 160f
|
||||
const val SECTOR_GAP_DEG = 1.5f
|
||||
const val ICON_SIZE_DP = 40f
|
||||
const val LABEL_TEXT_SIZE_SP = 12f
|
||||
const val HIT_RADIUS_SLOP_DP = 8f
|
||||
|
||||
const val ANGLE_START_RIGHT = 100f
|
||||
const val ANGLE_START_LEFT = -80f
|
||||
const val ANGLE_START_BOTTOM = 190f
|
||||
const val ANGLE_START_TOP = 10f
|
||||
|
||||
val COLOR_HIGHLIGHT_STROKE = Color.argb(210, 255, 255, 255)
|
||||
val COLOR_DIVIDER = Color.argb(235, 255, 245, 250)
|
||||
val COLOR_SHADOW = Color.argb(80, 0, 24, 48)
|
||||
val COLOR_DOT = Color.argb(230, 255, 255, 255)
|
||||
val COLOR_DOT_HALO = Color.argb(70, 255, 255, 255)
|
||||
}
|
||||
|
||||
var accentColor: Int = Color.rgb(2, 134, 180)
|
||||
set(value) { field = value; invalidate() }
|
||||
var sizeScale: Float = 1f
|
||||
set(value) {
|
||||
field = value.coerceIn(0.8f, 1.2f)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun colorNormal(ringIndex: Int): Int {
|
||||
val hsv = FloatArray(3)
|
||||
Color.colorToHSV(accentColor, hsv)
|
||||
if (ringIndex == 1) hsv[2] = (hsv[2] + 0.12f).coerceAtMost(1f)
|
||||
return Color.HSVToColor(Color.alpha(accentColor), hsv)
|
||||
}
|
||||
|
||||
private fun colorHighlight(): Int {
|
||||
val hsv = FloatArray(3)
|
||||
Color.colorToHSV(accentColor, hsv)
|
||||
hsv[2] = (hsv[2] * 0.65f).coerceAtLeast(0f)
|
||||
return Color.HSVToColor(Color.alpha(accentColor), hsv)
|
||||
}
|
||||
|
||||
var rings: List<Ring> = emptyList()
|
||||
set(value) { field = value; invalidate() }
|
||||
var anchorX: Float = 0f
|
||||
var anchorY: Float = 0f
|
||||
var edge: String = "right"
|
||||
var highlightedRing: Int = -1
|
||||
set(value) { if (field != value) { field = value; invalidate() } }
|
||||
var highlightedSlot: Int = -1
|
||||
set(value) { if (field != value) { field = value; invalidate() } }
|
||||
|
||||
private val sectorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
|
||||
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = COLOR_SHADOW
|
||||
setShadowLayer(8f, 0f, 3f, COLOR_SHADOW)
|
||||
}
|
||||
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
color = COLOR_DIVIDER
|
||||
}
|
||||
private val highlightStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
color = COLOR_HIGHLIGHT_STROKE
|
||||
}
|
||||
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = COLOR_DOT
|
||||
}
|
||||
private val dotHaloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = COLOR_DOT_HALO
|
||||
}
|
||||
private val labelPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
isFakeBoldText = true
|
||||
}
|
||||
private val path = Path()
|
||||
private val outerRect = RectF()
|
||||
private val innerRect = RectF()
|
||||
|
||||
private var animFraction = 0f
|
||||
private var animator: ValueAnimator? = null
|
||||
|
||||
init {
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
|
||||
fun isAnimationComplete() = animFraction >= 1.0f
|
||||
|
||||
fun animateIn() {
|
||||
animator?.cancel()
|
||||
animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 120
|
||||
addUpdateListener {
|
||||
animFraction = it.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(v: Float) = v * resources.displayMetrics.density
|
||||
private fun scaledDp(v: Float) = dp(v * sizeScale)
|
||||
|
||||
private fun sp(v: Float) =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, v, resources.displayMetrics)
|
||||
|
||||
private fun fanStartAngle() = when (edge) {
|
||||
"right" -> ANGLE_START_RIGHT
|
||||
"left" -> ANGLE_START_LEFT
|
||||
"bottom" -> ANGLE_START_BOTTOM
|
||||
"top" -> ANGLE_START_TOP
|
||||
else -> ANGLE_START_RIGHT
|
||||
}
|
||||
|
||||
// Drawing radii (with gap between rings)
|
||||
private fun ringDrawInnerR(ringIndex: Int) =
|
||||
if (ringIndex == 0) scaledDp(INNER_DEAD_ZONE_DP) else scaledDp(RING1_DRAW_INNER)
|
||||
private fun ringDrawOuterR(ringIndex: Int) =
|
||||
if (ringIndex == 0) scaledDp(RING0_DRAW_OUTER) else scaledDp(OUTER_LIMIT_DP)
|
||||
|
||||
private fun sectorStartAngle(slotIndex: Int, count: Int): Float =
|
||||
fanStartAngle() + slotIndex * (FAN_ARC_DEG / count) + SECTOR_GAP_DEG / 2f
|
||||
|
||||
private fun sectorSweep(count: Int): Float =
|
||||
(FAN_ARC_DEG / count) - SECTOR_GAP_DEG
|
||||
|
||||
fun hitTest(x: Float, y: Float): Pair<Int, Int>? {
|
||||
val dx = x - anchorX
|
||||
val dy = y - anchorY
|
||||
val distSq = dx * dx + dy * dy
|
||||
val dist = sqrt(distSq.toDouble()).toFloat()
|
||||
val ringIndex = hitRingIndex(dist) ?: return null
|
||||
val ring = rings.getOrNull(ringIndex) ?: return null
|
||||
val n = ring.slots.size
|
||||
if (n == 0) return null
|
||||
|
||||
val fingerAngle = normalize(Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat())
|
||||
val step = FAN_ARC_DEG / n
|
||||
|
||||
for (i in 0 until n) {
|
||||
val start = normalize(fanStartAngle() + i * step)
|
||||
if (isAngleInArc(fingerAngle, start, step)) {
|
||||
return Pair(ringIndex, i)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hitRingIndex(dist: Float): Int? {
|
||||
val candidates = rings.indices.filter { ringIndex ->
|
||||
rings[ringIndex].slots.isNotEmpty() &&
|
||||
dist >= ringDrawInnerR(ringIndex) - scaledDp(HIT_RADIUS_SLOP_DP) &&
|
||||
dist <= ringDrawOuterR(ringIndex) + scaledDp(HIT_RADIUS_SLOP_DP)
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
if (highlightedRing in candidates) return highlightedRing
|
||||
|
||||
return candidates.minByOrNull { ringIndex ->
|
||||
val inner = ringDrawInnerR(ringIndex)
|
||||
val outer = ringDrawOuterR(ringIndex)
|
||||
if (dist in inner..outer) 0f else minOf(kotlin.math.abs(dist - inner), kotlin.math.abs(dist - outer))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAngleInArc(angle: Float, start: Float, sweep: Float): Boolean {
|
||||
val end = normalize(start + sweep)
|
||||
return if (start <= end) angle in start..end
|
||||
else angle >= start || angle <= end
|
||||
}
|
||||
|
||||
private fun normalize(a: Float): Float = ((a % 360f) + 360f) % 360f
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (animFraction == 0f || rings.isEmpty()) return
|
||||
|
||||
val scale = animFraction
|
||||
val alpha = (255 * scale).toInt().coerceIn(0, 255)
|
||||
|
||||
dividerPaint.strokeWidth = dp(2f)
|
||||
dividerPaint.alpha = alpha
|
||||
highlightStrokePaint.strokeWidth = dp(1.5f)
|
||||
highlightStrokePaint.alpha = alpha
|
||||
shadowPaint.alpha = (95 * scale).toInt().coerceIn(0, 95)
|
||||
labelPaint.textSize = sp(LABEL_TEXT_SIZE_SP) * scale
|
||||
labelPaint.alpha = alpha
|
||||
|
||||
val iconHalf = (scaledDp(ICON_SIZE_DP) / 2f * scale).toInt()
|
||||
|
||||
rings.forEachIndexed { ringIndex, ring ->
|
||||
val n = ring.slots.size
|
||||
if (n == 0) return@forEachIndexed
|
||||
|
||||
val innerR = ringDrawInnerR(ringIndex) * scale
|
||||
val outerR = ringDrawOuterR(ringIndex) * scale
|
||||
|
||||
ring.slots.forEachIndexed { slotIndex, slot ->
|
||||
val startAngle = sectorStartAngle(slotIndex, n)
|
||||
val sweep = sectorSweep(n)
|
||||
val isHighlighted = (ringIndex == highlightedRing && slotIndex == highlightedSlot)
|
||||
|
||||
// Sector path
|
||||
path.reset()
|
||||
outerRect.set(anchorX - outerR, anchorY - outerR, anchorX + outerR, anchorY + outerR)
|
||||
innerRect.set(anchorX - innerR, anchorY - innerR, anchorX + innerR, anchorY + innerR)
|
||||
path.arcTo(outerRect, startAngle, sweep)
|
||||
path.arcTo(innerRect, startAngle + sweep, -sweep)
|
||||
path.close()
|
||||
|
||||
canvas.drawPath(path, shadowPaint)
|
||||
|
||||
sectorPaint.color = if (isHighlighted) colorHighlight() else colorNormal(ringIndex)
|
||||
sectorPaint.alpha = alpha
|
||||
canvas.drawPath(path, sectorPaint)
|
||||
|
||||
// Icon or label centered in sector
|
||||
val midRad = Math.toRadians((startAngle + sweep / 2f).toDouble())
|
||||
val midR = (innerR + outerR) / 2f
|
||||
val cx = (anchorX + midR * cos(midRad)).toInt()
|
||||
val cy = (anchorY + midR * sin(midRad)).toInt()
|
||||
|
||||
val icon = slot.icon
|
||||
if (icon != null) {
|
||||
icon.alpha = alpha
|
||||
icon.setBounds(cx - iconHalf, cy - iconHalf, cx + iconHalf, cy + iconHalf)
|
||||
icon.draw(canvas)
|
||||
} else if (slot.label.isNotBlank()) {
|
||||
val baseline = cy - (labelPaint.descent() + labelPaint.ascent()) / 2f
|
||||
canvas.drawText(slot.label.take(4), cx.toFloat(), baseline, labelPaint)
|
||||
}
|
||||
|
||||
if (isHighlighted) {
|
||||
canvas.drawPath(path, highlightStrokePaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center anchor dot
|
||||
dotHaloPaint.alpha = (95 * scale).toInt().coerceIn(0, 95)
|
||||
canvas.drawCircle(anchorX, anchorY, scaledDp(15f) * scale, dotHaloPaint)
|
||||
dotPaint.alpha = alpha
|
||||
canvas.drawCircle(anchorX, anchorY, scaledDp(4.5f) * scale, dotPaint)
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/com/fan/edgex/overlay/PieWindow.kt
Normal file
75
app/src/main/java/com/fan/edgex/overlay/PieWindow.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.fan.edgex.overlay
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.WindowManager
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
|
||||
class PieWindow(
|
||||
private val context: Context,
|
||||
private val onDismiss: () -> Unit,
|
||||
) {
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private val pieView = PieView(context)
|
||||
private var added = false
|
||||
|
||||
fun show(anchorX: Float, anchorY: Float, edge: String, rings: List<PieView.Ring>, accentColor: Int, sizeScale: Float) {
|
||||
if (added) return
|
||||
pieView.anchorX = anchorX
|
||||
pieView.anchorY = anchorY
|
||||
pieView.edge = edge
|
||||
pieView.rings = rings
|
||||
pieView.accentColor = accentColor
|
||||
pieView.sizeScale = sizeScale
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.TYPE_SYSTEM_ERROR,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
)
|
||||
|
||||
try {
|
||||
windowManager.addView(pieView, params)
|
||||
added = true
|
||||
pieView.animateIn()
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: PieWindow.show failed: ${t.message}")
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
fun update(x: Float, y: Float) {
|
||||
if (!added) return
|
||||
val hit = pieView.hitTest(x, y)
|
||||
pieView.highlightedRing = hit?.first ?: -1
|
||||
pieView.highlightedSlot = hit?.second ?: -1
|
||||
}
|
||||
|
||||
fun commit(): String? {
|
||||
val r = pieView.highlightedRing
|
||||
val s = pieView.highlightedSlot
|
||||
val selected = if (pieView.isAnimationComplete() && r >= 0 && s >= 0)
|
||||
pieView.rings.getOrNull(r)?.slots?.getOrNull(s)?.action
|
||||
else null
|
||||
dismiss()
|
||||
return selected
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
if (!added) return
|
||||
added = false
|
||||
try {
|
||||
windowManager.removeView(pieView)
|
||||
} catch (t: Throwable) {
|
||||
XposedBridge.log("EdgeX: PieWindow.dismiss failed: ${t.message}")
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
fun isShowing() = added
|
||||
}
|
||||
10
app/src/main/java/com/fan/edgex/premium/PremiumInstall.kt
Normal file
10
app/src/main/java/com/fan/edgex/premium/PremiumInstall.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.fan.edgex.premium
|
||||
|
||||
object PremiumInstall {
|
||||
const val DIR_PATH = "/data/system/edgex"
|
||||
const val DEX_PATH = "$DIR_PATH/premium.dex"
|
||||
const val META_PATH = "$DIR_PATH/premium.meta"
|
||||
// Legacy path from before Keystore binding; cleaned up on deactivation.
|
||||
const val LEGACY_DEVICE_ID_PATH = "$DIR_PATH/device_id"
|
||||
const val SUPPORTED_API_VERSION = 2
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.fan.edgex.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.graphics.toColorInt
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.HookConfigSnapshot
|
||||
import com.fan.edgex.config.getConfigBool
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.utils.Xlog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
|
||||
class NotificationEdgeService : NotificationListenerService() {
|
||||
|
||||
// Cache extracted icon colors per package; null means no colorful color was found
|
||||
private val iconColorCache = HashMap<String, Int?>()
|
||||
|
||||
private val lifecycleManager = NotificationLifecycleManager()
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// Repeating pulse for incoming calls (CATEGORY_CALL).
|
||||
// The system only calls onNotificationPosted once per call, so we drive repetition ourselves.
|
||||
// Pulse fires every RINGING_PULSE_INTERVAL_MS, restarting the Edge Lighting animation.
|
||||
// Stopped when the call notification is removed (answered / rejected / ended).
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var ringingKey: String? = null
|
||||
private var drivingNotificationKey: String? = null
|
||||
private var ringingColor: Int = 0
|
||||
private val ringingPulse = object : Runnable {
|
||||
override fun run() {
|
||||
val key = ringingKey ?: return
|
||||
fireLighting(key, ringingColor, CALL_DURATION_MS.toInt())
|
||||
handler.postDelayed(this, RINGING_PULSE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification, rankingMap: RankingMap) {
|
||||
if (!getConfigBool(AppConfig.EDGE_LIGHTING_ENABLED, true)) return
|
||||
if (!isScreenInteractive()) return
|
||||
if (!isAllowedPackage(sbn.packageName)) return
|
||||
|
||||
val ranking = Ranking()
|
||||
val hasRanking = rankingMap.getRanking(sbn.key, ranking)
|
||||
val alertInfo = if (hasRanking) {
|
||||
val hiddenEffects = NotificationManager.Policy.SUPPRESSED_EFFECT_PEEK
|
||||
val matchesFilter = ranking.matchesInterruptionFilter()
|
||||
val hasHighImportance = ranking.importance >= NotificationManager.IMPORTANCE_HIGH
|
||||
val isNotSuspended = !ranking.isSuspended
|
||||
val isNotSuppressed = (ranking.suppressedVisualEffects and hiddenEffects) == 0
|
||||
|
||||
Xlog.d(TAG, "onNotificationPosted debug: key=${sbn.key}, matchesFilter=$matchesFilter, importance=${ranking.importance}, isNotSuspended=$isNotSuspended, isNotSuppressed=$isNotSuppressed")
|
||||
|
||||
matchesFilter && hasHighImportance && isNotSuspended && isNotSuppressed
|
||||
} else {
|
||||
Xlog.d(TAG, "onNotificationPosted debug: key=${sbn.key} has no ranking in map")
|
||||
false
|
||||
}
|
||||
|
||||
val isGroupSummary = (sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0
|
||||
val lastAudiblyAlerted = if (hasRanking) ranking.lastAudiblyAlertedMillis else 0L
|
||||
|
||||
val decision = lifecycleManager.onNotificationPosted(
|
||||
key = sbn.key,
|
||||
isOngoing = sbn.isOngoing,
|
||||
isGroupSummary = isGroupSummary,
|
||||
groupAlertBehavior = sbn.notification.groupAlertBehavior,
|
||||
postTime = sbn.postTime,
|
||||
lastAudiblyAlertedMillis = lastAudiblyAlerted,
|
||||
shouldAlert = alertInfo
|
||||
)
|
||||
|
||||
Xlog.d(TAG, "onNotificationPosted: key=${sbn.key}, pkg=${sbn.packageName}, isOngoing=${sbn.isOngoing}, " +
|
||||
"isGroupSummary=$isGroupSummary, groupAlertBehavior=${sbn.notification.groupAlertBehavior}, " +
|
||||
"postTime=${sbn.postTime}, lastAudiblyAlerted=$lastAudiblyAlerted, shouldAlert=$alertInfo, " +
|
||||
"decision=$decision")
|
||||
|
||||
if (decision == NotificationLifecycleManager.Decision.IGNORE) return
|
||||
|
||||
val isCall = sbn.notification.category == Notification.CATEGORY_CALL
|
||||
|
||||
// Resolve color asynchronously to avoid blocking main thread with icon extraction
|
||||
serviceScope.launch {
|
||||
val color = resolveColor(sbn)
|
||||
if (isCall) {
|
||||
if (ringingKey != sbn.key) handler.removeCallbacks(ringingPulse)
|
||||
ringingKey = sbn.key
|
||||
ringingColor = color
|
||||
handler.removeCallbacks(ringingPulse)
|
||||
handler.post(ringingPulse)
|
||||
} else {
|
||||
if (sbn.key == ringingKey) stopRingingPulse()
|
||||
fireLighting(sbn.key, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification, rankingMap: RankingMap, reason: Int) {
|
||||
handleNotificationRemoved(sbn, reason)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||
handleNotificationRemoved(sbn, 2) // Default to REASON_CANCEL (2) if reason is unknown
|
||||
}
|
||||
|
||||
private fun handleNotificationRemoved(sbn: StatusBarNotification, reason: Int) {
|
||||
Xlog.d(TAG, "handleNotificationRemoved: key=${sbn.key}, reason=$reason, isOngoing=${sbn.isOngoing}")
|
||||
lifecycleManager.onNotificationRemoved(sbn.key, reason)
|
||||
if (sbn.key == ringingKey) stopRingingPulse()
|
||||
dismissIfDriving(sbn.key)
|
||||
}
|
||||
|
||||
override fun onNotificationRankingUpdate(rankingMap: RankingMap) {
|
||||
super.onNotificationRankingUpdate(rankingMap)
|
||||
val key = drivingNotificationKey ?: return
|
||||
if (!shouldAlert(key, rankingMap)) {
|
||||
if (key == ringingKey) stopRingingPulse()
|
||||
dismissIfDriving(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
Xlog.d(TAG, "onListenerConnected")
|
||||
runCatching {
|
||||
activeNotifications?.forEach { sbn ->
|
||||
lifecycleManager.prewarmNotification(sbn.key, sbn.postTime)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Xlog.e(TAG, "Failed to prewarm active notifications", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
super.onListenerDisconnected()
|
||||
Xlog.d(TAG, "onListenerDisconnected")
|
||||
iconColorCache.clear()
|
||||
stopRingingPulse()
|
||||
drivingNotificationKey?.let(::dismissIfDriving)
|
||||
}
|
||||
|
||||
private fun stopRingingPulse() {
|
||||
handler.removeCallbacks(ringingPulse)
|
||||
ringingKey = null
|
||||
}
|
||||
|
||||
private fun fireLighting(
|
||||
notificationKey: String,
|
||||
color: Int,
|
||||
durationMs: Int = getConfigString(AppConfig.EDGE_LIGHTING_DURATION_MS, "3000").toIntOrNull() ?: 3000,
|
||||
) {
|
||||
drivingNotificationKey = notificationKey
|
||||
sendBroadcast(Intent(HookConfigSnapshot.ACTION_EDGE_LIGHTING).apply {
|
||||
putExtra(HookConfigSnapshot.EXTRA_EDGE_LIGHTING_NOTIFICATION_KEY, notificationKey)
|
||||
putExtra(HookConfigSnapshot.EXTRA_EDGE_LIGHTING_COLOR, color)
|
||||
putExtra(HookConfigSnapshot.EXTRA_EDGE_LIGHTING_DURATION_MS, durationMs)
|
||||
})
|
||||
}
|
||||
|
||||
private fun dismissIfDriving(notificationKey: String) {
|
||||
if (drivingNotificationKey != notificationKey) return
|
||||
drivingNotificationKey = null
|
||||
sendBroadcast(Intent(HookConfigSnapshot.ACTION_EDGE_LIGHTING_DISMISS).apply {
|
||||
putExtra(HookConfigSnapshot.EXTRA_EDGE_LIGHTING_NOTIFICATION_KEY, notificationKey)
|
||||
})
|
||||
}
|
||||
|
||||
private fun shouldAlert(notificationKey: String, rankingMap: RankingMap): Boolean {
|
||||
val ranking = Ranking()
|
||||
return runCatching {
|
||||
if (!rankingMap.getRanking(notificationKey, ranking)) return@runCatching false
|
||||
val hiddenEffects = NotificationManager.Policy.SUPPRESSED_EFFECT_PEEK
|
||||
ranking.matchesInterruptionFilter() &&
|
||||
ranking.importance >= NotificationManager.IMPORTANCE_HIGH &&
|
||||
!ranking.isSuspended &&
|
||||
(ranking.suppressedVisualEffects and hiddenEffects) == 0
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private suspend fun resolveColor(sbn: StatusBarNotification): Int {
|
||||
val fallbackColor = parseColor(getConfigString(AppConfig.EDGE_LIGHTING_COLOR, DEFAULT_COLOR))
|
||||
return if (getConfigBool(AppConfig.EDGE_LIGHTING_AUTO_COLOR, true)) {
|
||||
val rawColor = sbn.notification.color
|
||||
if (rawColor != Notification.COLOR_DEFAULT) {
|
||||
// Force full alpha: some apps omit the alpha byte (0x07C160 instead of 0xFF07C160),
|
||||
// which makes the color transparent and the edge lighting invisible.
|
||||
rawColor or (0xFF shl 24)
|
||||
} else {
|
||||
// App didn't call setColor() — extract a representative color from its launcher icon.
|
||||
// Check cache first (synchronous, no I/O), otherwise extract on background thread.
|
||||
val cached = iconColorCache[sbn.packageName]
|
||||
if (cached != null) {
|
||||
cached ?: fallbackColor
|
||||
} else {
|
||||
val color = withContext(Dispatchers.Default) {
|
||||
extractIconColor(sbn.packageName)
|
||||
}
|
||||
iconColorCache[sbn.packageName] = color
|
||||
color ?: fallbackColor
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallbackColor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the app's launcher icon to a small bitmap and returns the most common
|
||||
* non-transparent, non-near-white, non-near-black pixel color.
|
||||
* Returns null if the icon has no suitable colorful pixels.
|
||||
*/
|
||||
private fun extractIconColor(packageName: String): Int? {
|
||||
return try {
|
||||
val drawable = packageManager.getApplicationIcon(packageName)
|
||||
val size = 64
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
drawable.setBounds(0, 0, size, size)
|
||||
drawable.draw(Canvas(bitmap))
|
||||
|
||||
val pixels = IntArray(size * size)
|
||||
bitmap.getPixels(pixels, 0, size, 0, 0, size, size)
|
||||
bitmap.recycle()
|
||||
|
||||
// Quantize to 8 levels per channel and count occurrences
|
||||
val counts = HashMap<Int, Int>()
|
||||
for (pixel in pixels) {
|
||||
if (Color.alpha(pixel) < 128) continue
|
||||
val r = Color.red(pixel)
|
||||
val g = Color.green(pixel)
|
||||
val b = Color.blue(pixel)
|
||||
if (r > 220 && g > 220 && b > 220) continue // near-white
|
||||
if (r < 40 && g < 40 && b < 40) continue // near-black
|
||||
val q = Color.rgb((r / 32) * 32, (g / 32) * 32, (b / 32) * 32)
|
||||
counts[q] = (counts[q] ?: 0) + 1
|
||||
}
|
||||
|
||||
counts.maxByOrNull { it.value }?.key?.let { it or (0xFF shl 24) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenInteractive(): Boolean {
|
||||
val powerManager = getSystemService(PowerManager::class.java)
|
||||
return powerManager?.isInteractive == true
|
||||
}
|
||||
|
||||
private fun isAllowedPackage(packageName: String): Boolean {
|
||||
val selected = parsePackageList(getConfigString(AppConfig.EDGE_LIGHTING_APP_LIST))
|
||||
return selected.isEmpty() || packageName in selected
|
||||
}
|
||||
|
||||
private fun parsePackageList(value: String): Set<String> {
|
||||
if (value.isBlank()) return emptySet()
|
||||
return runCatching {
|
||||
val array = JSONArray(value)
|
||||
buildSet {
|
||||
for (index in 0 until array.length()) {
|
||||
val packageName = array.optString(index).trim()
|
||||
if (packageName.isNotEmpty()) add(packageName)
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
value.split(",").mapNotNullTo(mutableSetOf()) { it.trim().takeIf(String::isNotEmpty) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseColor(value: String): Int =
|
||||
runCatching { value.toColorInt() }.getOrElse { DEFAULT_COLOR.toColorInt() }
|
||||
|
||||
private companion object {
|
||||
const val TAG = "NotificationEdgeService"
|
||||
const val DEFAULT_COLOR = "#00FFFF"
|
||||
// Each ringing pulse sends a 30-second animation so directional effects (comet, flow, etc.)
|
||||
// run continuously without restarting. 29 s interval ensures a fresh pulse fires before
|
||||
// the current animation fades out, acting as a fallback for calls that ring unusually long.
|
||||
const val CALL_DURATION_MS = 30_000L
|
||||
const val RINGING_PULSE_INTERVAL_MS = 29_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.fan.edgex.service
|
||||
|
||||
interface Clock {
|
||||
fun currentTimeMillis(): Long
|
||||
}
|
||||
|
||||
class SystemTimeClock : Clock {
|
||||
override fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
data class NotificationTriggerState(
|
||||
val lastTriggeredAt: Long,
|
||||
val lastPostTime: Long,
|
||||
val removedAt: Long? = null,
|
||||
val removalReason: Int? = null,
|
||||
)
|
||||
|
||||
class NotificationLifecycleManager(private val clock: Clock = SystemTimeClock()) {
|
||||
|
||||
companion object {
|
||||
private val seenNotifications = HashMap<String, NotificationTriggerState>()
|
||||
|
||||
const val REBUILD_THRESHOLD_MS = 10_000L
|
||||
const val CLEANUP_THRESHOLD_MS = 60_000L
|
||||
|
||||
fun clearState() {
|
||||
seenNotifications.clear()
|
||||
}
|
||||
|
||||
fun getTriggerState(key: String): NotificationTriggerState? {
|
||||
return seenNotifications[key]
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupExpired() {
|
||||
val now = clock.currentTimeMillis()
|
||||
val iterator = seenNotifications.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
val removedAt = entry.value.removedAt
|
||||
if (removedAt != null && (now - removedAt) > CLEANUP_THRESHOLD_MS) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Decision {
|
||||
TRIGGER,
|
||||
IGNORE
|
||||
}
|
||||
|
||||
fun onNotificationPosted(
|
||||
key: String,
|
||||
isOngoing: Boolean,
|
||||
isGroupSummary: Boolean,
|
||||
groupAlertBehavior: Int,
|
||||
postTime: Long,
|
||||
lastAudiblyAlertedMillis: Long,
|
||||
shouldAlert: Boolean
|
||||
): Decision {
|
||||
cleanupExpired()
|
||||
|
||||
if (!shouldAlert) return Decision.IGNORE
|
||||
|
||||
// Stage 2: Group Summary Filter
|
||||
if (isGroupSummary) {
|
||||
// Notification.GROUP_ALERT_SUMMARY is constant value 1
|
||||
if (groupAlertBehavior != 1) {
|
||||
return Decision.IGNORE
|
||||
}
|
||||
}
|
||||
|
||||
val now = clock.currentTimeMillis()
|
||||
val state = seenNotifications[key]
|
||||
|
||||
if (state == null) {
|
||||
seenNotifications[key] = NotificationTriggerState(
|
||||
lastTriggeredAt = now,
|
||||
lastPostTime = postTime
|
||||
)
|
||||
return Decision.TRIGGER
|
||||
}
|
||||
|
||||
val removedAt = state.removedAt
|
||||
if (removedAt != null) {
|
||||
val durationSinceRemoval = now - removedAt
|
||||
if (durationSinceRemoval < REBUILD_THRESHOLD_MS) {
|
||||
// User actions are:
|
||||
// REASON_CLICK = 1
|
||||
// REASON_CANCEL = 2 (user swiped away, or tapped clear all, or clicked to dismiss)
|
||||
// REASON_CANCEL_ALL = 3 (user cleared all)
|
||||
val isUserAction = when (state.removalReason) {
|
||||
1, 2, 3 -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
val newAlert = lastAudiblyAlertedMillis > state.lastTriggeredAt
|
||||
|
||||
if (newAlert || (!isOngoing && isUserAction)) {
|
||||
seenNotifications[key] = NotificationTriggerState(
|
||||
lastTriggeredAt = now,
|
||||
lastPostTime = postTime
|
||||
)
|
||||
return Decision.TRIGGER
|
||||
} else {
|
||||
// System rebuild or non-user removal without new alert -> ignore
|
||||
// Restore back to online (removedAt = null) so subsequent updates are handled
|
||||
seenNotifications[key] = state.copy(removedAt = null, removalReason = null)
|
||||
return Decision.IGNORE
|
||||
}
|
||||
} else {
|
||||
// Expired removal state -> treat as brand new notification
|
||||
seenNotifications[key] = NotificationTriggerState(
|
||||
lastTriggeredAt = now,
|
||||
lastPostTime = postTime
|
||||
)
|
||||
return Decision.TRIGGER
|
||||
}
|
||||
} else {
|
||||
// In-place update: once a notification is in the shade, subsequent updates
|
||||
// do not show a heads-up banner. Thus, we should not trigger Edge Lighting.
|
||||
return Decision.IGNORE
|
||||
}
|
||||
}
|
||||
|
||||
fun onNotificationRemoved(key: String, reason: Int) {
|
||||
cleanupExpired()
|
||||
val state = seenNotifications[key]
|
||||
if (state != null) {
|
||||
seenNotifications[key] = state.copy(
|
||||
removedAt = clock.currentTimeMillis(),
|
||||
removalReason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun prewarmNotification(key: String, postTime: Long) {
|
||||
val now = clock.currentTimeMillis()
|
||||
seenNotifications[key] = NotificationTriggerState(
|
||||
lastTriggeredAt = now,
|
||||
lastPostTime = postTime
|
||||
)
|
||||
}
|
||||
}
|
||||
339
app/src/main/java/com/fan/edgex/ui/ActionSelectionActivity.kt
Normal file
339
app/src/main/java/com/fan/edgex/ui/ActionSelectionActivity.kt
Normal file
@@ -0,0 +1,339 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
@Deprecated("Use Compose ActionSelectionSheet instead")
|
||||
class ActionSelectionActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_EXCLUDED_CODES = "excluded_codes"
|
||||
|
||||
fun actionIconRes(code: String): Int = when {
|
||||
code.isEmpty() || code == "none" -> R.drawable.ic_action_dot
|
||||
code == "back" -> R.drawable.ic_arrow_back
|
||||
code == "home" -> R.drawable.ic_home
|
||||
code == "recents" -> R.drawable.ic_recents
|
||||
code == "expand_notifications" -> R.drawable.ic_notifications
|
||||
code.startsWith("shell:") -> R.drawable.ic_terminal
|
||||
code == "sub_gesture" -> R.drawable.ic_sub_gesture
|
||||
code == "pie" -> R.drawable.ic_pie_menu
|
||||
code.startsWith("launch_app:") -> R.drawable.ic_launch_app
|
||||
code.startsWith("app_shortcut:") -> R.drawable.ic_app_shortcut
|
||||
code == "clear_background" -> R.drawable.ic_clear_recent
|
||||
code == "freezer_drawer" -> R.drawable.ic_freezer
|
||||
code == "refreeze" -> R.drawable.ic_refreeze
|
||||
code == "screenshot" -> R.drawable.ic_camera
|
||||
code == AppConfig.PARTIAL_SCREENSHOT_ACTION -> R.drawable.ic_partial_screenshot
|
||||
code == "clipboard" -> R.drawable.ic_paste
|
||||
code == "universal_copy" -> R.drawable.ic_content_copy
|
||||
code == "lock_screen" -> R.drawable.ic_power
|
||||
code == "kill_app" -> R.drawable.ic_kill_app
|
||||
code == "prev_app" -> R.drawable.ic_prev_app
|
||||
code == "next_app" -> R.drawable.ic_next_app
|
||||
code == "brightness_up" -> R.drawable.ic_brightness_up
|
||||
code == "brightness_down" -> R.drawable.ic_brightness_down
|
||||
code == "volume_up" -> R.drawable.ic_volume_up
|
||||
code == "volume_down" -> R.drawable.ic_volume_down
|
||||
code.startsWith("music_control:") -> R.drawable.ic_music
|
||||
code.startsWith("fast_scroll:") -> when (code.removePrefix("fast_scroll:")) {
|
||||
"to_top" -> R.drawable.ic_scroll_to_top
|
||||
"to_bottom" -> R.drawable.ic_scroll_to_bottom
|
||||
else -> R.drawable.ic_fast_scroll
|
||||
}
|
||||
code.startsWith("multi_action:") -> R.drawable.ic_multi_action
|
||||
code.startsWith("condition:") -> R.drawable.ic_condition
|
||||
code == AppConfig.CUSTOM_PANEL_ACTION -> R.drawable.ic_apps
|
||||
code == AppConfig.SIDE_BAR_LEFT_ACTION -> R.drawable.ic_side_bar_left
|
||||
code == AppConfig.SIDE_BAR_RIGHT_ACTION -> R.drawable.ic_side_bar_right
|
||||
code == "toggle_flashlight" -> R.drawable.ic_flashlight
|
||||
code == "toggle_wifi" -> R.drawable.ic_wifi
|
||||
code == "toggle_mobile_data" -> R.drawable.ic_mobile_data
|
||||
code == "game_mode" -> R.drawable.ic_game_mode
|
||||
else -> R.drawable.ic_action_dot
|
||||
}
|
||||
|
||||
fun applyActionIcon(context: Context, code: String, imageView: ImageView) {
|
||||
if (code.startsWith("launch_app:")) {
|
||||
val pkg = code.removePrefix("launch_app:")
|
||||
val icon = runCatching {
|
||||
context.packageManager.getApplicationIcon(pkg)
|
||||
}.getOrNull()
|
||||
if (icon != null) {
|
||||
imageView.setImageDrawable(icon)
|
||||
imageView.imageTintList = null
|
||||
return
|
||||
}
|
||||
}
|
||||
imageView.setImageResource(actionIconRes(code))
|
||||
imageView.imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
}
|
||||
|
||||
fun resolveActionLabel(context: Context, code: String, fallbackLabel: String): String {
|
||||
if (code.isEmpty() || code == "none") {
|
||||
return context.getString(R.string.action_none)
|
||||
}
|
||||
if (code.startsWith("music_control:")) {
|
||||
val subCode = code.removePrefix("music_control:")
|
||||
val resId = when (subCode) {
|
||||
"play_pause" -> R.string.action_music_play_pause
|
||||
"stop" -> R.string.action_music_stop
|
||||
"previous" -> R.string.action_music_previous
|
||||
"next" -> R.string.action_music_next
|
||||
else -> R.string.action_music_control
|
||||
}
|
||||
return context.getString(R.string.label_music_prefix, context.getString(resId))
|
||||
}
|
||||
if (code.startsWith("fast_scroll:")) {
|
||||
val subCode = code.removePrefix("fast_scroll:")
|
||||
val resId = when (subCode) {
|
||||
"to_top" -> R.string.action_scroll_to_top
|
||||
"to_bottom" -> R.string.action_scroll_to_bottom
|
||||
else -> R.string.action_fast_scroll
|
||||
}
|
||||
return context.getString(resId)
|
||||
}
|
||||
val resId = when (code) {
|
||||
"back" -> R.string.action_back
|
||||
"home" -> R.string.action_home
|
||||
"recents" -> R.string.action_recents
|
||||
"expand_notifications" -> R.string.action_expand_notifications
|
||||
"clear_background" -> R.string.action_clear_background
|
||||
"freezer_drawer" -> R.string.action_freezer_drawer
|
||||
"refreeze" -> R.string.action_refreeze
|
||||
"screenshot" -> R.string.action_screenshot
|
||||
AppConfig.PARTIAL_SCREENSHOT_ACTION -> R.string.action_partial_screenshot
|
||||
"clipboard" -> R.string.action_clipboard
|
||||
"universal_copy" -> R.string.action_universal_copy
|
||||
"lock_screen" -> R.string.action_lock_screen
|
||||
"kill_app" -> R.string.action_kill_app
|
||||
"prev_app" -> R.string.action_prev_app
|
||||
"next_app" -> R.string.action_next_app
|
||||
"brightness_up" -> R.string.action_brightness_up
|
||||
"brightness_down" -> R.string.action_brightness_down
|
||||
"volume_up" -> R.string.action_volume_up
|
||||
"volume_down" -> R.string.action_volume_down
|
||||
"toggle_flashlight" -> R.string.action_toggle_flashlight
|
||||
"toggle_wifi" -> R.string.action_toggle_wifi
|
||||
"toggle_mobile_data" -> R.string.action_toggle_mobile_data
|
||||
"game_mode" -> R.string.action_game_mode
|
||||
"sub_gesture" -> R.string.action_sub_gesture
|
||||
"pie" -> R.string.action_pie
|
||||
AppConfig.CUSTOM_PANEL_ACTION -> R.string.action_custom_panel
|
||||
AppConfig.SIDE_BAR_LEFT_ACTION -> R.string.action_left_side_bar
|
||||
AppConfig.SIDE_BAR_RIGHT_ACTION -> R.string.action_right_side_bar
|
||||
else -> 0
|
||||
}
|
||||
if (resId != 0) {
|
||||
return context.getString(resId)
|
||||
}
|
||||
return fallbackLabel
|
||||
}
|
||||
}
|
||||
|
||||
data class ActionItem(val label: String, val code: String, val iconRes: Int)
|
||||
|
||||
private fun actions(excludedCodes: Set<String>) = listOf(
|
||||
ActionItem(getString(R.string.action_none), "none", R.drawable.ic_action_dot),
|
||||
ActionItem(getString(R.string.action_back), "back", R.drawable.ic_arrow_back),
|
||||
ActionItem(getString(R.string.action_home), "home", R.drawable.ic_home),
|
||||
ActionItem(getString(R.string.action_recents), "recents", R.drawable.ic_recents),
|
||||
ActionItem(getString(R.string.action_expand_notifications), "expand_notifications", R.drawable.ic_notifications),
|
||||
ActionItem(getString(R.string.action_shell_command), "shell_command", R.drawable.ic_terminal),
|
||||
ActionItem(getString(R.string.action_sub_gesture), "sub_gesture", R.drawable.ic_sub_gesture),
|
||||
ActionItem(getString(R.string.action_pie), "pie", R.drawable.ic_pie_menu),
|
||||
ActionItem(getString(R.string.action_launch_app), "launch_app", R.drawable.ic_launch_app),
|
||||
ActionItem(getString(R.string.action_app_shortcut), "app_shortcut", R.drawable.ic_app_shortcut),
|
||||
ActionItem(getString(R.string.action_clear_background), "clear_background", R.drawable.ic_clear_recent),
|
||||
ActionItem(getString(R.string.action_freezer_drawer), "freezer_drawer", R.drawable.ic_freezer),
|
||||
ActionItem(getString(R.string.action_refreeze), "refreeze", R.drawable.ic_refreeze),
|
||||
ActionItem(getString(R.string.action_screenshot), "screenshot", R.drawable.ic_camera),
|
||||
ActionItem(getString(R.string.action_partial_screenshot), AppConfig.PARTIAL_SCREENSHOT_ACTION, R.drawable.ic_partial_screenshot),
|
||||
ActionItem(getString(R.string.action_clipboard), "clipboard", R.drawable.ic_paste),
|
||||
ActionItem(getString(R.string.action_universal_copy), "universal_copy", R.drawable.ic_content_copy),
|
||||
ActionItem(getString(R.string.action_lock_screen), "lock_screen", R.drawable.ic_power),
|
||||
ActionItem(getString(R.string.action_kill_app), "kill_app", R.drawable.ic_kill_app),
|
||||
ActionItem(getString(R.string.action_prev_app), "prev_app", R.drawable.ic_prev_app),
|
||||
ActionItem(getString(R.string.action_next_app), "next_app", R.drawable.ic_next_app),
|
||||
ActionItem(getString(R.string.action_brightness_up), "brightness_up", R.drawable.ic_brightness_up),
|
||||
ActionItem(getString(R.string.action_brightness_down), "brightness_down", R.drawable.ic_brightness_down),
|
||||
ActionItem(getString(R.string.action_volume_up), "volume_up", R.drawable.ic_volume_up),
|
||||
ActionItem(getString(R.string.action_volume_down), "volume_down", R.drawable.ic_volume_down),
|
||||
ActionItem(getString(R.string.action_music_control), "music_control", R.drawable.ic_music),
|
||||
ActionItem(getString(R.string.action_fast_scroll), "fast_scroll", R.drawable.ic_fast_scroll),
|
||||
ActionItem(getString(R.string.action_multi_action), "multi_action", R.drawable.ic_multi_action),
|
||||
ActionItem(getString(R.string.action_condition), "condition", R.drawable.ic_condition),
|
||||
ActionItem(getString(R.string.action_custom_panel), AppConfig.CUSTOM_PANEL_ACTION, R.drawable.ic_apps),
|
||||
ActionItem(getString(R.string.action_left_side_bar), AppConfig.SIDE_BAR_LEFT_ACTION, R.drawable.ic_side_bar_left),
|
||||
ActionItem(getString(R.string.action_right_side_bar), AppConfig.SIDE_BAR_RIGHT_ACTION, R.drawable.ic_side_bar_right),
|
||||
ActionItem(getString(R.string.action_toggle_flashlight), "toggle_flashlight", R.drawable.ic_flashlight),
|
||||
ActionItem(getString(R.string.action_toggle_wifi), "toggle_wifi", R.drawable.ic_wifi),
|
||||
ActionItem(getString(R.string.action_toggle_mobile_data), "toggle_mobile_data", R.drawable.ic_mobile_data),
|
||||
ActionItem(getString(R.string.action_game_mode), "game_mode", R.drawable.ic_game_mode),
|
||||
).filter { it.code !in excludedCodes }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_action_selection)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
// Header Insets
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
// Get Args
|
||||
val title = intent.getStringExtra("title") ?: getString(R.string.header_action_selection)
|
||||
val prefKey = intent.getStringExtra("pref_key") ?: "unknown"
|
||||
|
||||
findViewById<TextView>(R.id.tv_subtitle).text = title
|
||||
|
||||
// List
|
||||
val excludedCodes = (intent.getStringArrayExtra(EXTRA_EXCLUDED_CODES)?.toSet() ?: emptySet()).let { base ->
|
||||
if (prefKey.startsWith("pie_")) base + "pie" else base
|
||||
}
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
val adapter = ActionAdapter(actions(excludedCodes)) { item ->
|
||||
when (item.code) {
|
||||
"app_shortcut" -> {
|
||||
startActivity(Intent(this, ShortcutSelectionActivity::class.java)
|
||||
.putExtra("pref_key", prefKey))
|
||||
finish()
|
||||
}
|
||||
"shell_command" -> {
|
||||
startActivity(Intent(this, ShellCommandActivity::class.java)
|
||||
.putExtra("pref_key", prefKey))
|
||||
finish()
|
||||
}
|
||||
"sub_gesture" -> {
|
||||
putConfig(prefKey, "sub_gesture")
|
||||
putConfig("${prefKey}_label", getString(R.string.action_sub_gesture))
|
||||
startActivity(Intent(this, SubGestureActivity::class.java)
|
||||
.putExtra("pref_key", prefKey)
|
||||
.putExtra("title", title)
|
||||
.putExtra(EXTRA_EXCLUDED_CODES, intent.getStringArrayExtra(EXTRA_EXCLUDED_CODES)))
|
||||
finish()
|
||||
}
|
||||
"pie" -> {
|
||||
putConfig(prefKey, "pie")
|
||||
putConfig("${prefKey}_label", getString(R.string.action_pie))
|
||||
finish()
|
||||
}
|
||||
"launch_app" -> {
|
||||
startActivity(Intent(this, AppSelectionActivity::class.java)
|
||||
.putExtra("pref_key", prefKey))
|
||||
finish()
|
||||
}
|
||||
"music_control" -> {
|
||||
startActivity(Intent(this, MusicControlActivity::class.java)
|
||||
.putExtra("pref_key", prefKey))
|
||||
finish()
|
||||
}
|
||||
"fast_scroll" -> {
|
||||
startActivity(Intent(this, FastScrollActivity::class.java)
|
||||
.putExtra("pref_key", prefKey))
|
||||
finish()
|
||||
}
|
||||
"multi_action" -> {
|
||||
startActivity(Intent(this, MultiActionsListActivity::class.java)
|
||||
.putExtra(MultiActionsListActivity.EXTRA_MODE, MultiActionsListActivity.MODE_PICK)
|
||||
.putExtra(MultiActionsListActivity.EXTRA_PREF_KEY, prefKey)
|
||||
.putExtra(MultiActionsListActivity.EXTRA_TITLE, title))
|
||||
finish()
|
||||
}
|
||||
"condition" -> {
|
||||
startActivity(Intent(this, ConditionActionActivity::class.java)
|
||||
.putExtra("pref_key", prefKey)
|
||||
.putExtra("title", title)
|
||||
.putExtra(EXTRA_EXCLUDED_CODES, intent.getStringArrayExtra(EXTRA_EXCLUDED_CODES)))
|
||||
finish()
|
||||
}
|
||||
else -> {
|
||||
putConfig(prefKey, item.code)
|
||||
putConfig("${prefKey}_label", item.label)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
val etSearch = findViewById<EditText>(R.id.et_search)
|
||||
val titleBlock = findViewById<View>(R.id.title_block)
|
||||
val btnSearch = findViewById<ImageView>(R.id.btn_search)
|
||||
|
||||
etSearch.addTextChangedListener { adapter.filter(it?.toString().orEmpty()) }
|
||||
|
||||
btnSearch.setOnClickListener {
|
||||
if (etSearch.isGone) {
|
||||
titleBlock.isGone = true
|
||||
etSearch.isVisible = true
|
||||
etSearch.requestFocus()
|
||||
} else {
|
||||
if (etSearch.text.isEmpty()) {
|
||||
etSearch.isGone = true
|
||||
titleBlock.isVisible = true
|
||||
} else {
|
||||
etSearch.text.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ActionAdapter(
|
||||
private val allItems: List<ActionItem>,
|
||||
val onClick: (ActionItem) -> Unit,
|
||||
) : RecyclerView.Adapter<ActionAdapter.ViewHolder>() {
|
||||
|
||||
private var displayItems = allItems.toMutableList()
|
||||
|
||||
fun filter(query: String) {
|
||||
displayItems = if (query.isBlank()) {
|
||||
allItems.toMutableList()
|
||||
} else {
|
||||
allItems.filter { it.label.contains(query, ignoreCase = true) }.toMutableList()
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val icon: ImageView = v.findViewById(R.id.icon)
|
||||
val title: TextView = v.findViewById(R.id.title)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_action_selection, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = displayItems[position]
|
||||
holder.title.text = item.label
|
||||
holder.icon.setImageResource(item.iconRes)
|
||||
ThemeManager.applyToView(holder.itemView, this@ActionSelectionActivity)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = displayItems.size
|
||||
}
|
||||
}
|
||||
138
app/src/main/java/com/fan/edgex/ui/AppIconPickerActivity.kt
Normal file
138
app/src/main/java/com/fan/edgex/ui/AppIconPickerActivity.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
|
||||
class AppIconPickerActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ICON_REF = "icon_ref"
|
||||
}
|
||||
|
||||
private data class AppEntry(val packageName: String, val label: String, val icon: Drawable)
|
||||
|
||||
private lateinit var adapter: AppAdapter
|
||||
private val allApps = mutableListOf<AppEntry>()
|
||||
private val filtered = mutableListOf<AppEntry>()
|
||||
|
||||
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
if (uri != null) returnCustomIcon(uri)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_app_icon_picker)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
insets.getInsets(android.view.WindowInsets.Type.statusBars()).top,
|
||||
view.paddingRight,
|
||||
view.paddingBottom,
|
||||
)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
adapter = AppAdapter(filtered) { entry ->
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_ICON_REF, "${MultiActionIconUtils.PREFIX_APP}${entry.packageName}"))
|
||||
finish()
|
||||
}
|
||||
recyclerView.layoutManager = GridLayoutManager(this, 4)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
findViewById<View>(R.id.btn_from_gallery).setOnClickListener {
|
||||
galleryLauncher.launch("image/*")
|
||||
}
|
||||
|
||||
val etSearch = findViewById<EditText>(R.id.et_search)
|
||||
etSearch.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable?) { applyFilter(s?.toString() ?: "") }
|
||||
})
|
||||
|
||||
loadApps()
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
val pm = packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val resolveInfos = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
|
||||
allApps.clear()
|
||||
allApps.addAll(
|
||||
resolveInfos
|
||||
.map { ri ->
|
||||
AppEntry(
|
||||
packageName = ri.activityInfo.packageName,
|
||||
label = ri.loadLabel(pm).toString(),
|
||||
icon = ri.loadIcon(pm),
|
||||
)
|
||||
}
|
||||
.sortedBy { it.label.lowercase() }
|
||||
)
|
||||
applyFilter("")
|
||||
}
|
||||
|
||||
private fun applyFilter(query: String) {
|
||||
filtered.clear()
|
||||
if (query.isBlank()) {
|
||||
filtered.addAll(allApps)
|
||||
} else {
|
||||
val q = query.lowercase()
|
||||
filtered.addAll(allApps.filter { it.label.lowercase().contains(q) || it.packageName.lowercase().contains(q) })
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun returnCustomIcon(uri: Uri) {
|
||||
val filename = MultiActionIconUtils.saveCustomIconFromUri(this, uri) ?: return
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_ICON_REF, "${MultiActionIconUtils.PREFIX_CUSTOM}$filename"))
|
||||
finish()
|
||||
}
|
||||
|
||||
private class AppAdapter(
|
||||
private val items: List<AppEntry>,
|
||||
private val onClick: (AppEntry) -> Unit,
|
||||
) : RecyclerView.Adapter<AppAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val icon: ImageView = v.findViewById(R.id.iv_app_icon)
|
||||
val label: TextView = v.findViewById(R.id.tv_app_label)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_app_icon, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val entry = items[position]
|
||||
holder.icon.setImageDrawable(entry.icon)
|
||||
holder.label.text = entry.label
|
||||
holder.itemView.setOnClickListener { onClick(entry) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
152
app/src/main/java/com/fan/edgex/ui/AppSelectionActivity.kt
Normal file
152
app/src/main/java/com/fan/edgex/ui/AppSelectionActivity.kt
Normal file
@@ -0,0 +1,152 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.putConfigsSync
|
||||
import java.util.Locale
|
||||
|
||||
@Deprecated("Use Compose AppPickerSheet instead")
|
||||
class AppSelectionActivity : AppCompatActivity() {
|
||||
|
||||
data class AppItem(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val icon: android.graphics.drawable.Drawable?,
|
||||
)
|
||||
|
||||
private val allApps = mutableListOf<AppItem>()
|
||||
private val displayedApps = mutableListOf<AppItem>()
|
||||
private lateinit var adapter: AppAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_shortcut_selection)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<TextView>(R.id.tv_title).setText(R.string.header_app_selection)
|
||||
|
||||
val prefKey = intent.getStringExtra("pref_key") ?: "unknown"
|
||||
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
adapter = AppAdapter(displayedApps) { item ->
|
||||
putConfigsSync(
|
||||
prefKey to "launch_app:${item.packageName}",
|
||||
"${prefKey}_label" to item.label,
|
||||
"${prefKey}_title" to item.label,
|
||||
)
|
||||
finish()
|
||||
}
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
setupSearch()
|
||||
loadApps()
|
||||
}
|
||||
|
||||
private fun setupSearch() {
|
||||
val btnSearch = findViewById<ImageView>(R.id.btn_search)
|
||||
val etSearch = findViewById<EditText>(R.id.et_search)
|
||||
val tvTitle = findViewById<TextView>(R.id.tv_title)
|
||||
|
||||
etSearch.setHint(R.string.hint_search_apps)
|
||||
|
||||
btnSearch.setOnClickListener {
|
||||
if (etSearch.isGone) {
|
||||
tvTitle.isGone = true
|
||||
etSearch.isVisible = true
|
||||
etSearch.requestFocus()
|
||||
} else {
|
||||
if (etSearch.text.isEmpty()) {
|
||||
etSearch.isGone = true
|
||||
tvTitle.isVisible = true
|
||||
} else {
|
||||
etSearch.text.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
etSearch.addTextChangedListener { filterApps(it.toString()) }
|
||||
}
|
||||
|
||||
private fun filterApps(query: String) {
|
||||
displayedApps.clear()
|
||||
if (query.isEmpty()) {
|
||||
displayedApps.addAll(allApps)
|
||||
} else {
|
||||
val q = query.lowercase(Locale.getDefault())
|
||||
displayedApps.addAll(allApps.filter {
|
||||
it.label.lowercase().contains(q) || it.packageName.contains(q)
|
||||
})
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
Thread {
|
||||
val pm = packageManager
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val apps = pm.queryIntentActivities(mainIntent, 0)
|
||||
.map { ri ->
|
||||
AppItem(
|
||||
packageName = ri.activityInfo.packageName,
|
||||
label = ri.loadLabel(pm).toString(),
|
||||
icon = try { ri.loadIcon(pm) } catch (_: Exception) { null },
|
||||
)
|
||||
}
|
||||
.sortedBy { it.label }
|
||||
|
||||
runOnUiThread {
|
||||
allApps.clear()
|
||||
allApps.addAll(apps)
|
||||
filterApps(findViewById<EditText>(R.id.et_search).text.toString())
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
inner class AppAdapter(
|
||||
private val items: List<AppItem>,
|
||||
private val onClick: (AppItem) -> Unit,
|
||||
) : RecyclerView.Adapter<AppAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val icon: ImageView = view.findViewById(R.id.app_icon)
|
||||
val title: TextView = view.findViewById(R.id.app_name)
|
||||
val subtitle: TextView = view.findViewById(R.id.app_package)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_app_list, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.title.text = item.label
|
||||
holder.subtitle.text = item.packageName
|
||||
holder.icon.setImageDrawable(item.icon)
|
||||
ThemeManager.applyToView(holder.itemView, this@AppSelectionActivity)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
275
app/src/main/java/com/fan/edgex/ui/ColorPickerView.kt
Normal file
275
app/src/main/java/com/fan/edgex/ui/ColorPickerView.kt
Normal file
@@ -0,0 +1,275 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ColorPickerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
var onColorChanged: ((Int) -> Unit)? = null
|
||||
|
||||
var showAlphaBar: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
private val density = resources.displayMetrics.density
|
||||
private val barHeight = 24f * density
|
||||
private val gap = 10f * density
|
||||
|
||||
private var hue = 0f
|
||||
private var sat = 1f
|
||||
private var bri = 1f
|
||||
private var alpha = 255
|
||||
|
||||
private val svRect = RectF()
|
||||
private val hueRect = RectF()
|
||||
private val alphaRect = RectF()
|
||||
|
||||
private var svBitmap: Bitmap? = null
|
||||
private var hueBitmap: Bitmap? = null
|
||||
private var alphaBitmap: Bitmap? = null
|
||||
|
||||
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
private val selectorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.BLACK
|
||||
strokeWidth = 3f * density
|
||||
}
|
||||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private enum class ActiveRegion { NONE, SV, HUE, ALPHA }
|
||||
private var activeRegion = ActiveRegion.NONE
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val barCount = if (showAlphaBar) 2 else 1
|
||||
val height = (width + barCount * (barHeight + gap)).roundToInt()
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
val svSize = w.toFloat()
|
||||
svRect.set(0f, 0f, svSize, svSize)
|
||||
hueRect.set(0f, svSize + gap, svSize, svSize + gap + barHeight)
|
||||
alphaRect.set(
|
||||
0f,
|
||||
svSize + gap + barHeight + gap,
|
||||
svSize,
|
||||
svSize + gap + barHeight + gap + barHeight,
|
||||
)
|
||||
invalidateBitmaps()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (svRect.isEmpty) return
|
||||
|
||||
if (svBitmap == null) svBitmap = buildSvBitmap()
|
||||
svBitmap?.let { canvas.drawBitmap(it, null, svRect, bitmapPaint) }
|
||||
|
||||
if (hueBitmap == null) hueBitmap = buildHueBitmap()
|
||||
hueBitmap?.let { canvas.drawBitmap(it, null, hueRect, bitmapPaint) }
|
||||
|
||||
if (showAlphaBar) {
|
||||
if (alphaBitmap == null) alphaBitmap = buildAlphaBitmap()
|
||||
alphaBitmap?.let { canvas.drawBitmap(it, null, alphaRect, bitmapPaint) }
|
||||
}
|
||||
|
||||
val svX = svRect.left + sat * svRect.width()
|
||||
val svY = svRect.top + (1f - bri) * svRect.height()
|
||||
val circleRadius = 8f * density
|
||||
shadowPaint.strokeWidth = 3f * density
|
||||
canvas.drawCircle(svX, svY, circleRadius, shadowPaint)
|
||||
selectorPaint.color = Color.WHITE
|
||||
selectorPaint.strokeWidth = 2f * density
|
||||
canvas.drawCircle(svX, svY, circleRadius, selectorPaint)
|
||||
|
||||
drawBarSelector(canvas, hueRect.left + (hue / 360f) * hueRect.width(), hueRect)
|
||||
if (showAlphaBar) {
|
||||
drawBarSelector(canvas, alphaRect.left + (alpha / 255f) * alphaRect.width(), alphaRect)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val x = event.x
|
||||
val y = event.y
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
activeRegion = when {
|
||||
svRect.contains(x, y) -> ActiveRegion.SV
|
||||
hueRect.contains(x, y) -> ActiveRegion.HUE
|
||||
showAlphaBar && alphaRect.contains(x, y) -> ActiveRegion.ALPHA
|
||||
else -> ActiveRegion.NONE
|
||||
}
|
||||
updateFromTouch(x, y)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
updateFromTouch(x, y)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
-> {
|
||||
activeRegion = ActiveRegion.NONE
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
alpha = Color.alpha(color)
|
||||
val hsv = FloatArray(3)
|
||||
Color.colorToHSV(color, hsv)
|
||||
hue = hsv[0]
|
||||
sat = hsv[1]
|
||||
bri = hsv[2]
|
||||
invalidateBitmaps()
|
||||
}
|
||||
|
||||
fun getColor(): Int =
|
||||
Color.HSVToColor(if (showAlphaBar) alpha else 255, floatArrayOf(hue, sat, bri))
|
||||
|
||||
private fun updateFromTouch(x: Float, y: Float) {
|
||||
when (activeRegion) {
|
||||
ActiveRegion.SV -> {
|
||||
sat = ((x - svRect.left) / svRect.width()).coerceIn(0f, 1f)
|
||||
bri = 1f - ((y - svRect.top) / svRect.height()).coerceIn(0f, 1f)
|
||||
alphaBitmap?.recycle()
|
||||
alphaBitmap = null
|
||||
invalidate()
|
||||
onColorChanged?.invoke(getColor())
|
||||
}
|
||||
ActiveRegion.HUE -> {
|
||||
hue = ((x - hueRect.left) / hueRect.width()).coerceIn(0f, 1f) * 360f
|
||||
svBitmap?.recycle()
|
||||
svBitmap = null
|
||||
alphaBitmap?.recycle()
|
||||
alphaBitmap = null
|
||||
invalidate()
|
||||
onColorChanged?.invoke(getColor())
|
||||
}
|
||||
ActiveRegion.ALPHA -> {
|
||||
alpha = (((x - alphaRect.left) / alphaRect.width()).coerceIn(0f, 1f) * 255f).roundToInt()
|
||||
invalidate()
|
||||
onColorChanged?.invoke(getColor())
|
||||
}
|
||||
ActiveRegion.NONE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSvBitmap(): Bitmap {
|
||||
val width = svRect.width().roundToInt().coerceAtLeast(1)
|
||||
val height = svRect.height().roundToInt().coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val hueColor = Color.HSVToColor(floatArrayOf(hue, 1f, 1f))
|
||||
|
||||
val hPaint = Paint().apply {
|
||||
shader = LinearGradient(0f, 0f, width.toFloat(), 0f, Color.WHITE, hueColor, Shader.TileMode.CLAMP)
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), hPaint)
|
||||
|
||||
val vPaint = Paint().apply {
|
||||
shader = LinearGradient(0f, 0f, 0f, height.toFloat(), Color.WHITE, Color.BLACK, Shader.TileMode.CLAMP)
|
||||
xfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), vPaint)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun buildHueBitmap(): Bitmap {
|
||||
val width = hueRect.width().roundToInt().coerceAtLeast(1)
|
||||
val height = hueRect.height().roundToInt().coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val hueStops = floatArrayOf(0f, 60f, 120f, 180f, 240f, 300f, 360f)
|
||||
val colors = IntArray(hueStops.size) { index ->
|
||||
Color.HSVToColor(floatArrayOf(hueStops[index], 1f, 1f))
|
||||
}
|
||||
val positions = FloatArray(hueStops.size) { index -> index / (hueStops.size - 1).toFloat() }
|
||||
val paint = Paint().apply {
|
||||
shader = LinearGradient(0f, 0f, width.toFloat(), 0f, colors, positions, Shader.TileMode.CLAMP)
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun buildAlphaBitmap(): Bitmap {
|
||||
val width = alphaRect.width().roundToInt().coerceAtLeast(1)
|
||||
val height = alphaRect.height().roundToInt().coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val checkSize = 12f * density
|
||||
val checkPaint = Paint()
|
||||
var x = 0f
|
||||
var col = 0
|
||||
while (x < width) {
|
||||
var y = 0f
|
||||
var row = 0
|
||||
while (y < height) {
|
||||
checkPaint.color = if ((col + row) % 2 == 0) 0xFFCCCCCC.toInt() else Color.WHITE
|
||||
canvas.drawRect(
|
||||
x,
|
||||
y,
|
||||
(x + checkSize).coerceAtMost(width.toFloat()),
|
||||
(y + checkSize).coerceAtMost(height.toFloat()),
|
||||
checkPaint,
|
||||
)
|
||||
y += checkSize
|
||||
row++
|
||||
}
|
||||
x += checkSize
|
||||
col++
|
||||
}
|
||||
|
||||
val opaqueColor = Color.HSVToColor(floatArrayOf(hue, sat, bri))
|
||||
val gradientPaint = Paint().apply {
|
||||
shader = LinearGradient(0f, 0f, width.toFloat(), 0f, Color.TRANSPARENT, opaqueColor, Shader.TileMode.CLAMP)
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gradientPaint)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun drawBarSelector(canvas: Canvas, x: Float, rect: RectF) {
|
||||
val halfWidth = 3f * density
|
||||
shadowPaint.strokeWidth = 3f * density
|
||||
shadowPaint.style = Paint.Style.STROKE
|
||||
canvas.drawRect(x - halfWidth - 1, rect.top - 1, x + halfWidth + 1, rect.bottom + 1, shadowPaint)
|
||||
fillPaint.color = Color.WHITE
|
||||
canvas.drawRect(x - halfWidth, rect.top, x + halfWidth, rect.bottom, fillPaint)
|
||||
selectorPaint.color = Color.BLACK
|
||||
selectorPaint.strokeWidth = 1f * density
|
||||
canvas.drawRect(x - halfWidth, rect.top, x + halfWidth, rect.bottom, selectorPaint)
|
||||
}
|
||||
|
||||
private fun invalidateBitmaps() {
|
||||
svBitmap?.recycle()
|
||||
hueBitmap?.recycle()
|
||||
alphaBitmap?.recycle()
|
||||
svBitmap = null
|
||||
hueBitmap = null
|
||||
alphaBitmap = null
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.ConditionStore
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
@Deprecated("Use Compose ConditionSheet instead")
|
||||
class ConditionActionActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var prefKey: String
|
||||
private lateinit var condId: String
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_condition_action)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
prefKey = intent.getStringExtra("pref_key") ?: run { finish(); return }
|
||||
val title = intent.getStringExtra("title") ?: getString(R.string.action_condition)
|
||||
|
||||
condId = resolveOrCreateId()
|
||||
|
||||
putConfig(prefKey, ConditionStore.buildActionCode(condId))
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<TextView>(R.id.tv_subtitle).text = title
|
||||
|
||||
// 如果 row
|
||||
val rowIf = findViewById<View>(R.id.row_condition_if)
|
||||
rowIf.findViewById<TextView>(R.id.action_title).text = getString(R.string.cond_label_if)
|
||||
rowIf.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(this, ConditionPickerActivity::class.java)
|
||||
.putExtra(ConditionPickerActivity.EXTRA_COND_ID, condId)
|
||||
)
|
||||
}
|
||||
|
||||
// 然后 row
|
||||
val rowThen = findViewById<View>(R.id.row_condition_then)
|
||||
rowThen.findViewById<TextView>(R.id.action_title).text = getString(R.string.cond_label_then)
|
||||
rowThen.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("pref_key", ConditionStore.condThenKey(condId))
|
||||
.putExtra("title", getString(R.string.cond_label_then))
|
||||
.putExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES, intent.getStringArrayExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES))
|
||||
)
|
||||
}
|
||||
|
||||
// 否则 row
|
||||
val rowElse = findViewById<View>(R.id.row_condition_else)
|
||||
rowElse.findViewById<TextView>(R.id.action_title).text = getString(R.string.cond_label_else)
|
||||
rowElse.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("pref_key", ConditionStore.condElseKey(condId))
|
||||
.putExtra("title", getString(R.string.cond_label_else))
|
||||
.putExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES, intent.getStringArrayExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshSubtitles()
|
||||
}
|
||||
|
||||
private fun refreshSubtitles() {
|
||||
val none = getString(R.string.action_none)
|
||||
|
||||
val ifLabel = getConfigString(ConditionStore.condIfLabelKey(condId), none)
|
||||
val thenLabel = getConfigString(ConditionStore.condThenLabelKey(condId), none)
|
||||
val elseLabel = getConfigString(ConditionStore.condElseLabelKey(condId), none)
|
||||
|
||||
findViewById<View>(R.id.row_condition_if).findViewById<TextView>(R.id.action_subtitle).text = ifLabel
|
||||
findViewById<View>(R.id.row_condition_then).findViewById<TextView>(R.id.action_subtitle).text = thenLabel
|
||||
findViewById<View>(R.id.row_condition_else).findViewById<TextView>(R.id.action_subtitle).text = elseLabel
|
||||
|
||||
putConfig("${prefKey}_label", "if($ifLabel){$thenLabel} else {$elseLabel}")
|
||||
}
|
||||
|
||||
private fun resolveOrCreateId(): String {
|
||||
val existing = getConfigString(prefKey, "")
|
||||
val extracted = ConditionStore.extractId(existing)
|
||||
if (extracted != null) return extracted
|
||||
return System.currentTimeMillis().toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.ConditionStore
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
@Deprecated("Use Compose ConditionPickerSheet instead")
|
||||
class ConditionPickerActivity : AppCompatActivity() {
|
||||
|
||||
data class ConditionItem(val label: String, val code: String, val iconRes: Int)
|
||||
|
||||
private val conditions get() = listOf(
|
||||
ConditionItem(getString(R.string.cond_auto_brightness), "auto_brightness", R.drawable.ic_brightness_up),
|
||||
ConditionItem(getString(R.string.cond_auto_rotate), "auto_rotate", R.drawable.ic_screen_rotation),
|
||||
ConditionItem(getString(R.string.cond_wifi_enabled), "wifi_enabled", R.drawable.ic_wifi),
|
||||
ConditionItem(getString(R.string.cond_mobile_data), "mobile_data", R.drawable.ic_mobile_data),
|
||||
ConditionItem(getString(R.string.cond_location), "location", R.drawable.ic_location),
|
||||
ConditionItem(getString(R.string.cond_bluetooth), "bluetooth", R.drawable.ic_bluetooth),
|
||||
ConditionItem(getString(R.string.cond_nfc), "nfc", R.drawable.ic_nfc),
|
||||
ConditionItem(getString(R.string.cond_power_connected), "power_connected", R.drawable.ic_power),
|
||||
ConditionItem(getString(R.string.cond_wifi_connected), "wifi_connected", R.drawable.ic_wifi),
|
||||
ConditionItem(getString(R.string.cond_network_connected),"network_connected",R.drawable.ic_link),
|
||||
ConditionItem(getString(R.string.cond_media_playing), "media_playing", R.drawable.ic_music),
|
||||
ConditionItem(getString(R.string.cond_screen_portrait), "screen_portrait", R.drawable.ic_screen_portrait),
|
||||
ConditionItem(getString(R.string.cond_screen_landscape), "screen_landscape", R.drawable.ic_screen_landscape),
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_action_selection)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<TextView>(R.id.tv_subtitle).text = getString(R.string.header_condition_if)
|
||||
|
||||
val condId = intent.getStringExtra(EXTRA_COND_ID) ?: run { finish(); return }
|
||||
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = ConditionAdapter(conditions) { item ->
|
||||
putConfig(ConditionStore.condIfKey(condId), item.code)
|
||||
putConfig(ConditionStore.condIfLabelKey(condId), item.label)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
inner class ConditionAdapter(
|
||||
private val items: List<ConditionItem>,
|
||||
private val onClick: (ConditionItem) -> Unit,
|
||||
) : RecyclerView.Adapter<ConditionAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val icon: ImageView = v.findViewById(R.id.icon)
|
||||
val title: TextView = v.findViewById(R.id.title)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_action_selection, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.title.text = item.label
|
||||
holder.icon.setImageResource(item.iconRes)
|
||||
ThemeManager.applyToView(holder.itemView, this@ConditionPickerActivity)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_COND_ID = "cond_id"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
import org.json.JSONArray
|
||||
import java.util.Locale
|
||||
|
||||
class EdgeLightingAppFilterActivity : AppCompatActivity() {
|
||||
|
||||
private data class AppItem(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val icon: android.graphics.drawable.Drawable?,
|
||||
)
|
||||
|
||||
private val allApps = mutableListOf<AppItem>()
|
||||
private val displayedApps = mutableListOf<AppItem>()
|
||||
private val selectedPackages = linkedSetOf<String>()
|
||||
private lateinit var adapter: AppAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_edge_lighting_app_filter)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
selectedPackages.addAll(parsePackageList(getConfigString(AppConfig.EDGE_LIGHTING_APP_LIST)))
|
||||
setupAppList()
|
||||
loadApps()
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_edge_lighting_apps)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
adapter = AppAdapter(displayedApps) { item ->
|
||||
if (!selectedPackages.remove(item.packageName)) {
|
||||
selectedPackages.add(item.packageName)
|
||||
}
|
||||
saveSelectedPackages()
|
||||
refreshSelectedSummary()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
findViewById<EditText>(R.id.et_edge_lighting_app_search).addTextChangedListener {
|
||||
filterApps(it.toString())
|
||||
}
|
||||
findViewById<Button>(R.id.btn_edge_lighting_clear_apps).setOnClickListener {
|
||||
selectedPackages.clear()
|
||||
saveSelectedPackages()
|
||||
refreshSelectedSummary()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
refreshSelectedSummary()
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
Thread {
|
||||
val pm = packageManager
|
||||
val apps = pm.getInstalledApplications(0)
|
||||
.map { info ->
|
||||
AppItem(
|
||||
packageName = info.packageName,
|
||||
label = info.loadLabel(pm).toString(),
|
||||
icon = runCatching { info.loadIcon(pm) }.getOrNull(),
|
||||
)
|
||||
}
|
||||
.sortedBy { it.label.lowercase(Locale.getDefault()) }
|
||||
|
||||
runOnUiThread {
|
||||
allApps.clear()
|
||||
allApps.addAll(apps)
|
||||
filterApps(findViewById<EditText>(R.id.et_edge_lighting_app_search).text.toString())
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun filterApps(query: String) {
|
||||
val q = query.lowercase(Locale.getDefault())
|
||||
displayedApps.clear()
|
||||
displayedApps.addAll(
|
||||
if (q.isBlank()) allApps
|
||||
else allApps.filter { it.label.lowercase(Locale.getDefault()).contains(q) || it.packageName.contains(q) },
|
||||
)
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun saveSelectedPackages() {
|
||||
val value = if (selectedPackages.isEmpty()) "" else JSONArray(selectedPackages.toList()).toString()
|
||||
putConfig(AppConfig.EDGE_LIGHTING_APP_LIST, value)
|
||||
}
|
||||
|
||||
private fun refreshSelectedSummary() {
|
||||
findViewById<TextView>(R.id.text_edge_lighting_app_summary).text =
|
||||
if (selectedPackages.isEmpty()) {
|
||||
getString(R.string.edge_lighting_app_filter_all)
|
||||
} else {
|
||||
getString(R.string.edge_lighting_app_filter_selected, selectedPackages.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePackageList(value: String): Set<String> {
|
||||
if (value.isBlank()) return emptySet()
|
||||
return runCatching {
|
||||
val array = JSONArray(value)
|
||||
buildSet {
|
||||
for (index in 0 until array.length()) {
|
||||
val packageName = array.optString(index).trim()
|
||||
if (packageName.isNotEmpty()) add(packageName)
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
value.split(",").mapNotNullTo(mutableSetOf()) { it.trim().takeIf(String::isNotEmpty) }
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AppAdapter(
|
||||
private val items: List<AppItem>,
|
||||
private val onClick: (AppItem) -> Unit,
|
||||
) : RecyclerView.Adapter<AppAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val icon: ImageView = view.findViewById(R.id.app_icon)
|
||||
val name: TextView = view.findViewById(R.id.app_name)
|
||||
val pkg: TextView = view.findViewById(R.id.app_package)
|
||||
val frozen: TextView = view.findViewById(R.id.tv_frozen_status)
|
||||
val checkbox: CheckBox = view.findViewById(R.id.cb_include)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_app_list, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.name.text = item.label
|
||||
holder.pkg.text = item.packageName
|
||||
holder.icon.setImageDrawable(item.icon)
|
||||
holder.frozen.visibility = View.GONE
|
||||
holder.checkbox.isChecked = item.packageName in selectedPackages
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
ThemeManager.applyToView(holder.itemView, this@EdgeLightingAppFilterActivity)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.license.PremiumActivator
|
||||
import com.fan.edgex.config.getConfigBool
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
import com.fan.edgex.overlay.EdgeLightingView
|
||||
import com.fan.edgex.service.NotificationEdgeService
|
||||
import org.json.JSONArray
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
class EdgeLightingSettingsActivity : AppCompatActivity() {
|
||||
|
||||
private data class EffectOption(val value: String, val labelRes: Int)
|
||||
|
||||
private lateinit var previewView: EdgeLightingView
|
||||
private lateinit var previewLabel: TextView
|
||||
private var previewAnimator: ValueAnimator? = null
|
||||
|
||||
private val effects = listOf(
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_BASIC, R.string.edge_lighting_effect_basic),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_BREATHING, R.string.edge_lighting_effect_breathing),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_COMET, R.string.edge_lighting_effect_comet),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_FLOW, R.string.edge_lighting_effect_flow),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_MULTICOLOR, R.string.edge_lighting_effect_multicolor),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_SPOTLIGHT, R.string.edge_lighting_effect_spotlight),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_ECLIPSE, R.string.edge_lighting_effect_eclipse),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_ECHO, R.string.edge_lighting_effect_echo),
|
||||
EffectOption(AppConfig.EDGE_LIGHTING_EFFECT_RIPPLE, R.string.edge_lighting_effect_ripple),
|
||||
)
|
||||
private var selectedEffect = AppConfig.EDGE_LIGHTING_EFFECT_BASIC
|
||||
private val chipViews = mutableListOf<TextView>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_edge_lighting_settings)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
insets.getInsets(android.view.WindowInsets.Type.statusBars()).top,
|
||||
view.paddingRight,
|
||||
view.paddingBottom,
|
||||
)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
previewView = findViewById(R.id.preview_lighting_view)
|
||||
previewLabel = findViewById(R.id.preview_effect_label)
|
||||
|
||||
setupToggles()
|
||||
setupEffectChips()
|
||||
setupColorControls()
|
||||
setupSeekBars()
|
||||
setupNotificationAccessRow()
|
||||
setupAppFilterEntry()
|
||||
syncPreviewFromConfig()
|
||||
applyPremiumGating()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ThemeManager.applyToActivity(this)
|
||||
refreshNotificationAccessStatus()
|
||||
refreshAppFilterSummary()
|
||||
startPreviewAnimation()
|
||||
applyPremiumGating()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
stopPreviewAnimation()
|
||||
}
|
||||
|
||||
// --- Preview ---
|
||||
|
||||
private fun syncPreviewFromConfig() {
|
||||
val color = parseColor(getConfigString(AppConfig.EDGE_LIGHTING_COLOR, DEFAULT_COLOR))
|
||||
val widthDp = getConfigString(AppConfig.EDGE_LIGHTING_WIDTH_DP, "5").toIntOrNull() ?: 5
|
||||
val alpha = getConfigString(AppConfig.EDGE_LIGHTING_ALPHA, "1.0").toFloatOrNull() ?: 1f
|
||||
|
||||
previewView.glowColor = color
|
||||
previewView.glowWidthPx = widthDp.coerceIn(1, 20) * resources.displayMetrics.density
|
||||
previewView.glowAlpha = alpha.coerceIn(0f, 1f)
|
||||
previewView.effect = selectedEffect
|
||||
}
|
||||
|
||||
private fun startPreviewAnimation() {
|
||||
stopPreviewAnimation()
|
||||
previewAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 4200L
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.RESTART
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener { animation ->
|
||||
val progress = animation.animatedValue as Float
|
||||
previewView.flowProgress = progress
|
||||
val baseAlpha = getConfigString(AppConfig.EDGE_LIGHTING_ALPHA, "1.0")
|
||||
.toFloatOrNull()?.coerceIn(0f, 1f) ?: 1f
|
||||
previewView.glowAlpha = if (previewView.effect == AppConfig.EDGE_LIGHTING_EFFECT_BREATHING) {
|
||||
val pulse = 0.4f + 0.6f * ((sin(progress * PI * 4.0) + 1.0) / 2.0).toFloat()
|
||||
baseAlpha * pulse
|
||||
} else {
|
||||
baseAlpha
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPreviewAnimation() {
|
||||
previewAnimator?.cancel()
|
||||
previewAnimator = null
|
||||
}
|
||||
|
||||
private fun updatePreviewEffect(effect: String) {
|
||||
selectedEffect = effect
|
||||
previewView.effect = effect
|
||||
previewLabel.text = getString(effects.first { it.value == effect }.labelRes)
|
||||
}
|
||||
|
||||
private fun updatePreviewColor(color: Int) {
|
||||
previewView.glowColor = color
|
||||
}
|
||||
|
||||
private fun updatePreviewWidth(widthDp: Int) {
|
||||
previewView.glowWidthPx = widthDp.coerceIn(1, 20) * resources.displayMetrics.density
|
||||
}
|
||||
|
||||
// --- Toggles ---
|
||||
|
||||
private fun setupToggles() {
|
||||
val enabledSwitch = bindSwitch(R.id.switch_edge_lighting_enabled, AppConfig.EDGE_LIGHTING_ENABLED, false)
|
||||
val autoColorSwitch = bindSwitch(R.id.switch_auto_color, AppConfig.EDGE_LIGHTING_AUTO_COLOR, true)
|
||||
|
||||
fun applyEnabledState(isEnabled: Boolean) {
|
||||
autoColorSwitch.isEnabled = isEnabled
|
||||
autoColorSwitch.alpha = if (isEnabled) 1f else DISABLED_ALPHA
|
||||
}
|
||||
|
||||
applyEnabledState(enabledSwitch.isChecked)
|
||||
enabledSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
putConfig(AppConfig.EDGE_LIGHTING_ENABLED, isChecked)
|
||||
applyEnabledState(isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindSwitch(id: Int, key: String, default: Boolean): Switch {
|
||||
val switch = findViewById<Switch>(id)
|
||||
switch.isChecked = getConfigBool(key, default)
|
||||
switch.setOnCheckedChangeListener { _, isChecked -> putConfig(key, isChecked) }
|
||||
return switch
|
||||
}
|
||||
|
||||
// --- Effect Chips ---
|
||||
|
||||
private fun setupEffectChips() {
|
||||
selectedEffect = getConfigString(AppConfig.EDGE_LIGHTING_EFFECT, AppConfig.EDGE_LIGHTING_EFFECT_BASIC)
|
||||
val container = findViewById<LinearLayout>(R.id.effect_chips_container)
|
||||
val dp = resources.displayMetrics.density
|
||||
val accent = ThemeManager.currentAccent(this)
|
||||
|
||||
effects.forEachIndexed { index, option ->
|
||||
val chip = createChip(getString(option.labelRes), option.value == selectedEffect, accent)
|
||||
chip.setOnClickListener {
|
||||
selectChip(index, accent)
|
||||
putConfig(AppConfig.EDGE_LIGHTING_EFFECT, option.value)
|
||||
updatePreviewEffect(option.value)
|
||||
}
|
||||
if (index > 0) {
|
||||
val params = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
params.marginStart = (8 * dp).toInt()
|
||||
chip.layoutParams = params
|
||||
}
|
||||
chipViews.add(chip)
|
||||
container.addView(chip)
|
||||
}
|
||||
|
||||
previewLabel.text = getString(effects.first { it.value == selectedEffect }.labelRes)
|
||||
}
|
||||
|
||||
private fun createChip(label: String, selected: Boolean, accent: Int): TextView {
|
||||
val dp = resources.displayMetrics.density
|
||||
val chip = TextView(this)
|
||||
chip.text = label
|
||||
chip.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
chip.setPadding((16 * dp).toInt(), (9 * dp).toInt(), (16 * dp).toInt(), (9 * dp).toInt())
|
||||
applyChipStyle(chip, selected, accent)
|
||||
return chip
|
||||
}
|
||||
|
||||
private fun applyChipStyle(chip: TextView, selected: Boolean, accent: Int) {
|
||||
val dp = resources.displayMetrics.density
|
||||
val shape = GradientDrawable()
|
||||
shape.cornerRadius = 20 * dp
|
||||
if (selected) {
|
||||
shape.setColor(accent)
|
||||
chip.setTextColor(ThemeManager.onAccentColor(accent))
|
||||
} else {
|
||||
shape.setColor(Color.TRANSPARENT)
|
||||
shape.setStroke((1 * dp).toInt(), resources.getColor(R.color.ui_edit_stroke, null))
|
||||
chip.setTextColor(resources.getColor(R.color.ui_text_primary, null))
|
||||
}
|
||||
chip.background = shape
|
||||
}
|
||||
|
||||
private fun selectChip(selectedIndex: Int, accent: Int) {
|
||||
chipViews.forEachIndexed { index, chip ->
|
||||
applyChipStyle(chip, index == selectedIndex, accent)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Color Controls ---
|
||||
|
||||
private fun setupColorControls() {
|
||||
val red = findViewById<EditText>(R.id.edit_edge_lighting_red)
|
||||
val green = findViewById<EditText>(R.id.edit_edge_lighting_green)
|
||||
val blue = findViewById<EditText>(R.id.edit_edge_lighting_blue)
|
||||
val color = parseColor(getConfigString(AppConfig.EDGE_LIGHTING_COLOR, DEFAULT_COLOR))
|
||||
|
||||
red.setText(Color.red(color).toString())
|
||||
green.setText(Color.green(color).toString())
|
||||
blue.setText(Color.blue(color).toString())
|
||||
refreshColorPreview()
|
||||
|
||||
red.addTextChangedListener { refreshColorPreview() }
|
||||
green.addTextChangedListener { refreshColorPreview() }
|
||||
blue.addTextChangedListener { refreshColorPreview() }
|
||||
|
||||
findViewById<View>(R.id.btn_edge_lighting_apply_color).setOnClickListener {
|
||||
val customColor = readColorFromInputs()
|
||||
if (customColor == null) {
|
||||
Toast.makeText(this, R.string.toast_theme_invalid_rgb, Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
putConfig(AppConfig.EDGE_LIGHTING_COLOR, displayColor(customColor))
|
||||
updatePreviewColor(customColor)
|
||||
Toast.makeText(this, R.string.edge_lighting_color_saved, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// --- SeekBars ---
|
||||
|
||||
private fun setupSeekBars() {
|
||||
bindSeekBar(
|
||||
seekBarId = R.id.seek_edge_lighting_width,
|
||||
valueId = R.id.text_edge_lighting_width_value,
|
||||
key = AppConfig.EDGE_LIGHTING_WIDTH_DP,
|
||||
defaultValue = 5,
|
||||
min = 1,
|
||||
max = 20,
|
||||
formatter = { getString(R.string.edge_lighting_width_dp, it) },
|
||||
onChanged = { updatePreviewWidth(it) },
|
||||
)
|
||||
bindSeekBar(
|
||||
seekBarId = R.id.seek_edge_lighting_duration,
|
||||
valueId = R.id.text_edge_lighting_duration_value,
|
||||
key = AppConfig.EDGE_LIGHTING_DURATION_MS,
|
||||
defaultValue = 3000,
|
||||
min = 500,
|
||||
max = 10000,
|
||||
step = 100,
|
||||
formatter = { getString(R.string.edge_lighting_duration_ms, it) },
|
||||
)
|
||||
bindSeekBar(
|
||||
seekBarId = R.id.seek_edge_lighting_alpha,
|
||||
valueId = R.id.text_edge_lighting_alpha_value,
|
||||
key = AppConfig.EDGE_LIGHTING_ALPHA,
|
||||
defaultValue = 100,
|
||||
min = 0,
|
||||
max = 100,
|
||||
formatter = { getString(R.string.edge_lighting_alpha_pct, it) },
|
||||
saveValue = { putConfig(AppConfig.EDGE_LIGHTING_ALPHA, (it / 100f).toString()) },
|
||||
readValue = { ((getConfigString(AppConfig.EDGE_LIGHTING_ALPHA, "1.0").toFloatOrNull() ?: 1f) * 100).toInt() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindSeekBar(
|
||||
seekBarId: Int,
|
||||
valueId: Int,
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
min: Int,
|
||||
max: Int,
|
||||
step: Int = 1,
|
||||
formatter: (Int) -> String,
|
||||
saveValue: (Int) -> Unit = { putConfig(key, it.toString()) },
|
||||
readValue: () -> Int = { getConfigString(key, defaultValue.toString()).toIntOrNull() ?: defaultValue },
|
||||
onChanged: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
val seekBar = findViewById<SeekBar>(seekBarId)
|
||||
val label = findViewById<TextView>(valueId)
|
||||
seekBar.max = ((max - min) / step).coerceAtLeast(1)
|
||||
|
||||
fun progressToValue(progress: Int) = min + progress * step
|
||||
fun valueToProgress(value: Int) = (value.coerceIn(min, max) - min) / step
|
||||
|
||||
val initial = readValue().coerceIn(min, max)
|
||||
seekBar.progress = valueToProgress(initial)
|
||||
label.text = formatter(initial)
|
||||
|
||||
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
val value = progressToValue(progress)
|
||||
label.text = formatter(value.coerceIn(min, max))
|
||||
if (fromUser) {
|
||||
saveValue(value)
|
||||
onChanged?.invoke(value)
|
||||
}
|
||||
}
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {
|
||||
val value = progressToValue(seekBar?.progress ?: 0)
|
||||
saveValue(value)
|
||||
onChanged?.invoke(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Notification Access ---
|
||||
|
||||
private fun setupNotificationAccessRow() {
|
||||
findViewById<View>(R.id.item_notification_access).setOnClickListener {
|
||||
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
}
|
||||
refreshNotificationAccessStatus()
|
||||
}
|
||||
|
||||
private fun refreshNotificationAccessStatus() {
|
||||
val granted = isNotificationAccessGranted()
|
||||
val statusText = findViewById<TextView>(R.id.text_notification_access_status)
|
||||
statusText.setText(
|
||||
if (granted) R.string.edge_lighting_notif_status_granted
|
||||
else R.string.edge_lighting_notif_status_required,
|
||||
)
|
||||
val accent = ThemeManager.currentAccent(this)
|
||||
statusText.setTextColor(
|
||||
if (granted) accent
|
||||
else resources.getColor(R.color.ui_attention_red, theme),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isNotificationAccessGranted(): Boolean {
|
||||
val componentName = ComponentName(this, NotificationEdgeService::class.java).flattenToString()
|
||||
val enabled = Settings.Secure.getString(contentResolver, "enabled_notification_listeners").orEmpty()
|
||||
return enabled.split(':').any { it.equals(componentName, ignoreCase = true) }
|
||||
}
|
||||
|
||||
// --- App Filter ---
|
||||
|
||||
private fun setupAppFilterEntry() {
|
||||
findViewById<View>(R.id.item_edge_lighting_apps).setOnClickListener {
|
||||
startActivity(Intent(this, EdgeLightingAppFilterActivity::class.java))
|
||||
}
|
||||
refreshAppFilterSummary()
|
||||
}
|
||||
|
||||
private fun refreshAppFilterSummary() {
|
||||
val selectedCount = parsePackageList(getConfigString(AppConfig.EDGE_LIGHTING_APP_LIST)).size
|
||||
findViewById<TextView>(R.id.text_edge_lighting_app_summary).text =
|
||||
if (selectedCount == 0) getString(R.string.edge_lighting_app_filter_all)
|
||||
else getString(R.string.edge_lighting_app_filter_selected, selectedCount)
|
||||
}
|
||||
|
||||
// --- Color helpers ---
|
||||
|
||||
private fun refreshColorPreview() {
|
||||
val color = readColorFromInputs() ?: parseColor(DEFAULT_COLOR)
|
||||
ThemeManager.tintSwatch(findViewById(R.id.preview_edge_lighting_color), color)
|
||||
findViewById<TextView>(R.id.text_edge_lighting_color_hex).text = displayColor(color)
|
||||
}
|
||||
|
||||
private fun readColorFromInputs(): Int? {
|
||||
val r = findViewById<EditText>(R.id.edit_edge_lighting_red).text.toString().toIntOrNull()
|
||||
val g = findViewById<EditText>(R.id.edit_edge_lighting_green).text.toString().toIntOrNull()
|
||||
val b = findViewById<EditText>(R.id.edit_edge_lighting_blue).text.toString().toIntOrNull()
|
||||
if (r == null || g == null || b == null) return null
|
||||
if (r !in 0..255 || g !in 0..255 || b !in 0..255) return null
|
||||
return Color.rgb(r, g, b)
|
||||
}
|
||||
|
||||
private fun parseColor(value: String): Int =
|
||||
runCatching { value.toColorInt() }.getOrElse { DEFAULT_COLOR.toColorInt() }
|
||||
|
||||
private fun displayColor(color: Int): String =
|
||||
String.format("#%06X", 0xFFFFFF and color)
|
||||
|
||||
private fun parsePackageList(value: String): Set<String> {
|
||||
if (value.isBlank()) return emptySet()
|
||||
return runCatching {
|
||||
val array = JSONArray(value)
|
||||
buildSet {
|
||||
for (i in 0 until array.length()) {
|
||||
val pkg = array.optString(i).trim()
|
||||
if (pkg.isNotEmpty()) add(pkg)
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
value.split(",").mapNotNullTo(mutableSetOf()) { it.trim().takeIf(String::isNotEmpty) }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Premium gating (unlocked) ---
|
||||
|
||||
private fun applyPremiumGating() {
|
||||
// No-op: Edge Lighting is now available without premium
|
||||
}
|
||||
|
||||
private fun setGroupEnabled(view: View, enabled: Boolean) {
|
||||
view.isEnabled = enabled
|
||||
if (view is ViewGroup) {
|
||||
for (i in 0 until view.childCount) setGroupEnabled(view.getChildAt(i), enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_COLOR = "#00FFFF"
|
||||
const val DISABLED_ALPHA = 0.45f
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/com/fan/edgex/ui/FastScrollActivity.kt
Normal file
77
app/src/main/java/com/fan/edgex/ui/FastScrollActivity.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
class FastScrollActivity : AppCompatActivity() {
|
||||
|
||||
data class ScrollOption(
|
||||
val label: String,
|
||||
val code: String,
|
||||
@DrawableRes val iconRes: Int,
|
||||
)
|
||||
|
||||
private val options get() = listOf(
|
||||
ScrollOption(getString(R.string.action_scroll_to_top), "to_top", R.drawable.ic_scroll_to_top),
|
||||
ScrollOption(getString(R.string.action_scroll_to_bottom), "to_bottom", R.drawable.ic_scroll_to_bottom),
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_action_selection)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<TextView>(R.id.tv_subtitle).text = getString(R.string.header_fast_scroll)
|
||||
|
||||
val prefKey = intent.getStringExtra("pref_key") ?: "unknown"
|
||||
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = OptionsAdapter(options) { option ->
|
||||
putConfig(prefKey, "fast_scroll:${option.code}")
|
||||
putConfig("${prefKey}_label", option.label)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
inner class OptionsAdapter(
|
||||
private val items: List<ScrollOption>,
|
||||
private val onClick: (ScrollOption) -> Unit,
|
||||
) : RecyclerView.Adapter<OptionsAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val icon: ImageView = v.findViewById(R.id.icon)
|
||||
val title: TextView = v.findViewById(R.id.title)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_action_selection, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.title.text = item.label
|
||||
holder.icon.setImageResource(item.iconRes)
|
||||
ThemeManager.applyToView(holder.itemView, this@FastScrollActivity)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
306
app/src/main/java/com/fan/edgex/ui/FreezerActivity.kt
Normal file
306
app/src/main/java/com/fan/edgex/ui/FreezerActivity.kt
Normal file
@@ -0,0 +1,306 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.FreezerBootstrap
|
||||
import com.fan.edgex.config.configPrefs
|
||||
import com.fan.edgex.config.putConfig
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class FreezerActivity : AppCompatActivity() {
|
||||
|
||||
data class AppItem(
|
||||
val info: ApplicationInfo,
|
||||
val label: String,
|
||||
val isFrozen: Boolean,
|
||||
var isChecked: Boolean = false
|
||||
)
|
||||
|
||||
private val allApps = mutableListOf<AppItem>()
|
||||
private val displayedApps = mutableListOf<AppItem>()
|
||||
private val freezerList = mutableSetOf<String>() // Set of package names
|
||||
private lateinit var adapter: AppListAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_freezer)
|
||||
FreezerBootstrap.ensureMigrated(this)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
// Header Insets
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
// Recycler View
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
adapter = AppListAdapter(displayedApps,
|
||||
onClick = { app -> showAppDialog(app) },
|
||||
onCheckChanged = { app, isChecked ->
|
||||
toggleAppFreeze(app, isChecked)
|
||||
}
|
||||
)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
// Search Logic
|
||||
setupSearch()
|
||||
|
||||
// Load Apps
|
||||
loadApps()
|
||||
}
|
||||
|
||||
private fun setupSearch() {
|
||||
val btnSearch = findViewById<ImageView>(R.id.btn_search)
|
||||
val etSearch = findViewById<EditText>(R.id.et_search)
|
||||
val tvTitle = findViewById<TextView>(R.id.tv_title)
|
||||
|
||||
btnSearch.setOnClickListener {
|
||||
if (etSearch.isGone) {
|
||||
// Open Search
|
||||
tvTitle.isGone = true
|
||||
etSearch.isVisible = true
|
||||
etSearch.requestFocus()
|
||||
} else {
|
||||
// Close/Clear
|
||||
if (etSearch.text.isEmpty()) {
|
||||
etSearch.isGone = true
|
||||
tvTitle.isVisible = true
|
||||
} else {
|
||||
etSearch.text.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
etSearch.addTextChangedListener { text ->
|
||||
filterApps(text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
// Load saved list
|
||||
val savedString = configPrefs().getString(AppConfig.FREEZER_APP_LIST, "") ?: ""
|
||||
freezerList.clear()
|
||||
if (savedString.isNotEmpty()) {
|
||||
freezerList.addAll(savedString.split(","))
|
||||
}
|
||||
|
||||
val pm = packageManager
|
||||
val rawApps = pm.getInstalledApplications(0)
|
||||
|
||||
val list = rawApps.map { info ->
|
||||
AppItem(
|
||||
info = info,
|
||||
label = info.loadLabel(pm).toString(),
|
||||
isFrozen = !info.enabled,
|
||||
isChecked = !info.enabled
|
||||
)
|
||||
}.sortedBy { it.label.lowercase() }
|
||||
|
||||
allApps.clear()
|
||||
allApps.addAll(list)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
filterApps("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleAppFreeze(app: AppItem, freeze: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val cmd = if (freeze) "pm disable ${app.info.packageName}" else "pm enable ${app.info.packageName}"
|
||||
val success = runRootCommand(cmd)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
val msgRes = if (freeze) R.string.toast_frozen else R.string.toast_unfrozen
|
||||
Toast.makeText(this@FreezerActivity, getString(msgRes, app.label), Toast.LENGTH_SHORT).show()
|
||||
updateFreezerList(app.info.packageName, freeze)
|
||||
loadApps() // Reload to refresh all labels/states
|
||||
} else {
|
||||
val errorRes = if (freeze) R.string.toast_freeze_failed else R.string.toast_unfreeze_failed
|
||||
Toast.makeText(this@FreezerActivity, getString(errorRes), Toast.LENGTH_SHORT).show()
|
||||
adapter.notifyDataSetChanged() // Reset checkbox to previous state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFreezerList(packageName: String, add: Boolean) {
|
||||
if (add) {
|
||||
freezerList.add(packageName)
|
||||
} else {
|
||||
freezerList.remove(packageName)
|
||||
}
|
||||
|
||||
putConfig(AppConfig.FREEZER_APP_LIST, freezerList.joinToString(","))
|
||||
}
|
||||
|
||||
private fun filterApps(query: String) {
|
||||
displayedApps.clear()
|
||||
if (query.isEmpty()) {
|
||||
displayedApps.addAll(allApps)
|
||||
} else {
|
||||
val q = query.lowercase(Locale.getDefault())
|
||||
displayedApps.addAll(allApps.filter {
|
||||
it.label.lowercase().contains(q) || it.info.packageName.contains(q)
|
||||
})
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun showAppDialog(app: AppItem) {
|
||||
val dialog = BottomSheetDialog(this)
|
||||
val view = layoutInflater.inflate(R.layout.dialog_app_options, null)
|
||||
|
||||
view.findViewById<TextView>(R.id.dialog_title).text = app.label
|
||||
|
||||
val btnFreeze = view.findViewById<View>(R.id.btn_freeze)
|
||||
val btnUnfreeze = view.findViewById<View>(R.id.btn_unfreeze)
|
||||
|
||||
if (app.isFrozen) {
|
||||
btnFreeze.isGone = true
|
||||
btnUnfreeze.isVisible = true
|
||||
} else {
|
||||
btnFreeze.isVisible = true
|
||||
btnUnfreeze.isGone = true
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.btn_run).setOnClickListener {
|
||||
try {
|
||||
val intent = packageManager.getLaunchIntentForPackage(app.info.packageName)
|
||||
if (intent != null) {
|
||||
startActivity(intent)
|
||||
dialog.dismiss()
|
||||
} else {
|
||||
Toast.makeText(this, getString(R.string.toast_cannot_launch), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, getString(R.string.toast_error, e.message), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.btn_info).setOnClickListener {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = "package:${app.info.packageName}".toUri()
|
||||
startActivity(intent)
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
btnFreeze.setOnClickListener {
|
||||
if (runRootCommand("pm disable ${app.info.packageName}")) {
|
||||
Toast.makeText(this, getString(R.string.toast_frozen, app.label), Toast.LENGTH_SHORT).show()
|
||||
updateFreezerList(app.info.packageName, true)
|
||||
refreshApp()
|
||||
dialog.dismiss()
|
||||
} else {
|
||||
Toast.makeText(this, getString(R.string.toast_freeze_failed), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
btnUnfreeze.setOnClickListener {
|
||||
if (runRootCommand("pm enable ${app.info.packageName}")) {
|
||||
Toast.makeText(this, getString(R.string.toast_unfrozen, app.label), Toast.LENGTH_SHORT).show()
|
||||
updateFreezerList(app.info.packageName, false)
|
||||
refreshApp()
|
||||
dialog.dismiss()
|
||||
} else {
|
||||
Toast.makeText(this, getString(R.string.toast_unfreeze_failed), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.setContentView(view)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun refreshApp() {
|
||||
loadApps()
|
||||
}
|
||||
|
||||
private fun runRootCommand(cmd: String): Boolean {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
process.waitFor() == 0
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
inner class AppListAdapter(
|
||||
private val apps: List<AppItem>,
|
||||
private val onClick: (AppItem) -> Unit,
|
||||
private val onCheckChanged: (AppItem, Boolean) -> Unit
|
||||
) : RecyclerView.Adapter<AppListAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val icon: ImageView = view.findViewById(R.id.app_icon)
|
||||
val name: TextView = view.findViewById(R.id.app_name)
|
||||
val pkg: TextView = view.findViewById(R.id.app_package)
|
||||
val frozen: TextView = view.findViewById(R.id.tv_frozen_status)
|
||||
val checkbox: android.widget.CheckBox = view.findViewById(R.id.cb_include)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_app_list, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val app = apps[position]
|
||||
holder.name.text = app.label
|
||||
holder.pkg.text = app.info.packageName
|
||||
holder.icon.setImageDrawable(app.info.loadIcon(packageManager))
|
||||
ThemeManager.applyToView(holder.itemView, this@FreezerActivity)
|
||||
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = app.isChecked
|
||||
|
||||
holder.checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
if (buttonView.isPressed) onCheckChanged(app, isChecked)
|
||||
}
|
||||
|
||||
// Checkbox click must not bubble up to itemView
|
||||
holder.checkbox.setOnClickListener { }
|
||||
|
||||
if (app.isFrozen) {
|
||||
holder.frozen.isVisible = true
|
||||
holder.name.alpha = 0.5f
|
||||
} else {
|
||||
holder.frozen.isGone = true
|
||||
holder.name.alpha = 1.0f
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener { onClick(app) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = apps.size
|
||||
}
|
||||
}
|
||||
169
app/src/main/java/com/fan/edgex/ui/GesturesActivity.kt
Normal file
169
app/src/main/java/com/fan/edgex/ui/GesturesActivity.kt
Normal file
@@ -0,0 +1,169 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigBool
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
class GesturesActivity : AppCompatActivity() {
|
||||
private data class ActionSpec(
|
||||
val viewId: Int,
|
||||
@StringRes val labelRes: Int,
|
||||
val actionKey: String,
|
||||
)
|
||||
|
||||
private data class ZoneSpec(
|
||||
val viewId: Int,
|
||||
@StringRes val titleRes: Int,
|
||||
@StringRes val subtitleRes: Int? = null,
|
||||
val zoneKey: String,
|
||||
@DrawableRes val iconRes: Int,
|
||||
val actions: List<ActionSpec>,
|
||||
)
|
||||
|
||||
private val defaultActions = listOf(
|
||||
ActionSpec(R.id.action_click, R.string.gesture_click, "click"),
|
||||
ActionSpec(R.id.action_double_click, R.string.gesture_double_click, "double_click"),
|
||||
ActionSpec(R.id.action_long_press, R.string.gesture_long_press, "long_press"),
|
||||
)
|
||||
|
||||
private val sideLeftActions = defaultActions + listOf(
|
||||
ActionSpec(R.id.action_swipe_right, R.string.gesture_swipe_right, "swipe_right"),
|
||||
ActionSpec(R.id.action_swipe_up, R.string.gesture_swipe_up, "swipe_up"),
|
||||
ActionSpec(R.id.action_swipe_down, R.string.gesture_swipe_down, "swipe_down"),
|
||||
)
|
||||
|
||||
private val sideRightActions = defaultActions + listOf(
|
||||
ActionSpec(R.id.action_swipe_left, R.string.gesture_swipe_left, "swipe_left"),
|
||||
ActionSpec(R.id.action_swipe_up, R.string.gesture_swipe_up, "swipe_up"),
|
||||
ActionSpec(R.id.action_swipe_down, R.string.gesture_swipe_down, "swipe_down"),
|
||||
)
|
||||
|
||||
private val topActions = defaultActions + listOf(
|
||||
ActionSpec(R.id.action_swipe_down, R.string.gesture_swipe_down, "swipe_down"),
|
||||
ActionSpec(R.id.action_swipe_left, R.string.gesture_swipe_left, "swipe_left"),
|
||||
ActionSpec(R.id.action_swipe_right, R.string.gesture_swipe_right, "swipe_right"),
|
||||
)
|
||||
|
||||
private val bottomActions = defaultActions + listOf(
|
||||
ActionSpec(R.id.action_swipe_up, R.string.gesture_swipe_up, "swipe_up"),
|
||||
ActionSpec(R.id.action_swipe_left, R.string.gesture_swipe_left, "swipe_left"),
|
||||
ActionSpec(R.id.action_swipe_right, R.string.gesture_swipe_right, "swipe_right"),
|
||||
)
|
||||
|
||||
private val zoneSpecs = listOf(
|
||||
ZoneSpec(R.id.zone_left_top, R.string.zone_left_top, zoneKey = "left_top", iconRes = R.drawable.ic_edge_left_top, actions = sideLeftActions),
|
||||
ZoneSpec(R.id.zone_left_mid, R.string.zone_left_mid, zoneKey = "left_mid", iconRes = R.drawable.ic_edge_left_mid, actions = sideLeftActions),
|
||||
ZoneSpec(R.id.zone_left_bottom, R.string.zone_left_bottom, zoneKey = "left_bottom", iconRes = R.drawable.ic_edge_left_bottom, actions = sideLeftActions),
|
||||
ZoneSpec(R.id.zone_left_full, R.string.zone_left_full, R.string.zone_low_priority_subtitle, "left", R.drawable.ic_edge_left_full, sideLeftActions),
|
||||
ZoneSpec(R.id.zone_right_top, R.string.zone_right_top, zoneKey = "right_top", iconRes = R.drawable.ic_edge_right_top, actions = sideRightActions),
|
||||
ZoneSpec(R.id.zone_right_mid, R.string.zone_right_mid, zoneKey = "right_mid", iconRes = R.drawable.ic_edge_right_mid, actions = sideRightActions),
|
||||
ZoneSpec(R.id.zone_right_bottom, R.string.zone_right_bottom, zoneKey = "right_bottom", iconRes = R.drawable.ic_edge_right_bottom, actions = sideRightActions),
|
||||
ZoneSpec(R.id.zone_right_full, R.string.zone_right_full, R.string.zone_low_priority_subtitle, "right", R.drawable.ic_edge_right_full, sideRightActions),
|
||||
ZoneSpec(R.id.zone_top_left, R.string.zone_top_left, zoneKey = "top_left", iconRes = R.drawable.ic_edge_top_left, actions = topActions),
|
||||
ZoneSpec(R.id.zone_top_mid, R.string.zone_top_mid, zoneKey = "top_mid", iconRes = R.drawable.ic_edge_top_mid, actions = topActions),
|
||||
ZoneSpec(R.id.zone_top_right, R.string.zone_top_right, zoneKey = "top_right", iconRes = R.drawable.ic_edge_top_right, actions = topActions),
|
||||
ZoneSpec(R.id.zone_top_full, R.string.zone_top_full, R.string.zone_low_priority_subtitle, "top", R.drawable.ic_edge_top_full, topActions),
|
||||
ZoneSpec(R.id.zone_bottom_left, R.string.zone_bottom_left, zoneKey = "bottom_left", iconRes = R.drawable.ic_edge_bottom_left, actions = bottomActions),
|
||||
ZoneSpec(R.id.zone_bottom_mid, R.string.zone_bottom_mid, zoneKey = "bottom_mid", iconRes = R.drawable.ic_edge_bottom_mid, actions = bottomActions),
|
||||
ZoneSpec(R.id.zone_bottom_right, R.string.zone_bottom_right, zoneKey = "bottom_right", iconRes = R.drawable.ic_edge_bottom_right, actions = bottomActions),
|
||||
ZoneSpec(R.id.zone_bottom_full, R.string.zone_bottom_full, R.string.zone_low_priority_subtitle, "bottom", R.drawable.ic_edge_bottom_full, bottomActions),
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_gestures)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
zoneSpecs.forEach(::setupZone)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
zoneSpecs.forEach(::setupZone)
|
||||
}
|
||||
|
||||
private fun setupZone(spec: ZoneSpec) {
|
||||
val root = findViewById<View>(spec.viewId)
|
||||
val title = getString(spec.titleRes)
|
||||
val zoneKey = spec.zoneKey
|
||||
|
||||
root.findViewById<TextView>(R.id.title).text = title
|
||||
root.findViewById<TextView>(R.id.subtitle).apply {
|
||||
val subtitleRes = spec.subtitleRes
|
||||
if (subtitleRes != null) {
|
||||
text = getString(subtitleRes)
|
||||
isVisible = true
|
||||
} else {
|
||||
text = ""
|
||||
isGone = true
|
||||
}
|
||||
}
|
||||
root.findViewById<ImageView>(R.id.zone_icon).setImageResource(spec.iconRes)
|
||||
|
||||
val checkBox = root.findViewById<android.widget.CheckBox>(R.id.checkbox)
|
||||
val enabledKey = AppConfig.zoneEnabled(zoneKey)
|
||||
checkBox.setOnCheckedChangeListener(null)
|
||||
checkBox.isChecked = getConfigBool(enabledKey)
|
||||
|
||||
checkBox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
if (!buttonView.isPressed) return@setOnCheckedChangeListener
|
||||
putConfig(enabledKey, isChecked)
|
||||
}
|
||||
|
||||
val header = root.findViewById<View>(R.id.header)
|
||||
val body = root.findViewById<View>(R.id.body)
|
||||
val arrow = root.findViewById<ImageView>(R.id.arrow)
|
||||
|
||||
if (!header.hasOnClickListeners()) {
|
||||
header.setOnClickListener {
|
||||
if (body.isVisible) {
|
||||
body.isGone = true
|
||||
arrow.animate().rotation(0f).start()
|
||||
} else {
|
||||
body.isVisible = true
|
||||
arrow.animate().rotation(180f).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spec.actions.forEach { action ->
|
||||
setupAction(root.findViewById(action.viewId), getString(action.labelRes), zoneKey, action.actionKey, title)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAction(actionView: View, label: String, zoneKey: String, actionKey: String, zoneTitle: String) {
|
||||
actionView.findViewById<TextView>(R.id.action_title).text = label
|
||||
|
||||
val fullKey = AppConfig.gestureAction(zoneKey, actionKey)
|
||||
val savedLabel = getConfigString("${fullKey}_label", getString(R.string.action_none))
|
||||
actionView.findViewById<TextView>(R.id.action_subtitle).text = savedLabel
|
||||
|
||||
val savedCode = getConfigString(fullKey, "none")
|
||||
ActionSelectionActivity.applyActionIcon(this, savedCode, actionView.findViewById(R.id.action_icon))
|
||||
|
||||
actionView.setOnClickListener {
|
||||
startActivity(
|
||||
android.content.Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("title", "$zoneTitle / $label")
|
||||
.putExtra("pref_key", fullKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
183
app/src/main/java/com/fan/edgex/ui/KeysActivity.kt
Normal file
183
app/src/main/java/com/fan/edgex/ui/KeysActivity.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigBool
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
class KeysActivity : AppCompatActivity() {
|
||||
|
||||
data class KeyConfig(
|
||||
val keyCode: Int,
|
||||
val nameRes: Int,
|
||||
val iconRes: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
// Only Volume Up, Volume Down and Power keys
|
||||
val SUPPORTED_KEYS = listOf(
|
||||
KeyConfig(KeyEvent.KEYCODE_VOLUME_UP, R.string.key_volume_up, R.drawable.ic_volume_up),
|
||||
KeyConfig(KeyEvent.KEYCODE_VOLUME_DOWN, R.string.key_volume_down, R.drawable.ic_volume_down),
|
||||
KeyConfig(KeyEvent.KEYCODE_POWER, R.string.key_power, R.drawable.ic_power)
|
||||
)
|
||||
}
|
||||
|
||||
private val keyViews = mutableMapOf<Int, View>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_keys)
|
||||
|
||||
// Immersive Header Fix
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
// Add Key Items
|
||||
val container = findViewById<LinearLayout>(R.id.keys_container)
|
||||
for (keyConfig in SUPPORTED_KEYS) {
|
||||
val view = createKeyItem(keyConfig)
|
||||
keyViews[keyConfig.keyCode] = view
|
||||
container.addView(view)
|
||||
}
|
||||
ThemeManager.applyToActivity(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Refresh UI to show new selections
|
||||
refreshAllKeyItems()
|
||||
}
|
||||
|
||||
private fun createKeyItem(config: KeyConfig): View {
|
||||
val keyCode = config.keyCode
|
||||
val nameRes = config.nameRes
|
||||
val iconRes = config.iconRes
|
||||
|
||||
val inflater = LayoutInflater.from(this)
|
||||
val view = inflater.inflate(R.layout.item_key, null)
|
||||
ThemeManager.applyToView(view, this)
|
||||
|
||||
val title = view.findViewById<TextView>(R.id.title)
|
||||
val subtitle = view.findViewById<TextView>(R.id.subtitle)
|
||||
val icon = view.findViewById<ImageView>(R.id.key_icon)
|
||||
val checkbox = view.findViewById<CheckBox>(R.id.checkbox)
|
||||
val header = view.findViewById<View>(R.id.header)
|
||||
val body = view.findViewById<View>(R.id.body)
|
||||
val arrow = view.findViewById<ImageView>(R.id.arrow)
|
||||
|
||||
// Set Title and Icon
|
||||
title.text = getString(nameRes)
|
||||
icon.setImageResource(iconRes)
|
||||
|
||||
// Load State
|
||||
checkbox.setOnCheckedChangeListener(null)
|
||||
checkbox.isChecked = getConfigBool(AppConfig.keyEnabled(keyCode))
|
||||
|
||||
// Update Subtitle
|
||||
updateKeySubtitle(keyCode, subtitle)
|
||||
|
||||
// Checkbox Click
|
||||
checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
if (!buttonView.isPressed) return@setOnCheckedChangeListener
|
||||
putConfig(AppConfig.keyEnabled(keyCode), isChecked)
|
||||
}
|
||||
|
||||
// Expand/Collapse
|
||||
header.setOnClickListener {
|
||||
if (body.isVisible) {
|
||||
body.isGone = true
|
||||
arrow.animate().rotation(0f).start()
|
||||
} else {
|
||||
body.isVisible = true
|
||||
arrow.animate().rotation(180f).start()
|
||||
}
|
||||
}
|
||||
|
||||
// Setup Actions
|
||||
val keyName = getString(nameRes)
|
||||
setupAction(view.findViewById(R.id.action_click), getString(R.string.key_mode_click), keyCode, "click", keyName)
|
||||
setupAction(view.findViewById(R.id.action_double_click), getString(R.string.key_mode_double_click), keyCode, "double_click", keyName)
|
||||
setupAction(view.findViewById(R.id.action_long_press), getString(R.string.key_mode_long_press), keyCode, "long_press", keyName)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun setupAction(actionView: View, label: String, keyCode: Int, mode: String, keyName: String) {
|
||||
val titleView = actionView.findViewById<TextView>(R.id.action_title)
|
||||
val subtitleView = actionView.findViewById<TextView>(R.id.action_subtitle)
|
||||
|
||||
titleView.text = label
|
||||
|
||||
val prefKey = AppConfig.keyAction(keyCode, mode)
|
||||
|
||||
subtitleView.text = getConfigString("${prefKey}_label", getString(R.string.action_none))
|
||||
ActionSelectionActivity.applyActionIcon(this, getConfigString(prefKey, "none"), actionView.findViewById(R.id.action_icon))
|
||||
|
||||
actionView.setOnClickListener {
|
||||
val intent = Intent(this, ActionSelectionActivity::class.java)
|
||||
intent.putExtra("title", "$keyName / $label")
|
||||
intent.putExtra("pref_key", prefKey)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateKeySubtitle(keyCode: Int, subtitleView: TextView) {
|
||||
val noneLabel = getString(R.string.action_none)
|
||||
val labels = AppConfig.KEY_TRIGGERS.zip(
|
||||
listOf(getString(R.string.key_mode_click), getString(R.string.key_mode_double_click), getString(R.string.key_mode_long_press))
|
||||
).mapNotNull { (trigger, name) ->
|
||||
val label = getConfigString("${AppConfig.keyAction(keyCode, trigger)}_label")
|
||||
label.takeIf { it.isNotEmpty() && it != noneLabel }?.let { "$name: $it" }
|
||||
}
|
||||
subtitleView.text = labels.joinToString(", ").ifEmpty { getString(R.string.key_not_configured) }
|
||||
}
|
||||
|
||||
private fun refreshAllKeyItems() {
|
||||
for (config in SUPPORTED_KEYS) {
|
||||
val keyCode = config.keyCode
|
||||
keyViews[keyCode]?.let { view ->
|
||||
val subtitle = view.findViewById<TextView>(R.id.subtitle)
|
||||
val checkbox = view.findViewById<CheckBox>(R.id.checkbox)
|
||||
|
||||
// Refresh checkbox state
|
||||
checkbox.setOnCheckedChangeListener(null)
|
||||
checkbox.isChecked = getConfigBool(AppConfig.keyEnabled(keyCode))
|
||||
checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
if (!buttonView.isPressed) return@setOnCheckedChangeListener
|
||||
putConfig(AppConfig.keyEnabled(keyCode), isChecked)
|
||||
}
|
||||
|
||||
// Refresh subtitle
|
||||
updateKeySubtitle(keyCode, subtitle)
|
||||
|
||||
// Refresh action subtitles
|
||||
refreshAction(view.findViewById(R.id.action_click), keyCode, "click")
|
||||
refreshAction(view.findViewById(R.id.action_double_click), keyCode, "double_click")
|
||||
refreshAction(view.findViewById(R.id.action_long_press), keyCode, "long_press")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAction(actionView: View, keyCode: Int, mode: String) {
|
||||
val prefKey = AppConfig.keyAction(keyCode, mode)
|
||||
actionView.findViewById<TextView>(R.id.action_subtitle).text =
|
||||
getConfigString("${prefKey}_label", getString(R.string.action_none))
|
||||
ActionSelectionActivity.applyActionIcon(this, getConfigString(prefKey, "none"), actionView.findViewById(R.id.action_icon))
|
||||
}
|
||||
}
|
||||
28
app/src/main/java/com/fan/edgex/ui/MainActivity.kt
Normal file
28
app/src/main/java/com/fan/edgex/ui/MainActivity.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.fan.edgex.config.FreezerBootstrap
|
||||
import com.fan.edgex.config.ModuleActivationState
|
||||
import com.fan.edgex.config.broadcastFullConfigSnapshot
|
||||
import com.fan.edgex.config.syncRuntimeEnableFlagsFromConfiguredActions
|
||||
import com.fan.edgex.ui.compose.EdgeXApp
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = true
|
||||
isAppearanceLightNavigationBars = true
|
||||
}
|
||||
syncRuntimeEnableFlagsFromConfiguredActions()
|
||||
ModuleActivationState.requestRefresh(this)
|
||||
broadcastFullConfigSnapshot()
|
||||
FreezerBootstrap.ensureMigrated(this)
|
||||
setContent {
|
||||
EdgeXApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
395
app/src/main/java/com/fan/edgex/ui/MultiActionEditActivity.kt
Normal file
395
app/src/main/java/com/fan/edgex/ui/MultiActionEditActivity.kt
Normal file
@@ -0,0 +1,395 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.MultiAction
|
||||
import com.fan.edgex.config.MultiActionStep
|
||||
import com.fan.edgex.config.MultiActionStore
|
||||
import com.fan.edgex.config.broadcastFullConfigSnapshot
|
||||
import com.fan.edgex.config.configPrefs
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
import com.fan.edgex.config.requestHookActionExecution
|
||||
|
||||
class MultiActionEditActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ID = "multi_action_id"
|
||||
const val EXTRA_IS_NEW = "is_new"
|
||||
}
|
||||
|
||||
private lateinit var multiActionId: String
|
||||
private lateinit var steps: MutableList<MultiActionStep>
|
||||
private lateinit var adapter: StepAdapter
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var tvTitle: TextView
|
||||
|
||||
private var multiActionName = ""
|
||||
private var currentIconRef = ""
|
||||
private var isModified = false
|
||||
private var isNew = false
|
||||
|
||||
private var addingStep = false
|
||||
private var editingStepIndex = -1
|
||||
|
||||
private lateinit var ivIconPreview: ImageView
|
||||
|
||||
private val iconPickerLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
val ref = result.data?.getStringExtra(AppIconPickerActivity.EXTRA_ICON_REF) ?: return@registerForActivityResult
|
||||
MultiActionIconUtils.deleteIfCustom(this, currentIconRef)
|
||||
currentIconRef = ref
|
||||
MultiActionIconUtils.applyTo(this, ivIconPreview, currentIconRef)
|
||||
markModified()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_multi_action_edit)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
multiActionId = intent.getStringExtra(EXTRA_ID) ?: run { finish(); return }
|
||||
isNew = intent.getBooleanExtra(EXTRA_IS_NEW, false)
|
||||
|
||||
if (isNew) {
|
||||
multiActionName = multiActionId
|
||||
steps = mutableListOf()
|
||||
currentIconRef = ""
|
||||
} else {
|
||||
val existing = MultiActionStore.get(configPrefs(), multiActionId) ?: run { finish(); return }
|
||||
multiActionName = existing.name
|
||||
steps = existing.steps
|
||||
currentIconRef = existing.iconRef
|
||||
}
|
||||
|
||||
tvTitle = findViewById(R.id.tv_title)
|
||||
tvTitle.text = multiActionName
|
||||
tvTitle.setOnClickListener { showRenameDialog() }
|
||||
|
||||
ivIconPreview = findViewById(R.id.iv_icon_preview)
|
||||
MultiActionIconUtils.applyTo(this, ivIconPreview, currentIconRef)
|
||||
findViewById<View>(R.id.btn_icon).setOnClickListener { openIconPicker() }
|
||||
|
||||
recyclerView = findViewById(R.id.recycler_view)
|
||||
tvEmpty = findViewById(R.id.tv_empty)
|
||||
|
||||
adapter = StepAdapter(steps)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
insets.getInsets(android.view.WindowInsets.Type.statusBars()).top,
|
||||
view.paddingRight,
|
||||
view.paddingBottom,
|
||||
)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { handleBack() }
|
||||
findViewById<View>(R.id.btn_save).setOnClickListener { saveAndShowToast() }
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
handleBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val fab = findViewById<View>(R.id.btn_fab)
|
||||
fab.setOnClickListener { startAddStep() }
|
||||
fab.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val navBottom = insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
lp.bottomMargin = (16 * resources.displayMetrics.density + navBottom).toInt()
|
||||
view.layoutParams = lp
|
||||
insets
|
||||
}
|
||||
|
||||
updateEmpty()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ThemeManager.applyToActivity(this)
|
||||
MultiActionIconUtils.applyTo(this, ivIconPreview, currentIconRef)
|
||||
consumeTempStep()
|
||||
}
|
||||
|
||||
private fun consumeTempStep() {
|
||||
val tempKey = MultiActionStore.tempStepKey()
|
||||
val code = getConfigString(tempKey)
|
||||
val label = getConfigString("${tempKey}_label")
|
||||
|
||||
if (code.isBlank() || code == "none") {
|
||||
addingStep = false
|
||||
editingStepIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
val step = MultiActionStep(code, label.ifBlank { code })
|
||||
|
||||
when {
|
||||
addingStep -> {
|
||||
addingStep = false
|
||||
steps.add(step)
|
||||
adapter.notifyItemInserted(steps.size - 1)
|
||||
markModified()
|
||||
}
|
||||
editingStepIndex >= 0 -> {
|
||||
val idx = editingStepIndex
|
||||
editingStepIndex = -1
|
||||
steps[idx] = step
|
||||
adapter.notifyItemChanged(idx)
|
||||
markModified()
|
||||
}
|
||||
}
|
||||
|
||||
putConfig(tempKey, "")
|
||||
putConfig("${tempKey}_label", "")
|
||||
updateEmpty()
|
||||
}
|
||||
|
||||
private fun startAddStep() {
|
||||
addingStep = true
|
||||
editingStepIndex = -1
|
||||
putConfig(MultiActionStore.tempStepKey(), "")
|
||||
putConfig("${MultiActionStore.tempStepKey()}_label", "")
|
||||
startActivity(Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("pref_key", MultiActionStore.tempStepKey())
|
||||
.putExtra("title", getString(R.string.header_action_selection)))
|
||||
}
|
||||
|
||||
private fun editStep(index: Int) {
|
||||
addingStep = false
|
||||
editingStepIndex = index
|
||||
val current = steps[index]
|
||||
putConfig(MultiActionStore.tempStepKey(), current.code)
|
||||
putConfig("${MultiActionStore.tempStepKey()}_label", current.label)
|
||||
startActivity(Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("pref_key", MultiActionStore.tempStepKey())
|
||||
.putExtra("title", current.label))
|
||||
}
|
||||
|
||||
private fun showStepOptions(index: Int) {
|
||||
val step = steps[index]
|
||||
val options = arrayOf(
|
||||
getString(R.string.action_edit),
|
||||
getString(R.string.multi_action_step_edit_icon_name),
|
||||
getString(R.string.copy_copy),
|
||||
getString(R.string.action_execute),
|
||||
getString(R.string.multi_action_step_info),
|
||||
getString(R.string.action_delete),
|
||||
)
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(step.label)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> editStep(index)
|
||||
1 -> showEditIconNameDialog(index)
|
||||
2 -> copyStep(index)
|
||||
3 -> executeStep(step)
|
||||
4 -> showStepInfo(step)
|
||||
5 -> deleteStep(index)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showEditIconNameDialog(index: Int) {
|
||||
val step = steps[index]
|
||||
val editText = android.widget.EditText(this).apply {
|
||||
setText(step.label)
|
||||
selectAll()
|
||||
hint = getString(R.string.multi_action_step_edit_label_hint)
|
||||
}
|
||||
val container = android.widget.FrameLayout(this).apply {
|
||||
setPadding(48, 16, 48, 0)
|
||||
addView(editText)
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.multi_action_step_edit_icon_name)
|
||||
.setView(container)
|
||||
.setPositiveButton(R.string.btn_save) { _, _ ->
|
||||
val newLabel = editText.text.toString().trim().ifBlank { step.label }
|
||||
steps[index] = step.copy(label = newLabel)
|
||||
adapter.notifyItemChanged(index)
|
||||
markModified()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun copyStep(index: Int) {
|
||||
val copy = steps[index].copy()
|
||||
steps.add(index + 1, copy)
|
||||
adapter.notifyItemInserted(index + 1)
|
||||
markModified()
|
||||
updateEmpty()
|
||||
}
|
||||
|
||||
private fun executeStep(step: MultiActionStep) {
|
||||
requestHookActionExecution(step.code)
|
||||
}
|
||||
|
||||
private fun showStepInfo(step: MultiActionStep) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.multi_action_step_info)
|
||||
.setMessage(
|
||||
"${getString(R.string.multi_action_info_label, step.label)}\n" +
|
||||
"${getString(R.string.multi_action_info_code, step.code)}"
|
||||
)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteStep(index: Int) {
|
||||
steps.removeAt(index)
|
||||
adapter.notifyItemRemoved(index)
|
||||
markModified()
|
||||
updateEmpty()
|
||||
}
|
||||
|
||||
private fun showRenameDialog() {
|
||||
val editText = android.widget.EditText(this).apply {
|
||||
setText(multiActionName)
|
||||
selectAll()
|
||||
hint = getString(R.string.multi_action_edit_name_hint)
|
||||
}
|
||||
val container = android.widget.FrameLayout(this).apply {
|
||||
setPadding(48, 16, 48, 0)
|
||||
addView(editText)
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.action_rename)
|
||||
.setView(container)
|
||||
.setPositiveButton(R.string.btn_save) { _, _ ->
|
||||
val newName = editText.text.toString().trim().ifBlank { multiActionName }
|
||||
multiActionName = newName
|
||||
tvTitle.text = multiActionName
|
||||
markModified()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun markModified() {
|
||||
isModified = true
|
||||
}
|
||||
|
||||
private fun saveAndShowToast() {
|
||||
doSave()
|
||||
Toast.makeText(this, R.string.action_saved, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun openIconPicker() {
|
||||
iconPickerLauncher.launch(Intent(this, AppIconPickerActivity::class.java))
|
||||
}
|
||||
|
||||
private fun doSave() {
|
||||
val updated = MultiAction(multiActionId, multiActionName, steps, currentIconRef)
|
||||
MultiActionStore.save(configPrefs(), updated)
|
||||
broadcastFullConfigSnapshot()
|
||||
isModified = false
|
||||
isNew = false
|
||||
}
|
||||
|
||||
private fun handleBack() {
|
||||
if (!isModified) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.multi_action_unsaved_title)
|
||||
.setMessage(R.string.multi_action_unsaved_message)
|
||||
.setPositiveButton(R.string.btn_save) { _, _ ->
|
||||
doSave()
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(R.string.multi_action_discard) { _, _ ->
|
||||
if (isNew) MultiActionStore.delete(configPrefs(), multiActionId)
|
||||
finish()
|
||||
}
|
||||
.setNeutralButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun updateEmpty() {
|
||||
val empty = steps.isEmpty()
|
||||
tvEmpty.visibility = if (empty) View.VISIBLE else View.GONE
|
||||
recyclerView.visibility = if (empty) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
inner class StepAdapter(
|
||||
private val items: MutableList<MultiActionStep>,
|
||||
) : RecyclerView.Adapter<StepAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val icon: ImageView = v.findViewById(R.id.icon)
|
||||
val tvLabel: TextView = v.findViewById(R.id.tv_label)
|
||||
val btnMore: View = v.findViewById(R.id.btn_more)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_multi_action_step, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val step = items[position]
|
||||
holder.tvLabel.text = step.label
|
||||
holder.icon.setImageResource(iconForStep(step))
|
||||
ThemeManager.applyToView(holder.itemView, this@MultiActionEditActivity)
|
||||
holder.itemView.setOnLongClickListener {
|
||||
showStepOptions(position)
|
||||
true
|
||||
}
|
||||
holder.btnMore.setOnClickListener { showStepOptions(position) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
|
||||
private fun iconForStep(step: MultiActionStep): Int = when {
|
||||
step.code == "back" -> R.drawable.ic_arrow_back
|
||||
step.code == "home" -> R.drawable.ic_home
|
||||
step.code == "recents" || step.code == "recent" -> R.drawable.ic_recents
|
||||
step.code == "expand_notifications" -> R.drawable.ic_notifications
|
||||
step.code == "screenshot" -> R.drawable.ic_camera
|
||||
step.code == "lock_screen" -> R.drawable.ic_power
|
||||
step.code == "kill_app" -> R.drawable.ic_kill_app
|
||||
step.code == "clear_background" -> R.drawable.ic_clear_recent
|
||||
step.code == "freezer_drawer" -> R.drawable.ic_freezer
|
||||
step.code == "refreeze" -> R.drawable.ic_refreeze
|
||||
step.code == "clipboard" -> R.drawable.ic_paste
|
||||
step.code == "universal_copy" -> R.drawable.ic_content_copy
|
||||
step.code == "brightness_up" -> R.drawable.ic_brightness_up
|
||||
step.code == "brightness_down" -> R.drawable.ic_brightness_down
|
||||
step.code == "volume_up" -> R.drawable.ic_volume_up
|
||||
step.code == "volume_down" -> R.drawable.ic_volume_down
|
||||
step.code.startsWith("music_control:") -> R.drawable.ic_music
|
||||
step.code.startsWith("launch_app:") -> R.drawable.ic_launch_app
|
||||
step.code.startsWith("app_shortcut:") -> R.drawable.ic_app_shortcut
|
||||
step.code.startsWith("shell:") -> R.drawable.ic_terminal
|
||||
step.code.startsWith("multi_action:") -> R.drawable.ic_multi_action
|
||||
else -> R.drawable.ic_action_dot
|
||||
}
|
||||
}
|
||||
76
app/src/main/java/com/fan/edgex/ui/MultiActionIconUtils.kt
Normal file
76
app/src/main/java/com/fan/edgex/ui/MultiActionIconUtils.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.widget.ImageView
|
||||
import com.fan.edgex.R
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object MultiActionIconUtils {
|
||||
const val PREFIX_APP = "app:"
|
||||
const val PREFIX_CUSTOM = "custom:"
|
||||
|
||||
fun resolveDrawable(context: Context, iconRef: String): Drawable? = when {
|
||||
iconRef.startsWith(PREFIX_APP) -> loadAppIcon(context, iconRef.removePrefix(PREFIX_APP))
|
||||
iconRef.startsWith(PREFIX_CUSTOM) -> {
|
||||
val bmp = loadCustomBitmap(context, iconRef.removePrefix(PREFIX_CUSTOM))
|
||||
bmp?.let { BitmapDrawable(context.resources, it) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun loadAppIcon(context: Context, packageName: String): Drawable? {
|
||||
return try {
|
||||
context.packageManager.getApplicationIcon(packageName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCustomBitmap(context: Context, filename: String): Bitmap? {
|
||||
val file = File(context.filesDir, "multi_action_icons/$filename")
|
||||
return if (file.exists()) BitmapFactory.decodeFile(file.absolutePath) else null
|
||||
}
|
||||
|
||||
fun saveCustomIconFromUri(context: Context, uri: Uri): String? {
|
||||
val iconDir = File(context.filesDir, "multi_action_icons")
|
||||
iconDir.mkdirs()
|
||||
val filename = "${UUID.randomUUID()}.png"
|
||||
val file = File(iconDir, filename)
|
||||
return runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
filename
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun deleteIfCustom(context: Context, iconRef: String) {
|
||||
if (!iconRef.startsWith(PREFIX_CUSTOM)) return
|
||||
File(context.filesDir, "multi_action_icons/${iconRef.removePrefix(PREFIX_CUSTOM)}").delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply iconRef to an ImageView sitting inside a themed circle background.
|
||||
* - Default (empty): shows ic_multi_action with white tint
|
||||
* - App/Custom: shows the actual drawable with no tint so colors are preserved
|
||||
*/
|
||||
fun applyTo(context: Context, imageView: ImageView, iconRef: String) {
|
||||
val drawable = if (iconRef.isNotEmpty()) resolveDrawable(context, iconRef) else null
|
||||
if (drawable != null) {
|
||||
imageView.setImageDrawable(drawable)
|
||||
imageView.imageTintList = null
|
||||
imageView.clearColorFilter()
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.ic_multi_action)
|
||||
imageView.imageTintList =
|
||||
android.content.res.ColorStateList.valueOf(android.graphics.Color.WHITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
239
app/src/main/java/com/fan/edgex/ui/MultiActionsListActivity.kt
Normal file
239
app/src/main/java/com/fan/edgex/ui/MultiActionsListActivity.kt
Normal file
@@ -0,0 +1,239 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.MultiAction
|
||||
import com.fan.edgex.config.MultiActionStore
|
||||
import com.fan.edgex.config.broadcastFullConfigSnapshot
|
||||
import com.fan.edgex.config.configPrefs
|
||||
import com.fan.edgex.config.putConfig
|
||||
import com.fan.edgex.config.requestHookActionExecution
|
||||
|
||||
class MultiActionsListActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_MODE = "mode"
|
||||
const val MODE_PICK = "pick"
|
||||
const val EXTRA_PREF_KEY = "pref_key"
|
||||
const val EXTRA_TITLE = "title"
|
||||
}
|
||||
|
||||
private lateinit var adapter: MultiActionAdapter
|
||||
private val items = mutableListOf<MultiAction>()
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var tvEmpty: TextView
|
||||
private var pickMode = false
|
||||
private var prefKey = ""
|
||||
|
||||
private var pendingIconMultiAction: MultiAction? = null
|
||||
private val iconPickerLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val action = pendingIconMultiAction ?: return@registerForActivityResult
|
||||
pendingIconMultiAction = null
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
val newRef = result.data?.getStringExtra(AppIconPickerActivity.EXTRA_ICON_REF) ?: return@registerForActivityResult
|
||||
MultiActionIconUtils.deleteIfCustom(this, action.iconRef)
|
||||
MultiActionStore.save(configPrefs(), action.copy(iconRef = newRef))
|
||||
broadcastFullConfigSnapshot()
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_multi_actions_list)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
pickMode = intent.getStringExtra(EXTRA_MODE) == MODE_PICK
|
||||
prefKey = intent.getStringExtra(EXTRA_PREF_KEY) ?: ""
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
insets.getInsets(android.view.WindowInsets.Type.statusBars()).top,
|
||||
view.paddingRight,
|
||||
view.paddingBottom,
|
||||
)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
recyclerView = findViewById(R.id.recycler_view)
|
||||
tvEmpty = findViewById(R.id.tv_empty)
|
||||
|
||||
adapter = MultiActionAdapter(items,
|
||||
onClick = { multiAction ->
|
||||
if (pickMode) {
|
||||
pickAndReturn(multiAction)
|
||||
} else {
|
||||
openEdit(multiAction.id)
|
||||
}
|
||||
},
|
||||
onLongClick = { multiAction ->
|
||||
showItemOptions(multiAction)
|
||||
},
|
||||
)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
val fab = findViewById<View>(R.id.btn_fab)
|
||||
fab.setOnClickListener { createNew() }
|
||||
fab.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val navBottom = insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom
|
||||
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
|
||||
lp.bottomMargin = (16 * resources.displayMetrics.density + navBottom).toInt()
|
||||
view.layoutParams = lp
|
||||
insets
|
||||
}
|
||||
|
||||
loadData()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ThemeManager.applyToActivity(this)
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
val prefs = configPrefs()
|
||||
items.clear()
|
||||
items.addAll(MultiActionStore.getAll(prefs))
|
||||
adapter.notifyDataSetChanged()
|
||||
updateEmpty()
|
||||
}
|
||||
|
||||
private fun updateEmpty() {
|
||||
val empty = items.isEmpty()
|
||||
tvEmpty.visibility = if (empty) View.VISIBLE else View.GONE
|
||||
recyclerView.visibility = if (empty) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private fun createNew() {
|
||||
val id = MultiActionStore.generateId()
|
||||
startActivity(Intent(this, MultiActionEditActivity::class.java)
|
||||
.putExtra(MultiActionEditActivity.EXTRA_ID, id)
|
||||
.putExtra(MultiActionEditActivity.EXTRA_IS_NEW, true))
|
||||
}
|
||||
|
||||
private fun openEdit(id: String) {
|
||||
startActivity(Intent(this, MultiActionEditActivity::class.java)
|
||||
.putExtra(MultiActionEditActivity.EXTRA_ID, id))
|
||||
}
|
||||
|
||||
private fun pickAndReturn(multiAction: MultiAction) {
|
||||
if (prefKey.isNotBlank()) {
|
||||
putConfig(prefKey, MultiActionStore.actionCode(multiAction.id))
|
||||
putConfig("${prefKey}_label", multiAction.name)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun showItemOptions(multiAction: MultiAction) {
|
||||
val options = arrayOf(
|
||||
getString(R.string.action_edit),
|
||||
getString(R.string.action_rename),
|
||||
getString(R.string.multi_action_option_edit_icon),
|
||||
getString(R.string.action_execute),
|
||||
getString(R.string.action_delete),
|
||||
)
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(multiAction.name)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> openEdit(multiAction.id)
|
||||
1 -> showRenameDialog(multiAction)
|
||||
2 -> openIconPicker(multiAction)
|
||||
3 -> requestHookActionExecution(MultiActionStore.actionCode(multiAction.id))
|
||||
4 -> showDeleteConfirm(multiAction)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openIconPicker(multiAction: MultiAction) {
|
||||
pendingIconMultiAction = multiAction
|
||||
iconPickerLauncher.launch(Intent(this, AppIconPickerActivity::class.java))
|
||||
}
|
||||
|
||||
private fun showRenameDialog(multiAction: MultiAction) {
|
||||
val editText = android.widget.EditText(this).apply {
|
||||
setText(multiAction.name)
|
||||
selectAll()
|
||||
hint = getString(R.string.multi_action_edit_name_hint)
|
||||
}
|
||||
val container = android.widget.FrameLayout(this).apply {
|
||||
setPadding(48, 16, 48, 0)
|
||||
addView(editText)
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.action_rename)
|
||||
.setView(container)
|
||||
.setPositiveButton(R.string.btn_save) { _, _ ->
|
||||
val newName = editText.text.toString().trim().ifBlank { multiAction.name }
|
||||
val updated = multiAction.copy(name = newName)
|
||||
MultiActionStore.save(configPrefs(), updated)
|
||||
broadcastFullConfigSnapshot()
|
||||
loadData()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showDeleteConfirm(multiAction: MultiAction) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.multi_action_option_delete_confirm, multiAction.name))
|
||||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
MultiActionStore.delete(configPrefs(), multiAction.id)
|
||||
broadcastFullConfigSnapshot()
|
||||
loadData()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
inner class MultiActionAdapter(
|
||||
private val items: List<MultiAction>,
|
||||
private val onClick: (MultiAction) -> Unit,
|
||||
private val onLongClick: (MultiAction) -> Unit,
|
||||
) : RecyclerView.Adapter<MultiActionAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val tvName: TextView = v.findViewById(R.id.tv_name)
|
||||
val tvCount: TextView = v.findViewById(R.id.tv_count)
|
||||
val icon: ImageView = v.findViewById(R.id.icon)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_multi_action, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.tvName.text = item.name
|
||||
holder.tvCount.text = getString(R.string.multi_action_step_count, item.steps.size)
|
||||
ThemeManager.applyToView(holder.itemView, this@MultiActionsListActivity)
|
||||
MultiActionIconUtils.applyTo(this@MultiActionsListActivity, holder.icon, item.iconRef)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
holder.itemView.setOnLongClickListener {
|
||||
onLongClick(item)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
80
app/src/main/java/com/fan/edgex/ui/MusicControlActivity.kt
Normal file
80
app/src/main/java/com/fan/edgex/ui/MusicControlActivity.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
@Deprecated("Use Compose MusicControlSheet instead")
|
||||
class MusicControlActivity : AppCompatActivity() {
|
||||
|
||||
data class MusicOption(
|
||||
val label: String,
|
||||
val code: String,
|
||||
@DrawableRes val iconRes: Int,
|
||||
)
|
||||
|
||||
private val options get() = listOf(
|
||||
MusicOption(getString(R.string.action_music_play_pause), "play_pause", R.drawable.ic_music_play_pause),
|
||||
MusicOption(getString(R.string.action_music_stop), "stop", R.drawable.ic_music_stop),
|
||||
MusicOption(getString(R.string.action_music_previous), "previous", R.drawable.ic_music_previous),
|
||||
MusicOption(getString(R.string.action_music_next), "next", R.drawable.ic_music_next),
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_action_selection)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<TextView>(R.id.tv_subtitle).text = getString(R.string.header_music_control)
|
||||
|
||||
val prefKey = intent.getStringExtra("pref_key") ?: "unknown"
|
||||
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = OptionsAdapter(options) { option ->
|
||||
putConfig(prefKey, "music_control:${option.code}")
|
||||
putConfig("${prefKey}_label", getString(R.string.label_music_prefix, option.label))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
inner class OptionsAdapter(
|
||||
private val items: List<MusicOption>,
|
||||
private val onClick: (MusicOption) -> Unit,
|
||||
) : RecyclerView.Adapter<OptionsAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val icon: ImageView = v.findViewById(R.id.icon)
|
||||
val title: TextView = v.findViewById(R.id.title)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_action_selection, parent, false)
|
||||
return ViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.title.text = item.label
|
||||
holder.icon.setImageResource(item.iconRes)
|
||||
ThemeManager.applyToView(holder.itemView, this@MusicControlActivity)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
352
app/src/main/java/com/fan/edgex/ui/PanelConfigActivity.kt
Normal file
352
app/src/main/java/com/fan/edgex/ui/PanelConfigActivity.kt
Normal file
@@ -0,0 +1,352 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
class PanelConfigActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var mode: String
|
||||
private lateinit var content: LinearLayout
|
||||
private val slotRows = mutableListOf<SlotRow>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_CUSTOM
|
||||
buildLayout()
|
||||
ThemeManager.applyToActivity(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
syncRuntimeTitles()
|
||||
refreshSlots()
|
||||
ThemeManager.applyToActivity(this)
|
||||
}
|
||||
|
||||
private fun buildLayout() {
|
||||
val dp = resources.displayMetrics.density
|
||||
val root = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(resources.getColor(R.color.ui_background, theme))
|
||||
}
|
||||
|
||||
val header = LinearLayout(this).apply {
|
||||
id = R.id.header_container
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setBackgroundColor(resources.getColor(R.color.ui_header_background, theme))
|
||||
setPadding((8 * dp).toInt(), 0, (16 * dp).toInt(), 0)
|
||||
minimumHeight = resources.getDimensionPixelSize(
|
||||
androidx.appcompat.R.dimen.abc_action_bar_default_height_material
|
||||
)
|
||||
setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
insets.getInsets(android.view.WindowInsets.Type.statusBars()).top,
|
||||
view.paddingRight,
|
||||
view.paddingBottom,
|
||||
)
|
||||
insets
|
||||
}
|
||||
}
|
||||
header.addView(ImageView(this).apply {
|
||||
setImageResource(R.drawable.ic_arrow_back)
|
||||
setColorFilter(resources.getColor(R.color.ui_header_text, theme))
|
||||
setPadding((16 * dp).toInt(), (16 * dp).toInt(), (16 * dp).toInt(), (16 * dp).toInt())
|
||||
setOnClickListener { finish() }
|
||||
}, LinearLayout.LayoutParams((56 * dp).toInt(), (56 * dp).toInt()))
|
||||
header.addView(TextView(this).apply {
|
||||
text = titleForMode()
|
||||
textSize = 20f
|
||||
setTextColor(resources.getColor(R.color.ui_header_text, theme))
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
}, LinearLayout.LayoutParams(0, (56 * dp).toInt(), 1f))
|
||||
root.addView(header)
|
||||
|
||||
val scrollView = ScrollView(this)
|
||||
content = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, (8 * dp).toInt(), 0, (24 * dp).toInt())
|
||||
}
|
||||
scrollView.addView(content)
|
||||
root.addView(scrollView, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f,
|
||||
))
|
||||
|
||||
setContentView(root)
|
||||
populateContent()
|
||||
}
|
||||
|
||||
private fun populateContent() {
|
||||
content.removeAllViews()
|
||||
slotRows.clear()
|
||||
if (mode == MODE_CUSTOM) {
|
||||
repeat(AppConfig.CUSTOM_PANEL_ROWS) { row ->
|
||||
addSection(getString(R.string.panel_row_title, row + 1))
|
||||
val line = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
repeat(AppConfig.CUSTOM_PANEL_COLUMNS) { column ->
|
||||
line.addView(createSlotRow(
|
||||
title = getString(R.string.panel_column_title, column + 1),
|
||||
prefKey = AppConfig.customPanelSlot(row, column),
|
||||
))
|
||||
}
|
||||
content.addView(line)
|
||||
}
|
||||
} else {
|
||||
val side = sideForMode()
|
||||
repeat(AppConfig.SIDE_BAR_SLOTS) { index ->
|
||||
content.addView(createSlotRow(
|
||||
title = getString(R.string.panel_slot_title, index + 1),
|
||||
prefKey = AppConfig.sideBarSlot(side, index),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSlotRow(title: String, prefKey: String): View {
|
||||
val dp = resources.displayMetrics.density
|
||||
val row = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
|
||||
background = obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)).use {
|
||||
it.getDrawable(0)
|
||||
}
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
val iconBox = FrameLayout(this).apply {
|
||||
tag = "theme_icon_bg"
|
||||
background = resources.getDrawable(R.drawable.circle_background_teal, theme)
|
||||
}
|
||||
val icon = ImageView(this).apply {
|
||||
setImageResource(R.drawable.ic_action_dot)
|
||||
setColorFilter(resources.getColor(R.color.ui_icon_tint, theme))
|
||||
}
|
||||
iconBox.addView(icon, FrameLayout.LayoutParams((24 * dp).toInt(), (24 * dp).toInt(), Gravity.CENTER))
|
||||
row.addView(iconBox, LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()))
|
||||
|
||||
val texts = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((16 * dp).toInt(), 0, 0, 0)
|
||||
}
|
||||
val titleView = TextView(this).apply {
|
||||
text = title
|
||||
textSize = 16f
|
||||
setTextColor(resources.getColor(R.color.ui_text_primary, theme))
|
||||
}
|
||||
val subtitle = TextView(this).apply {
|
||||
textSize = 13f
|
||||
setTextColor(resources.getColor(R.color.ui_text_secondary, theme))
|
||||
maxLines = 1
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
}
|
||||
texts.addView(titleView)
|
||||
texts.addView(subtitle)
|
||||
row.addView(texts, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
|
||||
row.setOnClickListener {
|
||||
startActivity(Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("pref_key", prefKey)
|
||||
.putExtra("title", title)
|
||||
.putExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES, excludedActionCodes()))
|
||||
}
|
||||
slotRows += SlotRow(prefKey, icon, subtitle)
|
||||
ThemeManager.applyToView(row, this)
|
||||
return row
|
||||
}
|
||||
|
||||
private fun addSection(title: String) {
|
||||
val dp = resources.displayMetrics.density
|
||||
content.addView(TextView(this).apply {
|
||||
text = title
|
||||
textSize = 18f
|
||||
setTextColor(resources.getColor(R.color.ui_text_primary, theme))
|
||||
setPadding((16 * dp).toInt(), (20 * dp).toInt(), (16 * dp).toInt(), (8 * dp).toInt())
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshSlots() {
|
||||
slotRows.forEach { slot ->
|
||||
val action = getConfigString(slot.prefKey, "none")
|
||||
val savedLabel = getConfigString("${slot.prefKey}_label", getString(R.string.action_none))
|
||||
val label = displayTitleForAction(action, savedLabel)
|
||||
slot.subtitle.text = label
|
||||
val usesAppIcon = action.startsWith("launch_app:") || action.startsWith("app_shortcut:")
|
||||
val iconSize = if (usesAppIcon) 34 else 24
|
||||
slot.icon.layoutParams = FrameLayout.LayoutParams(
|
||||
(iconSize * resources.displayMetrics.density).toInt(),
|
||||
(iconSize * resources.displayMetrics.density).toInt(),
|
||||
Gravity.CENTER,
|
||||
)
|
||||
slot.icon.setImageDrawable(drawableForAction(action))
|
||||
if (usesAppIcon) {
|
||||
slot.icon.clearColorFilter()
|
||||
} else {
|
||||
slot.icon.setColorFilter(resources.getColor(R.color.ui_icon_tint, theme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncRuntimeTitles() {
|
||||
slotRows.forEach { slot ->
|
||||
val action = getConfigString(slot.prefKey, "none")
|
||||
val label = getConfigString("${slot.prefKey}_label")
|
||||
val title = displayTitleForAction(action, label)
|
||||
if (title.isNotBlank()) {
|
||||
putConfig("${slot.prefKey}_title", title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleForMode(): String = when (mode) {
|
||||
MODE_SIDE_LEFT -> getString(R.string.menu_left_side_bar)
|
||||
MODE_SIDE_RIGHT -> getString(R.string.menu_right_side_bar)
|
||||
else -> getString(R.string.menu_custom_panel)
|
||||
}
|
||||
|
||||
private fun sideForMode(): String = if (mode == MODE_SIDE_RIGHT) "right" else "left"
|
||||
|
||||
private fun excludedActionCodes(): Array<String> = when (mode) {
|
||||
MODE_SIDE_LEFT -> arrayOf(AppConfig.SIDE_BAR_LEFT_ACTION, AppConfig.SIDE_BAR_RIGHT_ACTION, AppConfig.PIE_ACTION, "sub_gesture")
|
||||
MODE_SIDE_RIGHT -> arrayOf(AppConfig.SIDE_BAR_LEFT_ACTION, AppConfig.SIDE_BAR_RIGHT_ACTION, AppConfig.PIE_ACTION, "sub_gesture")
|
||||
else -> arrayOf(AppConfig.CUSTOM_PANEL_ACTION, AppConfig.PIE_ACTION, "sub_gesture")
|
||||
}
|
||||
|
||||
private fun drawableForAction(action: String): android.graphics.drawable.Drawable? {
|
||||
if (action.startsWith("launch_app:")) {
|
||||
val packageName = action.removePrefix("launch_app:")
|
||||
val appIcon = runCatching {
|
||||
packageManager.getApplicationIcon(packageName)
|
||||
}.getOrNull()
|
||||
if (appIcon != null) return appIcon.foregroundOrSelf()
|
||||
}
|
||||
if (action.startsWith("app_shortcut:")) {
|
||||
val packageName = action.removePrefix("app_shortcut:").substringBefore(":")
|
||||
val appIcon = runCatching {
|
||||
packageManager.getApplicationIcon(packageName)
|
||||
}.getOrNull()
|
||||
if (appIcon != null) return appIcon.foregroundOrSelf()
|
||||
}
|
||||
return resources.getDrawable(iconForAction(action), theme)
|
||||
}
|
||||
|
||||
private fun displayTitleForAction(action: String, savedLabel: String): String {
|
||||
if (action.isBlank() || action == "none") return getString(R.string.action_none)
|
||||
return when {
|
||||
action.startsWith("launch_app:") -> appLabel(action.removePrefix("launch_app:"))
|
||||
?: stripKnownPrefix(savedLabel, "App:", "App: ", "应用:", "应用:", "应用: ")
|
||||
.ifBlank { getString(R.string.action_launch_app) }
|
||||
action.startsWith("app_shortcut:") -> stripKnownPrefix(
|
||||
savedLabel,
|
||||
"Shortcut:",
|
||||
"Shortcut: ",
|
||||
"快捷方式:",
|
||||
"快捷方式: ",
|
||||
"快捷方式:",
|
||||
).ifBlank {
|
||||
val packageName = action.removePrefix("app_shortcut:").substringBefore(":")
|
||||
appLabel(packageName) ?: getString(R.string.action_app_shortcut)
|
||||
}
|
||||
action.startsWith("shell:") -> shellCommandTitle(action, savedLabel)
|
||||
else -> savedLabel.ifBlank { action }
|
||||
}
|
||||
}
|
||||
|
||||
private fun appLabel(packageName: String): String? = runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
appInfo.loadLabel(packageManager).toString()
|
||||
}.getOrNull()
|
||||
|
||||
private fun shellCommandTitle(action: String, savedLabel: String): String {
|
||||
val command = action.removePrefix("shell:").split(":", limit = 2).getOrNull(1).orEmpty().trim()
|
||||
val saved = savedLabel.trim()
|
||||
return when {
|
||||
saved.isNotBlank() &&
|
||||
saved != getString(R.string.action_shell_command) &&
|
||||
saved != "Shell" &&
|
||||
saved != "Shell Command" &&
|
||||
saved != "Shell 命令" -> saved
|
||||
command.isNotBlank() -> command
|
||||
else -> getString(R.string.action_shell_command)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripKnownPrefix(value: String, vararg prefixes: String): String {
|
||||
val trimmed = value.trim()
|
||||
val match = prefixes.firstOrNull { trimmed.startsWith(it) } ?: return trimmed
|
||||
return trimmed.removePrefix(match).trim()
|
||||
}
|
||||
|
||||
private fun Drawable.foregroundOrSelf(): Drawable =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
|
||||
foreground?.mutate() ?: mutate()
|
||||
} else {
|
||||
mutate()
|
||||
}
|
||||
|
||||
private fun iconForAction(action: String): Int = when {
|
||||
action == "back" -> R.drawable.ic_arrow_back
|
||||
action == "home" -> R.drawable.ic_home
|
||||
action == "recent" || action == "recents" -> R.drawable.ic_recents
|
||||
action == "expand_notifications" -> R.drawable.ic_notifications
|
||||
action == "shell_command" || action.startsWith("shell:") -> R.drawable.ic_terminal
|
||||
action == "sub_gesture" -> R.drawable.ic_sub_gesture
|
||||
action.startsWith("launch_app:") -> R.drawable.ic_launch_app
|
||||
action.startsWith("app_shortcut:") -> R.drawable.ic_app_shortcut
|
||||
action == "clear_background" -> R.drawable.ic_clear_recent
|
||||
action == "freezer_drawer" -> R.drawable.ic_freezer
|
||||
action == "refreeze" -> R.drawable.ic_refreeze
|
||||
action == "screenshot" -> R.drawable.ic_camera
|
||||
action == "clipboard" -> R.drawable.ic_paste
|
||||
action == "universal_copy" -> R.drawable.ic_content_copy
|
||||
action == "lock_screen" -> R.drawable.ic_power
|
||||
action == "kill_app" -> R.drawable.ic_kill_app
|
||||
action == "brightness_up" -> R.drawable.ic_brightness_up
|
||||
action == "brightness_down" -> R.drawable.ic_brightness_down
|
||||
action == "volume_up" -> R.drawable.ic_volume_up
|
||||
action == "volume_down" -> R.drawable.ic_volume_down
|
||||
action.startsWith("music_control:") -> R.drawable.ic_music
|
||||
action.startsWith("multi_action:") -> R.drawable.ic_multi_action
|
||||
action == AppConfig.CUSTOM_PANEL_ACTION -> R.drawable.ic_apps
|
||||
action == AppConfig.SIDE_BAR_LEFT_ACTION -> R.drawable.ic_side_bar_left
|
||||
action == AppConfig.SIDE_BAR_RIGHT_ACTION -> R.drawable.ic_side_bar_right
|
||||
action == "toggle_wifi" -> R.drawable.ic_wifi
|
||||
action == "toggle_mobile_data" -> R.drawable.ic_mobile_data
|
||||
else -> R.drawable.ic_action_dot
|
||||
}
|
||||
|
||||
private data class SlotRow(
|
||||
val prefKey: String,
|
||||
val icon: ImageView,
|
||||
val subtitle: TextView,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val EXTRA_MODE = "mode"
|
||||
const val MODE_CUSTOM = "custom"
|
||||
const val MODE_SIDE_LEFT = "side_left"
|
||||
const val MODE_SIDE_RIGHT = "side_right"
|
||||
}
|
||||
}
|
||||
161
app/src/main/java/com/fan/edgex/ui/PieSettingsActivity.kt
Normal file
161
app/src/main/java/com/fan/edgex/ui/PieSettingsActivity.kt
Normal file
@@ -0,0 +1,161 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigString
|
||||
|
||||
class PieSettingsActivity : AppCompatActivity() {
|
||||
|
||||
private data class RowInfo(
|
||||
val edge: String,
|
||||
val ring: Int,
|
||||
val slot: Int,
|
||||
val view: View,
|
||||
)
|
||||
|
||||
private val rows = mutableListOf<RowInfo>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_pie_settings)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
val container = findViewById<LinearLayout>(R.id.pie_sections_container)
|
||||
val inflater = LayoutInflater.from(this)
|
||||
val density = resources.displayMetrics.density
|
||||
|
||||
val edgeStringRes = mapOf(
|
||||
"left" to R.string.pie_edge_left,
|
||||
"right" to R.string.pie_edge_right,
|
||||
"top" to R.string.pie_edge_top,
|
||||
"bottom" to R.string.pie_edge_bottom,
|
||||
)
|
||||
|
||||
for (edge in AppConfig.PIE_EDGES) {
|
||||
val sectionHeader = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
val tv = TypedValue()
|
||||
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, tv, true)
|
||||
setBackgroundResource(tv.resourceId)
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
val pad = (16 * density).toInt()
|
||||
setPadding(pad, pad, pad, pad)
|
||||
gravity = android.view.Gravity.CENTER_VERTICAL
|
||||
}
|
||||
|
||||
val titleView = TextView(this).apply {
|
||||
text = getString(edgeStringRes[edge] ?: R.string.pie_edge_left)
|
||||
setTextColor(resources.getColor(R.color.ui_text_primary, theme))
|
||||
textSize = 16f
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
|
||||
val arrowView = ImageView(this).apply {
|
||||
setImageResource(R.drawable.ic_expand_more)
|
||||
imageTintList = resources.getColorStateList(R.color.ui_text_secondary, theme)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
(24 * density).toInt(),
|
||||
(24 * density).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
sectionHeader.addView(titleView)
|
||||
sectionHeader.addView(arrowView)
|
||||
container.addView(sectionHeader)
|
||||
|
||||
val contentLayout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
visibility = View.GONE
|
||||
}
|
||||
|
||||
val dividerH = (1 * density).toInt()
|
||||
val dividerMargin = (4 * density).toInt()
|
||||
val rowPad = (4 * density).toInt()
|
||||
var firstRow = true
|
||||
|
||||
for (ring in 1..AppConfig.PIE_RINGS) {
|
||||
for (slot in 0 until AppConfig.PIE_SLOTS_PER_RING) {
|
||||
if (!firstRow) {
|
||||
val divider = View(this).also { d ->
|
||||
d.setBackgroundColor(resources.getColor(R.color.ui_divider, theme))
|
||||
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dividerH)
|
||||
lp.topMargin = dividerMargin
|
||||
lp.bottomMargin = dividerMargin
|
||||
d.layoutParams = lp
|
||||
}
|
||||
contentLayout.addView(divider)
|
||||
}
|
||||
firstRow = false
|
||||
|
||||
val row = inflater.inflate(R.layout.item_gesture_action, contentLayout, false)
|
||||
val tv2 = TypedValue()
|
||||
theme.resolveAttribute(android.R.attr.selectableItemBackground, tv2, true)
|
||||
row.setBackgroundResource(tv2.resourceId)
|
||||
row.isClickable = true
|
||||
row.isFocusable = true
|
||||
row.setPadding(rowPad, 0, rowPad, 0)
|
||||
row.findViewById<TextView>(R.id.action_title).text = getString(R.string.pie_ring_slot_label, ring, slot + 1)
|
||||
|
||||
val capturedEdge = edge
|
||||
val capturedRing = ring
|
||||
val capturedSlot = slot
|
||||
row.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("pref_key", AppConfig.pieSlot(capturedEdge, capturedRing, capturedSlot))
|
||||
.putExtra("title", getString(R.string.pie_ring_slot_label, capturedRing, capturedSlot + 1))
|
||||
)
|
||||
}
|
||||
|
||||
rows.add(RowInfo(edge, ring, slot, row))
|
||||
contentLayout.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
container.addView(contentLayout)
|
||||
|
||||
var expanded = false
|
||||
sectionHeader.setOnClickListener {
|
||||
expanded = !expanded
|
||||
contentLayout.visibility = if (expanded) View.VISIBLE else View.GONE
|
||||
arrowView.rotation = if (expanded) 180f else 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
private fun refreshLabels() {
|
||||
for (info in rows) {
|
||||
val action = getConfigString(
|
||||
AppConfig.pieSlot(info.edge, info.ring, info.slot),
|
||||
"none"
|
||||
)
|
||||
val rawLabel = getConfigString(
|
||||
AppConfig.pieSlotLabel(info.edge, info.ring, info.slot),
|
||||
getString(R.string.action_none),
|
||||
)
|
||||
val label = ActionSelectionActivity.resolveActionLabel(this, action, rawLabel)
|
||||
info.view.findViewById<TextView>(R.id.action_subtitle)?.text = label
|
||||
}
|
||||
}
|
||||
}
|
||||
285
app/src/main/java/com/fan/edgex/ui/PremiumActivity.kt
Normal file
285
app/src/main/java/com/fan/edgex/ui/PremiumActivity.kt
Normal file
@@ -0,0 +1,285 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.license.PremiumActivator
|
||||
import com.fan.edgex.utils.ActivationDialog
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class PremiumActivity : AppCompatActivity() {
|
||||
private var updateCheckStarted = false
|
||||
private var updateCheckInProgress = false
|
||||
private var updateStatusText: String? = null
|
||||
private var availableUpdate: PremiumActivator.DexUpdateStatus.Available? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_premium)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
insets.getInsets(android.view.WindowInsets.Type.statusBars()).top,
|
||||
view.paddingRight,
|
||||
view.paddingBottom,
|
||||
)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
findViewById<View>(R.id.item_edge_lighting).setOnClickListener {
|
||||
startActivity(Intent(this, EdgeLightingSettingsActivity::class.java))
|
||||
}
|
||||
findViewById<Button>(R.id.btn_update).setOnClickListener {
|
||||
performUpdate()
|
||||
}
|
||||
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ThemeManager.applyToActivity(this)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
val status = PremiumActivator.status(this)
|
||||
|
||||
data class StateVisuals(
|
||||
val iconRes: Int,
|
||||
val iconColorArgb: Int,
|
||||
val titleRes: Int,
|
||||
val descText: String?,
|
||||
)
|
||||
|
||||
val visuals = when (status) {
|
||||
PremiumActivator.Status.NotActivated -> StateVisuals(
|
||||
iconRes = R.drawable.ic_info,
|
||||
iconColorArgb = ThemeManager.currentAccent(this),
|
||||
titleRes = R.string.menu_premium_not_activated,
|
||||
descText = getString(R.string.premium_desc_not_activated),
|
||||
)
|
||||
PremiumActivator.Status.RebootRequired -> StateVisuals(
|
||||
iconRes = R.drawable.ic_restart_alt,
|
||||
iconColorArgb = ThemeManager.currentAccent(this),
|
||||
titleRes = R.string.premium_status_reboot,
|
||||
descText = buildString {
|
||||
append(getString(R.string.premium_desc_reboot))
|
||||
PremiumActivator.getActivationCode(this@PremiumActivity)?.let {
|
||||
append("\n")
|
||||
append(getString(R.string.premium_code_label, it))
|
||||
}
|
||||
},
|
||||
)
|
||||
PremiumActivator.Status.Installed -> StateVisuals(
|
||||
iconRes = R.drawable.ic_supporter_extra,
|
||||
iconColorArgb = getColor(R.color.ui_icon_bg),
|
||||
titleRes = R.string.premium_status_active,
|
||||
descText = buildString {
|
||||
PremiumActivator.getActivationCode(this@PremiumActivity)?.let {
|
||||
append(getString(R.string.premium_code_label, it))
|
||||
}
|
||||
}.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
val iconBg = findViewById<FrameLayout>(R.id.icon_status_bg)
|
||||
iconBg.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(visuals.iconColorArgb)
|
||||
}
|
||||
findViewById<ImageView>(R.id.icon_status).apply {
|
||||
setImageResource(visuals.iconRes)
|
||||
setColorFilter(ThemeManager.onAccentColor(visuals.iconColorArgb))
|
||||
}
|
||||
findViewById<TextView>(R.id.text_status).setText(visuals.titleRes)
|
||||
|
||||
val codeView = findViewById<TextView>(R.id.text_activation_code)
|
||||
if (!visuals.descText.isNullOrEmpty()) {
|
||||
codeView.visibility = View.VISIBLE
|
||||
codeView.text = visuals.descText
|
||||
} else {
|
||||
codeView.visibility = View.GONE
|
||||
}
|
||||
|
||||
val activated = status != PremiumActivator.Status.NotActivated
|
||||
val btnActivate = findViewById<Button>(R.id.btn_activate)
|
||||
val btnDeactivate = findViewById<Button>(R.id.btn_deactivate)
|
||||
|
||||
btnActivate.visibility = if (activated) View.GONE else View.VISIBLE
|
||||
btnDeactivate.visibility = if (activated) View.VISIBLE else View.GONE
|
||||
btnDeactivate.setText(R.string.premium_deactivate)
|
||||
btnDeactivate.isEnabled = true
|
||||
|
||||
btnActivate.setOnClickListener {
|
||||
ActivationDialog.show(this) { refreshStatus() }
|
||||
}
|
||||
btnDeactivate.setOnClickListener {
|
||||
showDeactivateConfirmDialog()
|
||||
}
|
||||
|
||||
refreshDexInfo(status)
|
||||
startUpdateCheckIfNeeded(status)
|
||||
}
|
||||
|
||||
private fun refreshDexInfo(status: PremiumActivator.Status) {
|
||||
val dexInfoView = findViewById<TextView>(R.id.text_dex_info)
|
||||
val btnUpdate = findViewById<Button>(R.id.btn_update)
|
||||
val activated = status != PremiumActivator.Status.NotActivated
|
||||
|
||||
if (!activated) {
|
||||
updateCheckStarted = false
|
||||
updateCheckInProgress = false
|
||||
updateStatusText = null
|
||||
availableUpdate = null
|
||||
dexInfoView.visibility = View.GONE
|
||||
btnUpdate.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
PremiumActivator.getDexInfo(this)?.let { info ->
|
||||
val time = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
.format(Date(info.installedAtMs))
|
||||
lines += getString(R.string.premium_dex_info, info.apiVersion, info.hashPrefix, time)
|
||||
}
|
||||
updateStatusText?.let { lines += it }
|
||||
|
||||
dexInfoView.visibility = if (lines.isEmpty()) View.GONE else View.VISIBLE
|
||||
dexInfoView.text = lines.joinToString("\n")
|
||||
btnUpdate.visibility = if (availableUpdate != null && !updateCheckInProgress) View.VISIBLE else View.GONE
|
||||
btnUpdate.isEnabled = availableUpdate != null && !updateCheckInProgress
|
||||
if (!updateCheckInProgress) {
|
||||
btnUpdate.setText(R.string.premium_update_download)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUpdateCheckIfNeeded(status: PremiumActivator.Status) {
|
||||
if (status == PremiumActivator.Status.NotActivated || updateCheckStarted || updateCheckInProgress) return
|
||||
|
||||
updateCheckStarted = true
|
||||
updateCheckInProgress = true
|
||||
updateStatusText = getString(R.string.premium_dex_update_checking)
|
||||
availableUpdate = null
|
||||
refreshDexInfo(status)
|
||||
|
||||
val appContext = applicationContext
|
||||
thread(name = "EdgeXPremiumUpdateCheck") {
|
||||
val result = PremiumActivator.checkInstalledDexUpdate(appContext)
|
||||
runOnUiThread {
|
||||
updateCheckInProgress = false
|
||||
result.onSuccess { updateStatus ->
|
||||
when (updateStatus) {
|
||||
is PremiumActivator.DexUpdateStatus.Available -> {
|
||||
availableUpdate = updateStatus
|
||||
updateStatusText = getString(
|
||||
R.string.premium_dex_update_available,
|
||||
updateStatus.info.hashPrefix,
|
||||
)
|
||||
}
|
||||
PremiumActivator.DexUpdateStatus.UpToDate -> {
|
||||
availableUpdate = null
|
||||
updateStatusText = getString(R.string.premium_dex_update_up_to_date)
|
||||
}
|
||||
PremiumActivator.DexUpdateStatus.NotInstalled,
|
||||
PremiumActivator.DexUpdateStatus.MissingActivationCode -> {
|
||||
availableUpdate = null
|
||||
updateStatusText = null
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
availableUpdate = null
|
||||
updateStatusText = getString(
|
||||
R.string.premium_update_failed,
|
||||
it.message ?: it.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
refreshDexInfo(PremiumActivator.status(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDeactivateConfirmDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.premium_deactivate_confirm_title)
|
||||
.setMessage(R.string.premium_deactivate_confirm_message)
|
||||
.setPositiveButton(R.string.premium_deactivate) { _, _ -> performDeactivate() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performDeactivate() {
|
||||
val btnDeactivate = findViewById<Button>(R.id.btn_deactivate)
|
||||
btnDeactivate.isEnabled = false
|
||||
btnDeactivate.text = getString(R.string.premium_deactivating)
|
||||
|
||||
thread(name = "EdgeXPremiumDeactivate") {
|
||||
val result = PremiumActivator.deactivate(applicationContext)
|
||||
runOnUiThread {
|
||||
btnDeactivate.isEnabled = true
|
||||
result.onSuccess {
|
||||
Toast.makeText(this, R.string.premium_deactivate_success, Toast.LENGTH_SHORT).show()
|
||||
}.onFailure {
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(R.string.premium_activation_failed, it.message ?: it.javaClass.simpleName),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performUpdate() {
|
||||
val btnUpdate = findViewById<Button>(R.id.btn_update)
|
||||
btnUpdate.isEnabled = false
|
||||
btnUpdate.setText(R.string.premium_update_downloading)
|
||||
updateCheckInProgress = true
|
||||
refreshDexInfo(PremiumActivator.status(this))
|
||||
|
||||
val appContext = applicationContext
|
||||
thread(name = "EdgeXPremiumUpdateDownload") {
|
||||
val result = PremiumActivator.updateInstalledDexIfNeeded(appContext)
|
||||
runOnUiThread {
|
||||
updateCheckInProgress = false
|
||||
result.onSuccess { updateResult ->
|
||||
availableUpdate = null
|
||||
updateCheckStarted = true
|
||||
updateStatusText = when (updateResult) {
|
||||
PremiumActivator.UpdateResult.Updated -> getString(R.string.premium_update_success)
|
||||
PremiumActivator.UpdateResult.UpToDate -> getString(R.string.premium_dex_update_up_to_date)
|
||||
PremiumActivator.UpdateResult.NotInstalled,
|
||||
PremiumActivator.UpdateResult.SkippedMissingActivationCode -> null
|
||||
}
|
||||
if (updateResult == PremiumActivator.UpdateResult.Updated) {
|
||||
Toast.makeText(this, R.string.premium_update_success, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure {
|
||||
updateStatusText = getString(
|
||||
R.string.premium_update_failed,
|
||||
it.message ?: it.javaClass.simpleName,
|
||||
)
|
||||
Toast.makeText(this, updateStatusText, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
94
app/src/main/java/com/fan/edgex/ui/ShellCommandActivity.kt
Normal file
94
app/src/main/java/com/fan/edgex/ui/ShellCommandActivity.kt
Normal file
@@ -0,0 +1,94 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfigsSync
|
||||
|
||||
/**
|
||||
* Activity for configuring a shell command action.
|
||||
* User can enter a shell command and optionally choose to run as root (su).
|
||||
*/
|
||||
@Deprecated("Use Compose ShellCommandSheet instead")
|
||||
class ShellCommandActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var editCommand: EditText
|
||||
private lateinit var checkRunAsRoot: CheckBox
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_shell_command)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
// Header Insets
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
editCommand = findViewById(R.id.edit_command)
|
||||
checkRunAsRoot = findViewById(R.id.check_run_as_root)
|
||||
|
||||
val prefKey = intent.getStringExtra("pref_key") ?: "unknown"
|
||||
|
||||
// Load existing config if editing
|
||||
val existingAction = getConfigString(prefKey)
|
||||
if (existingAction.startsWith("shell:")) {
|
||||
parseAndFillExisting(existingAction)
|
||||
}
|
||||
|
||||
findViewById<View>(R.id.btn_save).setOnClickListener {
|
||||
saveCommand(prefKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndFillExisting(action: String) {
|
||||
// Format: shell:{runAsRoot}:{command}
|
||||
// Example: shell:true:reboot or shell:false:echo hello
|
||||
val parts = action.removePrefix("shell:").split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
checkRunAsRoot.isChecked = parts[0] == "true"
|
||||
editCommand.setText(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveCommand(prefKey: String) {
|
||||
val command = editCommand.text.toString().trim()
|
||||
if (command.isEmpty()) {
|
||||
Toast.makeText(this, getString(R.string.toast_shell_command_empty), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Warn if user typed 'su' explicitly when runAsRoot is already checked
|
||||
if (checkRunAsRoot.isChecked && containsSuCommand(command)) {
|
||||
Toast.makeText(this, getString(R.string.toast_shell_su_warning), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
val runAsRoot = checkRunAsRoot.isChecked
|
||||
putConfigsSync(
|
||||
prefKey to "shell:$runAsRoot:$command",
|
||||
"${prefKey}_label" to command,
|
||||
"${prefKey}_title" to command,
|
||||
)
|
||||
|
||||
Toast.makeText(this, getString(R.string.toast_shell_command_saved), Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun containsSuCommand(command: String): Boolean {
|
||||
val lines = command.split("\n", "\r\n", "\r")
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed == "su" || trimmed.startsWith("su ")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
304
app/src/main/java/com/fan/edgex/ui/ShortcutSelectionActivity.kt
Normal file
304
app/src/main/java/com/fan/edgex/ui/ShortcutSelectionActivity.kt
Normal file
@@ -0,0 +1,304 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.putConfigsSync
|
||||
import java.util.Locale
|
||||
|
||||
@Deprecated("Use Compose AppShortcutPickerSheet instead")
|
||||
class ShortcutSelectionActivity : AppCompatActivity() {
|
||||
|
||||
data class ShortcutItem(
|
||||
val packageName: String,
|
||||
val shortcutId: String,
|
||||
val label: String,
|
||||
val appLabel: String,
|
||||
val icon: android.graphics.drawable.Drawable?
|
||||
)
|
||||
|
||||
private val allShortcuts = mutableListOf<ShortcutItem>()
|
||||
private val displayedShortcuts = mutableListOf<ShortcutItem>()
|
||||
private lateinit var adapter: ShortcutAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_shortcut_selection)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
// Header Insets
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
|
||||
// Get Args
|
||||
val prefKey = intent.getStringExtra("pref_key") ?: "unknown"
|
||||
|
||||
// RecyclerView
|
||||
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
adapter = ShortcutAdapter(displayedShortcuts) { item ->
|
||||
putConfigsSync(
|
||||
prefKey to "app_shortcut:${item.packageName}:${item.shortcutId}",
|
||||
"${prefKey}_label" to item.label,
|
||||
"${prefKey}_title" to item.label,
|
||||
)
|
||||
finish()
|
||||
}
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
// Setup Search
|
||||
setupSearch()
|
||||
// Load Shortcuts
|
||||
loadShortcuts()
|
||||
}
|
||||
|
||||
private fun setupSearch() {
|
||||
val btnSearch = findViewById<ImageView>(R.id.btn_search)
|
||||
val etSearch = findViewById<EditText>(R.id.et_search)
|
||||
val tvTitle = findViewById<TextView>(R.id.tv_title)
|
||||
|
||||
btnSearch.setOnClickListener {
|
||||
if (etSearch.isGone) {
|
||||
tvTitle.isGone = true
|
||||
etSearch.isVisible = true
|
||||
etSearch.requestFocus()
|
||||
} else {
|
||||
if (etSearch.text.isEmpty()) {
|
||||
etSearch.isGone = true
|
||||
tvTitle.isVisible = true
|
||||
} else {
|
||||
etSearch.text.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
etSearch.addTextChangedListener { text ->
|
||||
filterShortcuts(text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterShortcuts(query: String) {
|
||||
displayedShortcuts.clear()
|
||||
if (query.isEmpty()) {
|
||||
displayedShortcuts.addAll(allShortcuts)
|
||||
} else {
|
||||
val q = query.lowercase(Locale.getDefault())
|
||||
displayedShortcuts.addAll(allShortcuts.filter {
|
||||
it.label.lowercase().contains(q) || it.appLabel.lowercase().contains(q) || it.packageName.contains(q)
|
||||
})
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun loadShortcuts() {
|
||||
Thread {
|
||||
try {
|
||||
val launcherApps = getSystemService(Context.LAUNCHER_APPS_SERVICE) as android.content.pm.LauncherApps
|
||||
val pm = packageManager
|
||||
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN, null)
|
||||
mainIntent.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val apps = pm.queryIntentActivities(mainIntent, 0)
|
||||
|
||||
val tempList = mutableListOf<ShortcutItem>()
|
||||
|
||||
for (app in apps) {
|
||||
val packageName = app.activityInfo.packageName
|
||||
val appLabel = app.loadLabel(pm).toString()
|
||||
|
||||
try {
|
||||
val query = android.content.pm.LauncherApps.ShortcutQuery()
|
||||
query.setQueryFlags(
|
||||
android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
|
||||
android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
|
||||
android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED
|
||||
)
|
||||
query.setPackage(packageName)
|
||||
|
||||
val appShortcuts = launcherApps.getShortcuts(query, android.os.Process.myUserHandle()) ?: emptyList()
|
||||
|
||||
for (shortcut in appShortcuts) {
|
||||
val icon = try {
|
||||
launcherApps.getShortcutIconDrawable(shortcut, 0)
|
||||
} catch (e: Exception) {
|
||||
app.loadIcon(pm)
|
||||
}
|
||||
|
||||
tempList.add(
|
||||
ShortcutItem(
|
||||
packageName = packageName,
|
||||
shortcutId = shortcut.id,
|
||||
label = shortcut.shortLabel?.toString() ?: shortcut.longLabel?.toString() ?: getString(R.string.key_not_configured),
|
||||
appLabel = appLabel,
|
||||
icon = icon
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
// Likely not default launcher
|
||||
} catch (e: Exception) {
|
||||
// Other validation errors
|
||||
}
|
||||
}
|
||||
|
||||
tempList.sortWith(compareBy({ it.appLabel }, { it.label }))
|
||||
|
||||
runOnUiThread {
|
||||
allShortcuts.clear()
|
||||
allShortcuts.addAll(tempList)
|
||||
if (allShortcuts.isEmpty()) {
|
||||
loadShortcutsViaRoot()
|
||||
} else {
|
||||
filterShortcuts(findViewById<EditText>(R.id.et_search).text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
loadShortcutsViaRoot()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun loadShortcutsViaRoot() {
|
||||
Thread {
|
||||
android.util.Log.d("EdgeX_Dump", "Starting Root Dump...")
|
||||
val rootShortcuts = mutableListOf<ShortcutItem>()
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "dumpsys shortcut"))
|
||||
|
||||
Thread {
|
||||
val errReader = java.io.BufferedReader(java.io.InputStreamReader(process.errorStream))
|
||||
var errLine: String?
|
||||
while (errReader.readLine().also { errLine = it } != null) {
|
||||
android.util.Log.e("EdgeX_Dump", "STDERR: $errLine")
|
||||
}
|
||||
}.start()
|
||||
|
||||
val reader = java.io.BufferedReader(java.io.InputStreamReader(process.inputStream))
|
||||
|
||||
var line: String?
|
||||
var currentPackage: String? = null
|
||||
var currentId: String? = null
|
||||
var currentLabel: String? = null
|
||||
val pm = packageManager
|
||||
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
val l = line!!.trim()
|
||||
|
||||
if (l.startsWith("Package:") && l.contains("uid=")) {
|
||||
val parts = l.split("\\s+".toRegex())
|
||||
if (parts.size >= 2) {
|
||||
currentPackage = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if (l.startsWith("ShortcutInfo") && l.contains("id=")) {
|
||||
val afterId = l.substringAfter("id=")
|
||||
currentId = afterId.substringBefore(",").substringBefore(" ").trim()
|
||||
currentLabel = null
|
||||
} else if (l.startsWith("id=")) {
|
||||
currentId = l.substringAfter("id=").trim()
|
||||
}
|
||||
|
||||
if (l.startsWith("packageName=")) {
|
||||
currentPackage = l.substringAfter("packageName=").trim()
|
||||
}
|
||||
|
||||
if (l.startsWith("shortLabel=")) {
|
||||
val raw = l.substringAfter("shortLabel=")
|
||||
if (raw.contains(", resId=")) {
|
||||
currentLabel = raw.substringBefore(", resId=")
|
||||
} else {
|
||||
currentLabel = raw.substringBefore(",")
|
||||
}
|
||||
currentLabel = currentLabel?.trim()
|
||||
|
||||
if (currentPackage != null && currentId != null && currentLabel != null) {
|
||||
android.util.Log.d("EdgeX_Dump", "Found: $currentPackage / $currentId / $currentLabel")
|
||||
try {
|
||||
val exists = rootShortcuts.any { it.packageName == currentPackage && it.shortcutId == currentId }
|
||||
if (!exists) {
|
||||
val appInfo = pm.getApplicationInfo(currentPackage!!, 0)
|
||||
rootShortcuts.add(ShortcutItem(
|
||||
packageName = currentPackage!!,
|
||||
shortcutId = currentId!!,
|
||||
label = currentLabel!!,
|
||||
appLabel = appInfo.loadLabel(pm).toString(),
|
||||
icon = appInfo.loadIcon(pm)
|
||||
))
|
||||
}
|
||||
currentId = null
|
||||
currentLabel = null
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.close()
|
||||
process.waitFor()
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
android.util.Log.e("EdgeX_Dump", "Error: ${e.message}")
|
||||
}
|
||||
|
||||
runOnUiThread {
|
||||
if (rootShortcuts.isNotEmpty()) {
|
||||
allShortcuts.clear()
|
||||
allShortcuts.addAll(rootShortcuts)
|
||||
allShortcuts.sortWith(compareBy({ it.appLabel }, { it.label }))
|
||||
filterShortcuts(findViewById<EditText>(R.id.et_search).text.toString())
|
||||
Toast.makeText(this, getString(R.string.toast_shortcuts_loaded_via_root, rootShortcuts.size), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this, getString(R.string.toast_no_shortcuts_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
inner class ShortcutAdapter(
|
||||
private val items: List<ShortcutItem>,
|
||||
private val onClick: (ShortcutItem) -> Unit
|
||||
) : RecyclerView.Adapter<ShortcutAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val icon: ImageView = view.findViewById(R.id.app_icon)
|
||||
val title: TextView = view.findViewById(R.id.app_name)
|
||||
val subtitle: TextView = view.findViewById(R.id.app_package)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_app_list, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.title.text = item.label
|
||||
holder.subtitle.text = item.appLabel
|
||||
holder.icon.setImageDrawable(item.icon)
|
||||
ThemeManager.applyToView(holder.itemView, this@ShortcutSelectionActivity)
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
78
app/src/main/java/com/fan/edgex/ui/SubGestureActivity.kt
Normal file
78
app/src/main/java/com/fan/edgex/ui/SubGestureActivity.kt
Normal file
@@ -0,0 +1,78 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigString
|
||||
|
||||
@Deprecated("Use Compose SubGestureSheet instead")
|
||||
class SubGestureActivity : AppCompatActivity() {
|
||||
|
||||
private data class SlotSpec(
|
||||
val rowId: Int,
|
||||
val labelRes: Int,
|
||||
val direction: String,
|
||||
)
|
||||
|
||||
private val slots = listOf(
|
||||
SlotSpec(R.id.row_sub_hold, R.string.sub_gesture_hold, "hold"),
|
||||
SlotSpec(R.id.row_sub_swipe_left, R.string.gesture_swipe_left, "swipe_left"),
|
||||
SlotSpec(R.id.row_sub_swipe_right, R.string.gesture_swipe_right, "swipe_right"),
|
||||
SlotSpec(R.id.row_sub_swipe_up, R.string.gesture_swipe_up, "swipe_up"),
|
||||
SlotSpec(R.id.row_sub_swipe_down, R.string.gesture_swipe_down, "swipe_down"),
|
||||
)
|
||||
|
||||
private lateinit var parentKey: String
|
||||
private lateinit var parentTitle: String
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_sub_gesture)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
parentKey = intent.getStringExtra("pref_key") ?: ""
|
||||
parentTitle = intent.getStringExtra("title") ?: getString(R.string.action_sub_gesture)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<TextView>(R.id.tv_subtitle).text = parentTitle
|
||||
|
||||
slots.forEach { slot ->
|
||||
val row = findViewById<View>(slot.rowId)
|
||||
row.findViewById<TextView>(R.id.action_title).text = getString(slot.labelRes)
|
||||
row.setOnClickListener {
|
||||
val childKey = AppConfig.subGestureChildKey(parentKey, slot.direction)
|
||||
startActivity(
|
||||
Intent(this, ActionSelectionActivity::class.java)
|
||||
.putExtra("title", "$parentTitle / ${getString(slot.labelRes)}")
|
||||
.putExtra("pref_key", childKey)
|
||||
.putExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES, intent.getStringArrayExtra(ActionSelectionActivity.EXTRA_EXCLUDED_CODES))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshSubtitles()
|
||||
}
|
||||
|
||||
private fun refreshSubtitles() {
|
||||
slots.forEach { slot ->
|
||||
val childKey = AppConfig.subGestureChildKey(parentKey, slot.direction)
|
||||
val action = getConfigString(childKey, "none")
|
||||
val rawLabel = getConfigString("${childKey}_label", getString(R.string.action_none))
|
||||
val label = ActionSelectionActivity.resolveActionLabel(this, action, rawLabel)
|
||||
val row = findViewById<View>(slot.rowId)
|
||||
row.findViewById<TextView>(R.id.action_subtitle).text = label
|
||||
ActionSelectionActivity.applyActionIcon(this, action, row.findViewById(R.id.action_icon))
|
||||
}
|
||||
}
|
||||
}
|
||||
115
app/src/main/java/com/fan/edgex/ui/ThemeActivity.kt
Normal file
115
app/src/main/java/com/fan/edgex/ui/ThemeActivity.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RadioButton
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.fan.edgex.R
|
||||
|
||||
class ThemeActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_theme)
|
||||
ThemeManager.applyToActivity(this)
|
||||
|
||||
findViewById<View>(R.id.header_container).setOnApplyWindowInsetsListener { view, insets ->
|
||||
view.setPadding(view.paddingLeft, insets.getInsets(android.view.WindowInsets.Type.statusBars()).top, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
findViewById<View>(R.id.btn_back).setOnClickListener { finish() }
|
||||
findViewById<View>(R.id.btn_apply_custom).setOnClickListener { applyCustomColor() }
|
||||
|
||||
renderPresetRows()
|
||||
refreshPreview()
|
||||
}
|
||||
|
||||
private fun renderPresetRows() {
|
||||
val container = findViewById<LinearLayout>(R.id.preset_container)
|
||||
val currentPresetId = ThemeManager.currentPresetId(this)
|
||||
container.removeAllViews()
|
||||
|
||||
ThemeManager.presets.forEach { preset ->
|
||||
val row = layoutInflater.inflate(R.layout.item_theme_preset, container, false)
|
||||
val swatch = row.findViewById<View>(R.id.swatch)
|
||||
val title = row.findViewById<TextView>(R.id.title)
|
||||
val radio = row.findViewById<RadioButton>(R.id.radio)
|
||||
|
||||
ThemeManager.tintSwatch(swatch, preset.accentColor)
|
||||
title.text = getString(preset.titleRes)
|
||||
radio.isChecked = currentPresetId == preset.id
|
||||
radio.isClickable = false
|
||||
ThemeManager.applyToView(row, this)
|
||||
|
||||
row.setOnClickListener {
|
||||
ThemeManager.savePreset(this, preset.id)
|
||||
ThemeManager.applyToActivity(this)
|
||||
renderPresetRows()
|
||||
refreshPreview()
|
||||
Toast.makeText(this, getString(R.string.toast_theme_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
val accent = ThemeManager.currentAccent(this)
|
||||
val editRed = findViewById<EditText>(R.id.edit_red)
|
||||
val editGreen = findViewById<EditText>(R.id.edit_green)
|
||||
val editBlue = findViewById<EditText>(R.id.edit_blue)
|
||||
editRed.setText(Color.red(accent).toString())
|
||||
editGreen.setText(Color.green(accent).toString())
|
||||
editBlue.setText(Color.blue(accent).toString())
|
||||
|
||||
val watcher = object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable?) = refreshCustomPreview()
|
||||
}
|
||||
editRed.addTextChangedListener(watcher)
|
||||
editGreen.addTextChangedListener(watcher)
|
||||
editBlue.addTextChangedListener(watcher)
|
||||
}
|
||||
|
||||
private fun refreshPreview() {
|
||||
val accent = ThemeManager.currentAccent(this)
|
||||
ThemeManager.tintSwatch(findViewById(R.id.preview_swatch), accent)
|
||||
findViewById<TextView>(R.id.text_current_hex).text = ThemeManager.displayColor(accent)
|
||||
}
|
||||
|
||||
private fun refreshCustomPreview() {
|
||||
val r = findViewById<EditText>(R.id.edit_red).text.toString().toIntOrNull() ?: return
|
||||
val g = findViewById<EditText>(R.id.edit_green).text.toString().toIntOrNull() ?: return
|
||||
val b = findViewById<EditText>(R.id.edit_blue).text.toString().toIntOrNull() ?: return
|
||||
if (r !in 0..255 || g !in 0..255 || b !in 0..255) return
|
||||
val color = Color.rgb(r, g, b)
|
||||
ThemeManager.tintSwatch(findViewById(R.id.custom_preview_swatch), color)
|
||||
findViewById<TextView>(R.id.text_custom_hex).text = ThemeManager.displayColor(color)
|
||||
}
|
||||
|
||||
private fun applyCustomColor() {
|
||||
val redValue = findViewById<EditText>(R.id.edit_red).text.toString().toIntOrNull()
|
||||
val greenValue = findViewById<EditText>(R.id.edit_green).text.toString().toIntOrNull()
|
||||
val blueValue = findViewById<EditText>(R.id.edit_blue).text.toString().toIntOrNull()
|
||||
|
||||
if (redValue !in 0..255 || greenValue !in 0..255 || blueValue !in 0..255) {
|
||||
Toast.makeText(this, getString(R.string.toast_theme_invalid_rgb), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val red = redValue ?: return
|
||||
val green = greenValue ?: return
|
||||
val blue = blueValue ?: return
|
||||
ThemeManager.saveCustomColor(this, Color.rgb(red, green, blue))
|
||||
ThemeManager.applyToActivity(this)
|
||||
renderPresetRows()
|
||||
refreshPreview()
|
||||
Toast.makeText(this, getString(R.string.toast_theme_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
163
app/src/main/java/com/fan/edgex/ui/ThemeManager.kt
Normal file
163
app/src/main/java/com/fan/edgex/ui/ThemeManager.kt
Normal file
@@ -0,0 +1,163 @@
|
||||
package com.fan.edgex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.widget.CompoundButtonCompat
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
|
||||
object ThemeManager {
|
||||
const val PRESET_DEFAULT = "default"
|
||||
const val PRESET_CLASSIC = "classic"
|
||||
const val PRESET_CEDAR = "cedar"
|
||||
const val PRESET_OCEAN = "ocean"
|
||||
const val PRESET_EMBER = "ember"
|
||||
const val PRESET_CUSTOM = "custom"
|
||||
|
||||
private const val DEFAULT_CUSTOM_COLOR = "#326D32"
|
||||
|
||||
data class ThemePreset(
|
||||
val id: String,
|
||||
@StringRes val titleRes: Int,
|
||||
val accentColor: Int,
|
||||
)
|
||||
|
||||
val presets = listOf(
|
||||
ThemePreset(PRESET_DEFAULT, R.string.theme_preset_default, "#326D32".toColorInt()),
|
||||
ThemePreset(PRESET_CLASSIC, R.string.theme_preset_classic, "#00796B".toColorInt()),
|
||||
ThemePreset(PRESET_CEDAR, R.string.theme_preset_cedar, "#496B3D".toColorInt()),
|
||||
ThemePreset(PRESET_OCEAN, R.string.theme_preset_ocean, "#2F6F8F".toColorInt()),
|
||||
ThemePreset(PRESET_EMBER, R.string.theme_preset_ember, "#C56B2A".toColorInt()),
|
||||
)
|
||||
|
||||
fun currentPresetId(context: Context): String =
|
||||
context.getConfigString(AppConfig.THEME_PRESET, PRESET_DEFAULT)
|
||||
|
||||
fun currentAccent(context: Context): Int {
|
||||
val presetId = currentPresetId(context)
|
||||
if (presetId == PRESET_CUSTOM) {
|
||||
return parseColorOrDefault(context.getConfigString(AppConfig.THEME_CUSTOM_COLOR, DEFAULT_CUSTOM_COLOR))
|
||||
}
|
||||
return presets.firstOrNull { it.id == presetId }?.accentColor
|
||||
?: presets.first { it.id == PRESET_DEFAULT }.accentColor
|
||||
}
|
||||
|
||||
fun onAccentColor(accentColor: Int): Int =
|
||||
if (ColorUtils.calculateLuminance(accentColor) > 0.45) Color.BLACK else Color.WHITE
|
||||
|
||||
fun displayColor(color: Int): String =
|
||||
String.format("#%06X", 0xFFFFFF and color)
|
||||
|
||||
fun savePreset(context: Context, presetId: String) {
|
||||
context.putConfig(AppConfig.THEME_PRESET, presetId)
|
||||
}
|
||||
|
||||
fun saveCustomColor(context: Context, color: Int) {
|
||||
context.putConfig(AppConfig.THEME_CUSTOM_COLOR, displayColor(color))
|
||||
context.putConfig(AppConfig.THEME_PRESET, PRESET_CUSTOM)
|
||||
}
|
||||
|
||||
fun applyToActivity(activity: AppCompatActivity) {
|
||||
val root = activity.findViewById<View>(android.R.id.content) ?: return
|
||||
val accent = currentAccent(activity)
|
||||
applyToSystemBars(activity, onAccentColor(accent))
|
||||
applyToView(root, activity)
|
||||
}
|
||||
|
||||
fun applyToView(view: View, context: Context) {
|
||||
val accent = currentAccent(context)
|
||||
val onAccent = onAccentColor(accent)
|
||||
val secondaryOnAccent = ColorUtils.setAlphaComponent(onAccent, 179)
|
||||
applyRecursively(view, accent, onAccent, secondaryOnAccent)
|
||||
}
|
||||
|
||||
fun tintSwatch(view: View, color: Int) {
|
||||
tintBackground(view, color)
|
||||
}
|
||||
|
||||
private fun applyRecursively(view: View, accent: Int, onAccent: Int, secondaryOnAccent: Int) {
|
||||
if (view.id == R.id.header_container) {
|
||||
view.setBackgroundColor(accent)
|
||||
tintHeaderContent(view, onAccent, secondaryOnAccent)
|
||||
}
|
||||
|
||||
if (view.tag == "theme_icon_bg") {
|
||||
tintBackground(view, accent)
|
||||
}
|
||||
|
||||
if (view.tag == "theme_fab") {
|
||||
ViewCompat.setBackgroundTintList(view, ColorStateList.valueOf(accent))
|
||||
(view as? ImageView)?.setColorFilter(onAccent)
|
||||
}
|
||||
|
||||
when (view) {
|
||||
is android.widget.CompoundButton -> {
|
||||
CompoundButtonCompat.setButtonTintList(view, ColorStateList.valueOf(accent))
|
||||
}
|
||||
is Button -> {
|
||||
ViewCompat.setBackgroundTintList(view, ColorStateList.valueOf(accent))
|
||||
view.setTextColor(onAccent)
|
||||
}
|
||||
}
|
||||
|
||||
if (view is ViewGroup) {
|
||||
for (index in 0 until view.childCount) {
|
||||
applyRecursively(view.getChildAt(index), accent, onAccent, secondaryOnAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tintHeaderContent(view: View, onAccent: Int, secondaryOnAccent: Int) {
|
||||
when (view) {
|
||||
is ImageView -> view.setColorFilter(onAccent)
|
||||
is EditText -> {
|
||||
view.setTextColor(onAccent)
|
||||
view.setHintTextColor(secondaryOnAccent)
|
||||
}
|
||||
is TextView -> {
|
||||
val color = if (view.id == R.id.tv_subtitle) secondaryOnAccent else onAccent
|
||||
view.setTextColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
if (view is ViewGroup) {
|
||||
for (index in 0 until view.childCount) {
|
||||
tintHeaderContent(view.getChildAt(index), onAccent, secondaryOnAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tintBackground(view: View, color: Int) {
|
||||
val background = view.background?.mutate() ?: return
|
||||
when (background) {
|
||||
is GradientDrawable -> background.setColor(color)
|
||||
else -> DrawableCompat.setTint(background, color)
|
||||
}
|
||||
view.background = background
|
||||
}
|
||||
|
||||
private fun parseColorOrDefault(value: String): Int =
|
||||
runCatching { value.toColorInt() }.getOrElse { DEFAULT_CUSTOM_COLOR.toColorInt() }
|
||||
|
||||
private fun applyToSystemBars(activity: AppCompatActivity, onAccent: Int) {
|
||||
WindowInsetsControllerCompat(activity.window, activity.window.decorView)
|
||||
.isAppearanceLightStatusBars = onAccent == Color.BLACK
|
||||
}
|
||||
}
|
||||
348
app/src/main/java/com/fan/edgex/ui/compose/EdgeXApp.kt
Normal file
348
app/src/main/java/com/fan/edgex/ui/compose/EdgeXApp.kt
Normal file
@@ -0,0 +1,348 @@
|
||||
package com.fan.edgex.ui.compose
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.license.PremiumActivator
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.config.ModuleActivationState
|
||||
import com.fan.edgex.config.configPrefs
|
||||
import com.fan.edgex.config.getConfigBool
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfig
|
||||
import com.fan.edgex.ui.compose.components.EdgeXToast
|
||||
import com.fan.edgex.ui.compose.components.UpdateDialog
|
||||
import com.fan.edgex.ui.compose.screens.AboutScreen
|
||||
import com.fan.edgex.ui.compose.screens.FreezerScreen
|
||||
import com.fan.edgex.ui.compose.screens.GesturesScreen
|
||||
import com.fan.edgex.ui.compose.screens.HomeCallbacks
|
||||
import com.fan.edgex.ui.compose.screens.HomeScreen
|
||||
import com.fan.edgex.ui.compose.screens.HomeStats
|
||||
import com.fan.edgex.ui.compose.screens.KeysScreen
|
||||
import com.fan.edgex.ui.compose.screens.MultiScreen
|
||||
import com.fan.edgex.ui.compose.screens.PieScreen
|
||||
import com.fan.edgex.ui.compose.screens.CustomPanelScreen
|
||||
import com.fan.edgex.ui.compose.screens.PremiumScreen
|
||||
import com.fan.edgex.ui.compose.screens.SideBarScreen
|
||||
import com.fan.edgex.ui.compose.screens.ThemeScreen
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXAccent
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXTheme
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
import com.fan.edgex.utils.UpdateChecker
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class EdgeXRoute(@StringRes val labelRes: Int) {
|
||||
Home(R.string.compose_route_home),
|
||||
Gestures(R.string.header_gestures),
|
||||
Keys(R.string.header_keys),
|
||||
Freezer(R.string.header_freezer),
|
||||
Pie(R.string.header_pie_settings),
|
||||
CustomPanel(R.string.menu_custom_panel),
|
||||
SideBar(R.string.menu_side_bar),
|
||||
Multi(R.string.menu_multi_actions),
|
||||
Theme(R.string.header_theme),
|
||||
EdgeLighting(R.string.menu_edge_lighting),
|
||||
Premium(R.string.menu_premium),
|
||||
About(R.string.menu_about),
|
||||
}
|
||||
|
||||
data class HomeUiState(
|
||||
val stats: HomeStats,
|
||||
val gesturesEnabled: Boolean,
|
||||
val debug: Boolean,
|
||||
val haptic: Boolean,
|
||||
val hapticType: String,
|
||||
val arcDrawer: Boolean,
|
||||
val keysEnabled: Boolean,
|
||||
val edgeLighting: Boolean,
|
||||
val moduleActive: Boolean,
|
||||
val accent: EdgeXAccent,
|
||||
val darkMode: Boolean,
|
||||
val premiumStatus: PremiumActivator.Status,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EdgeXApp() {
|
||||
val context = LocalContext.current
|
||||
val restartSystemUiFailed = stringResource(R.string.toast_restart_sysui_failed)
|
||||
val updateChecking = stringResource(R.string.update_checking)
|
||||
val updateAlreadyLatest = stringResource(R.string.update_already_latest)
|
||||
val stack = remember { mutableStateListOf(EdgeXRoute.Home) }
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
var uiState by remember { mutableStateOf(context.readHomeUiState()) }
|
||||
var toast by remember { mutableStateOf<String?>(null) }
|
||||
var availableUpdate by remember { mutableStateOf<UpdateChecker.ReleaseInfo?>(null) }
|
||||
|
||||
fun refresh() {
|
||||
uiState = context.readHomeUiState()
|
||||
}
|
||||
|
||||
fun showToast(message: String) {
|
||||
toast = message
|
||||
}
|
||||
|
||||
fun popRoute() {
|
||||
if (stack.size > 1) {
|
||||
val popped = stack.removeAt(stack.lastIndex)
|
||||
saveableStateHolder.removeState(popped)
|
||||
}
|
||||
}
|
||||
|
||||
fun popRouteAndRefresh() {
|
||||
refresh()
|
||||
popRoute()
|
||||
}
|
||||
|
||||
fun checkForUpdates() {
|
||||
val activity = context as? Activity ?: return
|
||||
showToast(updateChecking)
|
||||
UpdateChecker.checkNow(activity) { release ->
|
||||
if (release == null) {
|
||||
showToast(updateAlreadyLatest)
|
||||
} else {
|
||||
availableUpdate = release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(toast) {
|
||||
if (toast != null) {
|
||||
delay(1800)
|
||||
toast = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
(context as? Activity)?.let { activity ->
|
||||
UpdateChecker.checkOnLaunch(activity) { availableUpdate = it }
|
||||
}
|
||||
ModuleActivationState.requestRefresh(context)
|
||||
delay(350)
|
||||
refresh()
|
||||
}
|
||||
|
||||
EdgeXTheme(darkTheme = uiState.darkMode, accent = uiState.accent) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
BackHandler(enabled = stack.size > 1) {
|
||||
when (stack.last()) {
|
||||
EdgeXRoute.Gestures,
|
||||
EdgeXRoute.Theme -> popRouteAndRefresh()
|
||||
else -> popRoute()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.bg)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
val route = stack.last()
|
||||
saveableStateHolder.SaveableStateProvider(key = route) {
|
||||
when (route) {
|
||||
EdgeXRoute.Home -> HomeScreen(
|
||||
state = uiState,
|
||||
callbacks = HomeCallbacks(
|
||||
openRoute = { stack.add(it) },
|
||||
showToast = ::showToast,
|
||||
restartSystemUi = {
|
||||
restartSystemUi {
|
||||
showToast(restartSystemUiFailed)
|
||||
}
|
||||
},
|
||||
setDebug = {
|
||||
context.putConfig(AppConfig.DEBUG_MATRIX, it)
|
||||
refresh()
|
||||
},
|
||||
setHaptic = {
|
||||
context.putConfig(AppConfig.HAPTIC_FEEDBACK, it)
|
||||
refresh()
|
||||
},
|
||||
setHapticType = {
|
||||
context.putConfig(AppConfig.HAPTIC_FEEDBACK_TYPE, it)
|
||||
refresh()
|
||||
},
|
||||
setArcDrawer = {
|
||||
context.putConfig(AppConfig.FREEZER_ARC_DRAWER, it)
|
||||
refresh()
|
||||
},
|
||||
),
|
||||
)
|
||||
EdgeXRoute.Gestures -> GesturesScreen(
|
||||
onBack = ::popRouteAndRefresh,
|
||||
showToast = ::showToast,
|
||||
onOpenMultiActions = { stack.add(EdgeXRoute.Multi) },
|
||||
)
|
||||
EdgeXRoute.Freezer -> FreezerScreen(
|
||||
onBack = ::popRouteAndRefresh,
|
||||
showToast = ::showToast,
|
||||
)
|
||||
EdgeXRoute.Keys -> KeysScreen(
|
||||
onBack = ::popRouteAndRefresh,
|
||||
showToast = ::showToast,
|
||||
)
|
||||
EdgeXRoute.Pie -> PieScreen(
|
||||
onBack = ::popRoute,
|
||||
)
|
||||
EdgeXRoute.CustomPanel -> CustomPanelScreen(
|
||||
onBack = ::popRoute,
|
||||
)
|
||||
EdgeXRoute.SideBar -> SideBarScreen(
|
||||
onBack = ::popRoute,
|
||||
)
|
||||
EdgeXRoute.Multi -> MultiScreen(
|
||||
onBack = ::popRoute,
|
||||
showToast = ::showToast,
|
||||
)
|
||||
EdgeXRoute.Theme -> ThemeScreen(
|
||||
onBack = ::popRouteAndRefresh,
|
||||
onThemeChanged = ::refresh,
|
||||
showToast = ::showToast,
|
||||
)
|
||||
EdgeXRoute.EdgeLighting -> { /* removed */ }
|
||||
EdgeXRoute.Premium -> PremiumScreen(
|
||||
onBack = ::popRoute,
|
||||
onOpenEdgeLighting = { },
|
||||
showToast = ::showToast,
|
||||
)
|
||||
EdgeXRoute.About -> AboutScreen(
|
||||
onBack = ::popRoute,
|
||||
showToast = ::showToast,
|
||||
onCheckForUpdates = ::checkForUpdates,
|
||||
onOpenSupportAuthor = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
EdgeXToast(
|
||||
message = toast,
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.BottomCenter),
|
||||
)
|
||||
|
||||
availableUpdate?.let { release ->
|
||||
UpdateDialog(
|
||||
release = release,
|
||||
onDismiss = { availableUpdate = null },
|
||||
onSkip = {
|
||||
UpdateChecker.skipVersion(context, release)
|
||||
availableUpdate = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartSystemUi(onFailure: () -> Unit) {
|
||||
Thread {
|
||||
val succeeded = runCatching {
|
||||
Shell.cmd("killall com.android.systemui").exec().isSuccess
|
||||
}.getOrDefault(false)
|
||||
if (!succeeded) {
|
||||
Handler(Looper.getMainLooper()).post(onFailure)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun Context.readHomeUiState(): HomeUiState {
|
||||
val prefs = configPrefs()
|
||||
val all = prefs.all.mapValues { (_, v) -> v?.toString() ?: "" }
|
||||
return HomeUiState(
|
||||
stats = readHomeStats(prefs, all),
|
||||
gesturesEnabled = all.getConfigBool(AppConfig.GESTURES_ENABLED),
|
||||
debug = all.getConfigBool(AppConfig.DEBUG_MATRIX),
|
||||
haptic = all.getConfigBool(AppConfig.HAPTIC_FEEDBACK, default = true),
|
||||
hapticType = all.getConfigString(
|
||||
AppConfig.HAPTIC_FEEDBACK_TYPE,
|
||||
AppConfig.HAPTIC_FEEDBACK_TYPE_CLICK,
|
||||
),
|
||||
arcDrawer = all.getConfigBool(AppConfig.FREEZER_ARC_DRAWER),
|
||||
keysEnabled = all.getConfigBool(AppConfig.KEYS_ENABLED),
|
||||
edgeLighting = all.getConfigBool(AppConfig.EDGE_LIGHTING_ENABLED, default = true),
|
||||
moduleActive = ModuleActivationState.isActive(this),
|
||||
accent = EdgeXAccent.fromId(all.getConfigString(AppConfig.UI_ACCENT, EdgeXAccent.Default.id)),
|
||||
darkMode = run {
|
||||
val darkSetting = all.getConfigString(AppConfig.UI_DARK_MODE, "system")
|
||||
when (darkSetting) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
"system" -> (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
else -> darkSetting.toBooleanStrictOrNull() ?: ((resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
}
|
||||
},
|
||||
premiumStatus = PremiumActivator.status(this),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Map<String, String>.getConfigString(key: String, default: String = ""): String =
|
||||
this[key] ?: default
|
||||
|
||||
private fun Map<String, String>.getConfigBool(key: String, default: Boolean = false): Boolean =
|
||||
this[key]?.toBooleanStrictOrNull() ?: default
|
||||
|
||||
private fun Context.readHomeStats(prefs: SharedPreferences, all: Map<String, String>): HomeStats {
|
||||
val configuredGestures = AppConfig.ZONES.sumOf { zone ->
|
||||
AppConfig.GESTURES.count { gesture ->
|
||||
val value = all.getConfigString(AppConfig.gestureAction(zone, gesture), "none")
|
||||
value.isNotBlank() && value != "none"
|
||||
}
|
||||
}
|
||||
val activeZones = AppConfig.ZONES.count { zone ->
|
||||
val enabledKey = AppConfig.zoneEnabled(zone)
|
||||
if (prefs.contains(enabledKey)) {
|
||||
all.getConfigBool(enabledKey)
|
||||
} else {
|
||||
AppConfig.GESTURES.any { gesture ->
|
||||
AppConfig.isActiveActionValue(all.getConfigString(AppConfig.gestureAction(zone, gesture), "none"))
|
||||
}
|
||||
}
|
||||
}
|
||||
val frozenCount = all[AppConfig.FREEZER_APP_LIST]
|
||||
?.split(',')
|
||||
?.count { it.isNotBlank() }
|
||||
?: 0
|
||||
val keyCount = if (all.getConfigBool(AppConfig.KEYS_ENABLED)) {
|
||||
listOf(24, 25, 26).count { keyCode ->
|
||||
val enabledKey = AppConfig.keyEnabled(keyCode)
|
||||
if (prefs.contains(enabledKey)) {
|
||||
all.getConfigBool(enabledKey)
|
||||
} else {
|
||||
AppConfig.KEY_TRIGGERS.any { trigger ->
|
||||
AppConfig.isActiveActionValue(all.getConfigString(AppConfig.keyAction(keyCode, trigger), "none"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return HomeStats(
|
||||
configuredGestures = configuredGestures,
|
||||
activeZones = activeZones,
|
||||
frozenApps = frozenCount,
|
||||
keyCount = keyCount,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.AppConfig
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
|
||||
data class ActionSelectionItem(
|
||||
val code: String,
|
||||
val labelRes: Int,
|
||||
val icon: Int,
|
||||
val needsSecondary: Boolean = false,
|
||||
)
|
||||
|
||||
val allActionSelectionItems = listOf(
|
||||
ActionSelectionItem("none", R.string.action_none, EdgeXIcons.Check),
|
||||
ActionSelectionItem("back", R.string.action_back, EdgeXIcons.Back),
|
||||
ActionSelectionItem("home", R.string.action_home, EdgeXIcons.Home),
|
||||
ActionSelectionItem("recents", R.string.action_recents, EdgeXIcons.Recents),
|
||||
ActionSelectionItem("expand_notifications", R.string.action_expand_notifications, EdgeXIcons.Notifications),
|
||||
ActionSelectionItem("shell_command", R.string.action_shell_command, EdgeXIcons.Terminal, needsSecondary = true),
|
||||
ActionSelectionItem("sub_gesture", R.string.action_sub_gesture, EdgeXIcons.SubGesture, needsSecondary = true),
|
||||
ActionSelectionItem("pie", R.string.action_pie, EdgeXIcons.Pie),
|
||||
ActionSelectionItem("launch_app", R.string.action_launch_app, EdgeXIcons.LaunchApp, needsSecondary = true),
|
||||
ActionSelectionItem("app_shortcut", R.string.action_app_shortcut, EdgeXIcons.AppShortcut, needsSecondary = true),
|
||||
ActionSelectionItem("clear_background", R.string.action_clear_background, EdgeXIcons.ClearBackground),
|
||||
ActionSelectionItem("freezer_drawer", R.string.action_freezer_drawer, EdgeXIcons.Freeze),
|
||||
ActionSelectionItem("refreeze", R.string.action_refreeze, EdgeXIcons.Refreeze),
|
||||
ActionSelectionItem("screenshot", R.string.action_screenshot, EdgeXIcons.Screenshot),
|
||||
ActionSelectionItem(AppConfig.PARTIAL_SCREENSHOT_ACTION, R.string.action_partial_screenshot, EdgeXIcons.PartialScreenshot),
|
||||
ActionSelectionItem("clipboard", R.string.action_clipboard, EdgeXIcons.Clipboard),
|
||||
ActionSelectionItem("universal_copy", R.string.action_universal_copy, EdgeXIcons.UniversalCopy),
|
||||
ActionSelectionItem("lock_screen", R.string.action_lock_screen, EdgeXIcons.Lock),
|
||||
ActionSelectionItem("kill_app", R.string.action_kill_app, EdgeXIcons.KillApp),
|
||||
ActionSelectionItem("prev_app", R.string.action_prev_app, EdgeXIcons.PrevApp),
|
||||
ActionSelectionItem("next_app", R.string.action_next_app, EdgeXIcons.NextApp),
|
||||
ActionSelectionItem("brightness_up", R.string.action_brightness_up, EdgeXIcons.BrightnessUp),
|
||||
ActionSelectionItem("brightness_down", R.string.action_brightness_down, EdgeXIcons.BrightnessDown),
|
||||
ActionSelectionItem("volume_up", R.string.action_volume_up, EdgeXIcons.VolumeUp),
|
||||
ActionSelectionItem("volume_down", R.string.action_volume_down, EdgeXIcons.VolumeDown),
|
||||
ActionSelectionItem("music_control", R.string.action_music_control, EdgeXIcons.Music, needsSecondary = true),
|
||||
ActionSelectionItem("fast_scroll", R.string.action_fast_scroll, EdgeXIcons.FastScroll, needsSecondary = true),
|
||||
ActionSelectionItem("multi_action", R.string.action_multi_action, EdgeXIcons.Multi, needsSecondary = true),
|
||||
ActionSelectionItem("condition", R.string.action_condition, EdgeXIcons.Condition, needsSecondary = true),
|
||||
ActionSelectionItem(AppConfig.CUSTOM_PANEL_ACTION, R.string.action_custom_panel, EdgeXIcons.CustomPanel),
|
||||
ActionSelectionItem(AppConfig.SIDE_BAR_LEFT_ACTION, R.string.action_left_side_bar, EdgeXIcons.SideBarLeft),
|
||||
ActionSelectionItem(AppConfig.SIDE_BAR_RIGHT_ACTION, R.string.action_right_side_bar, EdgeXIcons.SideBarRight),
|
||||
ActionSelectionItem("toggle_flashlight", R.string.action_toggle_flashlight, EdgeXIcons.Flashlight),
|
||||
ActionSelectionItem("toggle_wifi", R.string.action_toggle_wifi, EdgeXIcons.Wifi),
|
||||
ActionSelectionItem("toggle_mobile_data", R.string.action_toggle_mobile_data, EdgeXIcons.MobileData),
|
||||
ActionSelectionItem("game_mode", R.string.action_game_mode, EdgeXIcons.GameMode),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ActionSelectionSheet(
|
||||
open: Boolean,
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
excludedCodes: Set<String>,
|
||||
onSelect: (ActionSelectionItem) -> Unit,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
|
||||
val items = remember(excludedCodes) {
|
||||
allActionSelectionItems.filter { it.code !in excludedCodes }
|
||||
}
|
||||
|
||||
val query = searchQuery.trim()
|
||||
val filtered = items.filter { item ->
|
||||
query.isBlank() || stringResource(item.labelRes).contains(query, ignoreCase = true)
|
||||
}
|
||||
|
||||
val shellItem = filtered.find { it.code == "shell_command" }
|
||||
val musicItem = filtered.find { it.code == "music_control" }
|
||||
val others = filtered.filter { it.code != "shell_command" && it.code != "music_control" }
|
||||
|
||||
EdgeXBottomSheet(
|
||||
open = open,
|
||||
title = title,
|
||||
onDismissRequest = {
|
||||
searchQuery = ""
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
placeholder = { Text(stringResource(R.string.compose_search_actions_hint), color = colors.onSurfaceDim) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(EdgeXRadius.sm),
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
EdgeXIcon(EdgeXIcons.Back, contentDescription = "Clear", tint = colors.onSurfaceDim)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colors.accent,
|
||||
unfocusedBorderColor = colors.outline,
|
||||
cursorColor = colors.accent,
|
||||
),
|
||||
)
|
||||
|
||||
// Action Grid List
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val finalRows = if (searchQuery.isNotBlank()) {
|
||||
filtered.chunked(2)
|
||||
} else {
|
||||
val baseRows = others.chunked(2)
|
||||
val specialRow = listOfNotNull(musicItem, shellItem)
|
||||
val fRows = mutableListOf<List<ActionSelectionItem>>()
|
||||
var inserted = false
|
||||
baseRows.forEach { row ->
|
||||
fRows.add(row)
|
||||
if (!inserted && specialRow.isNotEmpty() && row.any { it.code == "expand_notifications" || it.code == "sub_gesture" }) {
|
||||
fRows.add(specialRow)
|
||||
inserted = true
|
||||
}
|
||||
}
|
||||
if (!inserted && specialRow.isNotEmpty()) {
|
||||
fRows.add(specialRow)
|
||||
}
|
||||
fRows
|
||||
}
|
||||
|
||||
finalRows.forEach { rowItems ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
ActionGridItem(
|
||||
action = rowItems[0],
|
||||
onClick = {
|
||||
searchQuery = ""
|
||||
onSelect(rowItems[0])
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (rowItems.size > 1) {
|
||||
ActionGridItem(
|
||||
action = rowItems[1],
|
||||
onClick = {
|
||||
searchQuery = ""
|
||||
onSelect(rowItems[1])
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionGridItem(
|
||||
action: ActionSelectionItem,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.testTag("gesture_action_${action.code}")
|
||||
.clip(RoundedCornerShape(EdgeXRadius.sm))
|
||||
.background(colors.surface1)
|
||||
.border(1.dp, colors.outline, RoundedCornerShape(EdgeXRadius.sm))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(RoundedCornerShape(EdgeXRadius.xs))
|
||||
.background(colors.accentSoft),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
EdgeXIcon(
|
||||
imageVector = action.icon,
|
||||
contentDescription = null,
|
||||
tint = colors.onAccentSoft,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(action.labelRes),
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (action.needsSecondary) {
|
||||
EdgeXIcon(
|
||||
imageVector = EdgeXIcons.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = colors.onSurfaceDim,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
data class AppItem(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val icon: Drawable?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppPickerSheet(
|
||||
open: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (AppItem) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colors = LocalEdgeXColors.current
|
||||
var apps by remember { mutableStateOf(emptyList<AppItem>()) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
LaunchedEffect(open) {
|
||||
if (open && apps.isEmpty()) {
|
||||
apps = withContext(Dispatchers.IO) { context.loadLaunchableApps() }
|
||||
}
|
||||
if (!open) query = ""
|
||||
}
|
||||
EdgeXBottomSheet(open = open, title = stringResource(R.string.action_launch_app), onDismissRequest = onDismiss) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
placeholder = { Text(stringResource(R.string.hint_search_apps), color = colors.onSurfaceDim) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(EdgeXRadius.sm),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colors.accent,
|
||||
unfocusedBorderColor = colors.outline,
|
||||
cursorColor = colors.accent,
|
||||
),
|
||||
)
|
||||
val filtered = remember(apps, query) {
|
||||
val q = query.trim()
|
||||
if (q.isBlank()) {
|
||||
apps
|
||||
} else {
|
||||
apps.filter {
|
||||
it.label.contains(q, ignoreCase = true) || it.packageName.contains(q, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
EdgeXListGroup {
|
||||
filtered.forEachIndexed { index, app ->
|
||||
AppRow(app = app, onClick = { onPick(app) })
|
||||
if (index != filtered.lastIndex) EdgeXDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRow(app: AppItem, onClick: () -> Unit) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||
if (app.icon != null) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
}
|
||||
},
|
||||
update = { imageView ->
|
||||
val drawable = app.icon.constantState?.newDrawable()?.mutate() ?: app.icon
|
||||
imageView.setImageDrawable(drawable)
|
||||
},
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
} else {
|
||||
EdgeXIconBox(EdgeXIcons.LaunchApp, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.label,
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName,
|
||||
color = colors.onSurfaceDim,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Context.loadLaunchableApps(): List<AppItem> {
|
||||
val pm = packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
return pm.queryIntentActivities(intent, 0)
|
||||
.map { info ->
|
||||
AppItem(
|
||||
packageName = info.activityInfo.packageName,
|
||||
label = info.loadLabel(pm).toString(),
|
||||
icon = runCatching { info.loadIcon(pm) }.getOrNull(),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.packageName }
|
||||
.sortedBy { it.label.lowercase(Locale.getDefault()) }
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
data class ShortcutItem(
|
||||
val packageName: String,
|
||||
val shortcutId: String,
|
||||
val label: String,
|
||||
val appLabel: String,
|
||||
val icon: Drawable?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppShortcutPickerSheet(
|
||||
open: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (ShortcutItem) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colors = LocalEdgeXColors.current
|
||||
var shortcuts by remember { mutableStateOf(emptyList<ShortcutItem>()) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(open) {
|
||||
if (open && shortcuts.isEmpty()) {
|
||||
shortcuts = withContext(Dispatchers.IO) { context.loadShortcuts() }
|
||||
}
|
||||
if (!open) query = ""
|
||||
}
|
||||
|
||||
EdgeXBottomSheet(open = open, title = stringResource(R.string.action_app_shortcut), onDismissRequest = onDismiss) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
placeholder = { Text(stringResource(R.string.hint_search_apps), color = colors.onSurfaceDim) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(EdgeXRadius.sm),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colors.accent,
|
||||
unfocusedBorderColor = colors.outline,
|
||||
cursorColor = colors.accent,
|
||||
),
|
||||
)
|
||||
val filtered = remember(shortcuts, query) {
|
||||
val q = query.trim()
|
||||
if (q.isBlank()) {
|
||||
shortcuts
|
||||
} else {
|
||||
shortcuts.filter {
|
||||
it.label.contains(q, ignoreCase = true) ||
|
||||
it.appLabel.contains(q, ignoreCase = true) ||
|
||||
it.packageName.contains(q, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
EdgeXListGroup {
|
||||
filtered.forEachIndexed { index, shortcut ->
|
||||
ShortcutRow(shortcut = shortcut, onClick = { onPick(shortcut) })
|
||||
if (index != filtered.lastIndex) EdgeXDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShortcutRow(shortcut: ShortcutItem, onClick: () -> Unit) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||
if (shortcut.icon != null) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
}
|
||||
},
|
||||
update = { imageView ->
|
||||
val drawable = shortcut.icon.constantState?.newDrawable()?.mutate() ?: shortcut.icon
|
||||
imageView.setImageDrawable(drawable)
|
||||
},
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
} else {
|
||||
EdgeXIconBox(EdgeXIcons.AppShortcut, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = shortcut.label,
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = shortcut.appLabel,
|
||||
color = colors.onSurfaceDim,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.loadShortcuts(): List<ShortcutItem> {
|
||||
val result = loadShortcutsViaLauncherApps()
|
||||
return result.ifEmpty { loadShortcutsViaRoot() }
|
||||
}
|
||||
|
||||
private fun Context.loadShortcutsViaLauncherApps(): List<ShortcutItem> {
|
||||
val launcherApps = getSystemService(Context.LAUNCHER_APPS_SERVICE) as android.content.pm.LauncherApps
|
||||
val pm = packageManager
|
||||
val mainIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val apps = pm.queryIntentActivities(mainIntent, 0)
|
||||
val tempList = mutableListOf<ShortcutItem>()
|
||||
for (app in apps) {
|
||||
val packageName = app.activityInfo.packageName
|
||||
val appLabel = app.loadLabel(pm).toString()
|
||||
try {
|
||||
val query = android.content.pm.LauncherApps.ShortcutQuery()
|
||||
query.setQueryFlags(
|
||||
android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
|
||||
android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
|
||||
android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED
|
||||
)
|
||||
query.setPackage(packageName)
|
||||
val appShortcuts = launcherApps.getShortcuts(query, android.os.Process.myUserHandle()) ?: emptyList()
|
||||
for (shortcut in appShortcuts) {
|
||||
val icon = try {
|
||||
launcherApps.getShortcutIconDrawable(shortcut, 0)
|
||||
} catch (e: Exception) {
|
||||
app.loadIcon(pm)
|
||||
}
|
||||
tempList.add(
|
||||
ShortcutItem(
|
||||
packageName = packageName,
|
||||
shortcutId = shortcut.id,
|
||||
label = shortcut.shortLabel?.toString() ?: shortcut.longLabel?.toString() ?: "",
|
||||
appLabel = appLabel,
|
||||
icon = icon,
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return tempList.sortedWith(compareBy({ it.appLabel }, { it.label }))
|
||||
}
|
||||
|
||||
private fun Context.loadShortcutsViaRoot(): List<ShortcutItem> {
|
||||
val rootShortcuts = mutableListOf<ShortcutItem>()
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "dumpsys shortcut"))
|
||||
val reader = java.io.BufferedReader(java.io.InputStreamReader(process.inputStream))
|
||||
var line: String?
|
||||
var currentPackage: String? = null
|
||||
var currentId: String? = null
|
||||
var currentLabel: String? = null
|
||||
val pm = packageManager
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
val l = line!!.trim()
|
||||
if (l.startsWith("Package:") && l.contains("uid=")) {
|
||||
val parts = l.split("\\s+".toRegex())
|
||||
if (parts.size >= 2) {
|
||||
currentPackage = parts[1]
|
||||
}
|
||||
}
|
||||
if (l.startsWith("ShortcutInfo") && l.contains("id=")) {
|
||||
val afterId = l.substringAfter("id=")
|
||||
currentId = afterId.substringBefore(",").substringBefore(" ").trim()
|
||||
currentLabel = null
|
||||
} else if (l.startsWith("id=")) {
|
||||
currentId = l.substringAfter("id=").trim()
|
||||
}
|
||||
if (l.startsWith("packageName=")) {
|
||||
currentPackage = l.substringAfter("packageName=").trim()
|
||||
}
|
||||
if (l.startsWith("shortLabel=")) {
|
||||
val raw = l.substringAfter("shortLabel=")
|
||||
currentLabel = if (raw.contains(", resId=")) {
|
||||
raw.substringBefore(", resId=")
|
||||
} else {
|
||||
raw.substringBefore(",")
|
||||
}
|
||||
currentLabel = currentLabel?.trim()
|
||||
if (currentPackage != null && currentId != null && currentLabel != null) {
|
||||
val exists = rootShortcuts.any { it.packageName == currentPackage && it.shortcutId == currentId }
|
||||
if (!exists) {
|
||||
try {
|
||||
val appInfo = pm.getApplicationInfo(currentPackage!!, 0)
|
||||
rootShortcuts.add(
|
||||
ShortcutItem(
|
||||
packageName = currentPackage!!,
|
||||
shortcutId = currentId!!,
|
||||
label = currentLabel!!,
|
||||
appLabel = appInfo.loadLabel(pm).toString(),
|
||||
icon = appInfo.loadIcon(pm),
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
currentId = null
|
||||
currentLabel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.close()
|
||||
process.waitFor()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return rootShortcuts.sortedWith(compareBy({ it.appLabel }, { it.label }))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.fan.edgex.R
|
||||
|
||||
data class ConditionItem(
|
||||
val labelRes: Int,
|
||||
val code: String,
|
||||
val iconRes: Int,
|
||||
)
|
||||
|
||||
val allConditionItems = listOf(
|
||||
ConditionItem(R.string.cond_foreground_app, "foreground_app", R.drawable.ic_apps),
|
||||
ConditionItem(R.string.cond_auto_brightness, "auto_brightness", R.drawable.ic_brightness_up),
|
||||
ConditionItem(R.string.cond_auto_rotate, "auto_rotate", R.drawable.ic_screen_rotation),
|
||||
ConditionItem(R.string.cond_wifi_enabled, "wifi_enabled", R.drawable.ic_wifi),
|
||||
ConditionItem(R.string.cond_mobile_data, "mobile_data", R.drawable.ic_mobile_data),
|
||||
ConditionItem(R.string.cond_location, "location", R.drawable.ic_location),
|
||||
ConditionItem(R.string.cond_bluetooth, "bluetooth", R.drawable.ic_bluetooth),
|
||||
ConditionItem(R.string.cond_nfc, "nfc", R.drawable.ic_nfc),
|
||||
ConditionItem(R.string.cond_power_connected, "power_connected", R.drawable.ic_power),
|
||||
ConditionItem(R.string.cond_wifi_connected, "wifi_connected", R.drawable.ic_wifi),
|
||||
ConditionItem(R.string.cond_network_connected, "network_connected", R.drawable.ic_link),
|
||||
ConditionItem(R.string.cond_media_playing, "media_playing", R.drawable.ic_music),
|
||||
ConditionItem(R.string.cond_screen_portrait, "screen_portrait", R.drawable.ic_screen_portrait),
|
||||
ConditionItem(R.string.cond_screen_landscape, "screen_landscape", R.drawable.ic_screen_landscape),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ConditionPickerSheet(
|
||||
open: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (ConditionItem) -> Unit,
|
||||
) {
|
||||
EdgeXBottomSheet(
|
||||
open = open,
|
||||
title = stringResource(R.string.header_condition_if),
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
EdgeXListGroup {
|
||||
allConditionItems.forEachIndexed { index, item ->
|
||||
EdgeXRow(
|
||||
title = stringResource(item.labelRes),
|
||||
icon = item.iconRes,
|
||||
onClick = { onSelect(item) },
|
||||
)
|
||||
if (index != allConditionItems.lastIndex) EdgeXDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
|
||||
@Composable
|
||||
fun EdgeXTopBar(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
EdgeXIconButton(onClick = onBack) {
|
||||
EdgeXIcon(EdgeXIcons.Back, contentDescription = stringResource(R.string.compose_back), tint = colors.onSurface)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
tonal: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (tonal) colors.accentSoft else Color.Transparent)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXIconBox(
|
||||
imageVector: Int,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
background: Color = LocalEdgeXColors.current.accentSoft,
|
||||
tint: Color = LocalEdgeXColors.current.onAccentSoft,
|
||||
iconSize: androidx.compose.ui.unit.Dp = 22.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(EdgeXRadius.sm))
|
||||
.background(background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
EdgeXIcon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(iconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXTile(
|
||||
title: String,
|
||||
meta: String,
|
||||
icon: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
iconBackground: Color = LocalEdgeXColors.current.accentSoft,
|
||||
iconTint: Color = LocalEdgeXColors.current.onAccentSoft,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Card(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(EdgeXRadius.lg),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.surface),
|
||||
border = BorderStroke(1.dp, colors.outline),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
EdgeXIconBox(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
background = iconBackground,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Column {
|
||||
Text(title, color = colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(meta, color = colors.onSurfaceDim, fontWeight = FontWeight.Medium, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXListGroup(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(EdgeXRadius.lg))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.outline, RoundedCornerShape(EdgeXRadius.lg)),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXRow(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
icon: Int? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
EdgeXIconBox(imageVector = icon, contentDescription = null)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, color = colors.onSurface, fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(subtitle, color = colors.onSurfaceDim, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXSwitchRow(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
icon: Int? = null,
|
||||
) {
|
||||
EdgeXRow(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
modifier = modifier,
|
||||
onClick = { onCheckedChange(!checked) },
|
||||
) {
|
||||
EdgeXSwitch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXSwitch(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = modifier,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = colors.onAccent,
|
||||
checkedTrackColor = colors.accent,
|
||||
checkedBorderColor = colors.accent,
|
||||
uncheckedThumbColor = colors.onSurfaceDim,
|
||||
uncheckedTrackColor = colors.surface2,
|
||||
uncheckedBorderColor = colors.outlineStrong,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> EdgeXSegmentedControl(
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: (T) -> String,
|
||||
onSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
EdgeXChip(
|
||||
label = label(option),
|
||||
selected = option == selected,
|
||||
onClick = { onSelected(option) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(32.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = if (selected) colors.accentSoft else colors.surface1,
|
||||
contentColor = if (selected) colors.onAccentSoft else colors.onSurface2,
|
||||
),
|
||||
border = if (selected) null else BorderStroke(1.dp, colors.outline),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
) {
|
||||
Text(label, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EdgeXBottomSheet(
|
||||
open: Boolean,
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
skipPartiallyExpanded: Boolean = true,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
if (open) {
|
||||
val sheetState = androidx.compose.material3.rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
sheetState = sheetState,
|
||||
containerColor = colors.surface,
|
||||
contentColor = colors.onSurface,
|
||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||
dragHandle = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp, bottom = 4.dp)
|
||||
.size(width = 36.dp, height = 4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(colors.outlineStrong),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 12.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun EdgeXToast(
|
||||
message: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = !message.isNullOrBlank()) {
|
||||
Surface(
|
||||
color = colors.onSurface,
|
||||
contentColor = colors.surface,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
) {
|
||||
Text(
|
||||
text = message.orEmpty(),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXDivider() {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(colors.outline),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier =
|
||||
clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PreviewSectionHeader(title: String, subtitle: String) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Column(modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 4.dp, bottom = 6.dp)) {
|
||||
Text(title, color = colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||
Text(subtitle, color = colors.onSurfaceDim, fontWeight = FontWeight.Medium, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhoneFrame(
|
||||
modifier: Modifier = Modifier,
|
||||
width: Dp = 176.dp,
|
||||
height: Dp = 320.dp,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.background(Color(0xFF1D2018))
|
||||
.border(1.dp, colors.accent.copy(alpha = 0.24f), RoundedCornerShape(30.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 11.dp)
|
||||
.width(24.dp)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color.White.copy(alpha = 0.35f)),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.fan.edgex.R
|
||||
|
||||
object EdgeXIcons {
|
||||
@DrawableRes val Back = R.drawable.ic_arrow_back
|
||||
@DrawableRes val Search = R.drawable.ic_search
|
||||
@DrawableRes val More = R.drawable.ic_more_vert
|
||||
@DrawableRes val ChevronRight = R.drawable.ic_chevron_right
|
||||
@DrawableRes val Check = R.drawable.ic_action_dot
|
||||
@DrawableRes val Edit = R.drawable.ic_edit
|
||||
@DrawableRes val Save = R.drawable.ic_save
|
||||
@DrawableRes val Duplicate = R.drawable.ic_duplicate
|
||||
@DrawableRes val Execute = R.drawable.ic_execute
|
||||
@DrawableRes val MoveUp = R.drawable.ic_arrow_drop_up
|
||||
@DrawableRes val MoveDown = R.drawable.ic_arrow_drop_down
|
||||
@DrawableRes val Plus = R.drawable.ic_add
|
||||
@DrawableRes val Gesture = R.drawable.ic_gesture
|
||||
@DrawableRes val Freeze = R.drawable.ic_freezer
|
||||
@DrawableRes val Keys = R.drawable.ic_keyboard
|
||||
@DrawableRes val Pie = R.drawable.ic_pie_menu
|
||||
@DrawableRes val Multi = R.drawable.ic_multi_action
|
||||
@DrawableRes val Theme = R.drawable.ic_theme
|
||||
@DrawableRes val DarkMode = R.drawable.ic_dark_mode
|
||||
@DrawableRes val Sparkle = R.drawable.ic_supporter_extra
|
||||
@DrawableRes val VolumeUp = R.drawable.ic_volume_up
|
||||
@DrawableRes val VolumeDown = R.drawable.ic_volume_down
|
||||
@DrawableRes val Power = R.drawable.ic_power
|
||||
@DrawableRes val Home = R.drawable.ic_home
|
||||
@DrawableRes val Recents = R.drawable.ic_recents
|
||||
@DrawableRes val Lock = R.drawable.ic_power
|
||||
@DrawableRes val Screenshot = R.drawable.ic_camera
|
||||
@DrawableRes val Flashlight = R.drawable.ic_flashlight
|
||||
@DrawableRes val Notifications = R.drawable.ic_notifications
|
||||
@DrawableRes val BrightnessUp = R.drawable.ic_brightness_up
|
||||
@DrawableRes val BrightnessDown = R.drawable.ic_brightness_down
|
||||
@DrawableRes val ClearBackground = R.drawable.ic_clear_recent
|
||||
@DrawableRes val KillApp = R.drawable.ic_kill_app
|
||||
@DrawableRes val PrevApp = R.drawable.ic_prev_app
|
||||
@DrawableRes val NextApp = R.drawable.ic_next_app
|
||||
@DrawableRes val Apps = R.drawable.ic_apps
|
||||
@DrawableRes val CustomPanel = R.drawable.ic_apps
|
||||
@DrawableRes val EdgePanel = R.drawable.ic_edge_panel
|
||||
@DrawableRes val SideBar = R.drawable.ic_side_bar
|
||||
@DrawableRes val SideBarLeft = R.drawable.ic_side_bar_left
|
||||
@DrawableRes val SideBarRight = R.drawable.ic_side_bar_right
|
||||
@DrawableRes val Settings = R.drawable.ic_settings
|
||||
@DrawableRes val DeveloperMode = R.drawable.ic_developer_mode
|
||||
@DrawableRes val ArcDrawer = R.drawable.ic_arc_drawer
|
||||
@DrawableRes val About = R.drawable.ic_about
|
||||
@DrawableRes val Person = R.drawable.ic_person
|
||||
@DrawableRes val EdgeLighting = R.drawable.ic_edge_lighting
|
||||
@DrawableRes val Info = R.drawable.ic_info
|
||||
@DrawableRes val Donate = R.drawable.ic_donate
|
||||
@DrawableRes val Link = R.drawable.ic_link
|
||||
@DrawableRes val Terminal = R.drawable.ic_terminal
|
||||
@DrawableRes val Vibration = R.drawable.ic_vibration
|
||||
@DrawableRes val Restart = R.drawable.ic_restart_alt
|
||||
@DrawableRes val SubGesture = R.drawable.ic_sub_gesture
|
||||
@DrawableRes val LaunchApp = R.drawable.ic_launch_app
|
||||
@DrawableRes val AppShortcut = R.drawable.ic_app_shortcut
|
||||
@DrawableRes val Clipboard = R.drawable.ic_paste
|
||||
@DrawableRes val UniversalCopy = R.drawable.ic_content_copy
|
||||
@DrawableRes val Music = R.drawable.ic_music
|
||||
@DrawableRes val FastScroll = R.drawable.ic_fast_scroll
|
||||
@DrawableRes val Condition = R.drawable.ic_condition
|
||||
@DrawableRes val If = R.drawable.ic_if
|
||||
@DrawableRes val Wifi = R.drawable.ic_wifi
|
||||
@DrawableRes val MobileData = R.drawable.ic_mobile_data
|
||||
@DrawableRes val GooglePlay = R.drawable.ic_google_play
|
||||
@DrawableRes val GameMode = R.drawable.ic_game_mode
|
||||
@DrawableRes val Refreeze = R.drawable.ic_refreeze
|
||||
@DrawableRes val PartialScreenshot = R.drawable.ic_partial_screenshot
|
||||
@DrawableRes val Alipay = R.drawable.ic_alipay
|
||||
@DrawableRes val WechatPay = R.drawable.ic_wechat_pay
|
||||
@DrawableRes val KoFi = R.drawable.ic_ko_fi
|
||||
@DrawableRes val Eth = R.drawable.ic_eth
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EdgeXIcon(
|
||||
@DrawableRes imageVector: Int,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(imageVector),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.fan.edgex.R
|
||||
|
||||
@Composable
|
||||
fun FastScrollSheet(
|
||||
open: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (code: String, label: String) -> Unit,
|
||||
) {
|
||||
EdgeXBottomSheet(open = open, title = stringResource(R.string.header_fast_scroll), onDismissRequest = onDismiss) {
|
||||
val options = listOf(
|
||||
Triple("to_top", R.string.action_scroll_to_top, R.drawable.ic_scroll_to_top),
|
||||
Triple("to_bottom", R.string.action_scroll_to_bottom, R.drawable.ic_scroll_to_bottom),
|
||||
)
|
||||
EdgeXListGroup {
|
||||
options.forEachIndexed { index, option ->
|
||||
val label = stringResource(option.second)
|
||||
EdgeXRow(title = label, icon = option.third, onClick = { onPick(option.first, label) })
|
||||
if (index != options.lastIndex) EdgeXDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ForegroundAppConditionSheet(
|
||||
open: Boolean,
|
||||
initialPackageNames: Set<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (packageNames: Set<String>) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colors = LocalEdgeXColors.current
|
||||
var apps by remember { mutableStateOf(emptyList<AppItem>()) }
|
||||
var query by remember(open) { mutableStateOf("") }
|
||||
var selectedPackages by remember(open, initialPackageNames) {
|
||||
mutableStateOf(initialPackageNames)
|
||||
}
|
||||
|
||||
LaunchedEffect(open) {
|
||||
if (open && apps.isEmpty()) {
|
||||
apps = withContext(Dispatchers.IO) { context.loadLaunchableApps() }
|
||||
}
|
||||
}
|
||||
|
||||
EdgeXBottomSheet(
|
||||
open = open,
|
||||
title = stringResource(R.string.cond_foreground_app),
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag("foreground_app_condition_sheet"),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cond_foreground_selected_count, selectedPackages.size),
|
||||
color = colors.onSurfaceDim,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.testTag("foreground_app_selected_count"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.testTag("foreground_app_search"),
|
||||
placeholder = { Text(stringResource(R.string.hint_search_apps), color = colors.onSurfaceDim) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(EdgeXRadius.sm),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colors.accent,
|
||||
unfocusedBorderColor = colors.outline,
|
||||
cursorColor = colors.accent,
|
||||
),
|
||||
)
|
||||
|
||||
val filteredApps = remember(apps, query) {
|
||||
val normalizedQuery = query.trim()
|
||||
if (normalizedQuery.isEmpty()) {
|
||||
apps
|
||||
} else {
|
||||
apps.filter { app ->
|
||||
app.label.contains(normalizedQuery, ignoreCase = true) ||
|
||||
app.packageName.contains(normalizedQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
EdgeXListGroup {
|
||||
filteredApps.forEachIndexed { index, app ->
|
||||
ForegroundAppRow(
|
||||
app = app,
|
||||
checked = app.packageName in selectedPackages,
|
||||
onToggle = {
|
||||
selectedPackages = if (app.packageName in selectedPackages) {
|
||||
selectedPackages - app.packageName
|
||||
} else {
|
||||
selectedPackages + app.packageName
|
||||
}
|
||||
},
|
||||
)
|
||||
if (index != filteredApps.lastIndex) EdgeXDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("foreground_app_cancel"),
|
||||
) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(selectedPackages) },
|
||||
enabled = selectedPackages.isNotEmpty(),
|
||||
modifier = Modifier.testTag("foreground_app_save"),
|
||||
shape = RoundedCornerShape(EdgeXRadius.md),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.btn_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForegroundAppRow(
|
||||
app: AppItem,
|
||||
checked: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
val colors = LocalEdgeXColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
.testTag("foreground_app_package_${app.packageName}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||
if (app.icon != null) {
|
||||
AndroidView(
|
||||
factory = { imageContext ->
|
||||
ImageView(imageContext).apply { scaleType = ImageView.ScaleType.CENTER_INSIDE }
|
||||
},
|
||||
update = { imageView ->
|
||||
val drawable = app.icon.constantState?.newDrawable()?.mutate() ?: app.icon
|
||||
imageView.setImageDrawable(drawable)
|
||||
},
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
} else {
|
||||
EdgeXIconBox(EdgeXIcons.LaunchApp, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.label,
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName,
|
||||
color = colors.onSurfaceDim,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { onToggle() },
|
||||
modifier = Modifier.testTag("foreground_app_checkbox_${app.packageName}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.fan.edgex.R
|
||||
|
||||
@Composable
|
||||
fun MusicControlSheet(
|
||||
open: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (code: String, label: String) -> Unit,
|
||||
) {
|
||||
EdgeXBottomSheet(open = open, title = stringResource(R.string.header_music_control), onDismissRequest = onDismiss) {
|
||||
val options = listOf(
|
||||
Triple("play_pause", R.string.action_music_play_pause, R.drawable.ic_music_play_pause),
|
||||
Triple("stop", R.string.action_music_stop, R.drawable.ic_music_stop),
|
||||
Triple("previous", R.string.action_music_previous, R.drawable.ic_music_previous),
|
||||
Triple("next", R.string.action_music_next, R.drawable.ic_music_next),
|
||||
)
|
||||
EdgeXListGroup {
|
||||
options.forEachIndexed { index, option ->
|
||||
val label = stringResource(option.second)
|
||||
val actionLabel = stringResource(R.string.label_music_prefix, label)
|
||||
EdgeXRow(title = label, icon = option.third, onClick = { onPick(option.first, actionLabel) })
|
||||
if (index != options.lastIndex) EdgeXDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.MultiActionStore
|
||||
import com.fan.edgex.config.putConfigsSync
|
||||
import com.fan.edgex.ui.compose.screens.ConditionSheet
|
||||
import com.fan.edgex.ui.compose.screens.MultiActionPickerSheet
|
||||
import com.fan.edgex.ui.compose.screens.SubGestureSheet
|
||||
|
||||
enum class SecondaryType {
|
||||
AppPicker,
|
||||
MusicControl,
|
||||
FastScroll,
|
||||
ShellCommand,
|
||||
AppShortcut,
|
||||
SubGesture,
|
||||
Condition,
|
||||
MultiAction,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): SecondaryType? = when (code) {
|
||||
"launch_app" -> AppPicker
|
||||
"music_control" -> MusicControl
|
||||
"fast_scroll" -> FastScroll
|
||||
"shell_command" -> ShellCommand
|
||||
"app_shortcut" -> AppShortcut
|
||||
"sub_gesture" -> SubGesture
|
||||
"condition" -> Condition
|
||||
"multi_action" -> MultiAction
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondaryActionDispatcher(
|
||||
type: SecondaryType?,
|
||||
prefKey: String,
|
||||
title: String,
|
||||
excludedCodes: Set<String> = emptySet(),
|
||||
onCreateMultiAction: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
when (type) {
|
||||
SecondaryType.AppPicker -> {
|
||||
AppPickerSheet(
|
||||
open = true,
|
||||
onDismiss = onDismiss,
|
||||
onPick = { app ->
|
||||
context.putConfigsSync(
|
||||
prefKey to "launch_app:${app.packageName}",
|
||||
"${prefKey}_label" to app.label,
|
||||
"${prefKey}_title" to app.label,
|
||||
)
|
||||
onSaved()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.MusicControl -> {
|
||||
MusicControlSheet(
|
||||
open = true,
|
||||
onDismiss = onDismiss,
|
||||
onPick = { code, label ->
|
||||
context.putConfigsSync(
|
||||
prefKey to "music_control:$code",
|
||||
"${prefKey}_label" to label,
|
||||
"${prefKey}_title" to "",
|
||||
)
|
||||
onSaved()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.FastScroll -> {
|
||||
FastScrollSheet(
|
||||
open = true,
|
||||
onDismiss = onDismiss,
|
||||
onPick = { code, label ->
|
||||
context.putConfigsSync(
|
||||
prefKey to "fast_scroll:$code",
|
||||
"${prefKey}_label" to label,
|
||||
"${prefKey}_title" to "",
|
||||
)
|
||||
onSaved()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.ShellCommand -> {
|
||||
ShellCommandSheet(
|
||||
open = true,
|
||||
prefKey = prefKey,
|
||||
onDismiss = onDismiss,
|
||||
onSave = onSaved,
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.AppShortcut -> {
|
||||
AppShortcutPickerSheet(
|
||||
open = true,
|
||||
onDismiss = onDismiss,
|
||||
onPick = { shortcut ->
|
||||
context.putConfigsSync(
|
||||
prefKey to "app_shortcut:${shortcut.packageName}:${shortcut.shortcutId}",
|
||||
"${prefKey}_label" to shortcut.label,
|
||||
"${prefKey}_title" to shortcut.label,
|
||||
)
|
||||
onSaved()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.SubGesture -> {
|
||||
SubGestureSheet(
|
||||
open = true,
|
||||
prefKey = prefKey,
|
||||
title = title,
|
||||
excludedCodes = excludedCodes,
|
||||
onDismiss = onDismiss,
|
||||
onSaved = onSaved,
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.Condition -> {
|
||||
ConditionSheet(
|
||||
open = true,
|
||||
prefKey = prefKey,
|
||||
title = title,
|
||||
excludedCodes = excludedCodes,
|
||||
onDismiss = onDismiss,
|
||||
onSaved = onSaved,
|
||||
)
|
||||
}
|
||||
|
||||
SecondaryType.MultiAction -> {
|
||||
MultiActionPickerSheet(
|
||||
open = true,
|
||||
currentId = "",
|
||||
onCreate = onCreateMultiAction,
|
||||
onDismiss = onDismiss,
|
||||
onPick = { action ->
|
||||
context.putConfigsSync(
|
||||
prefKey to MultiActionStore.actionCode(action.id),
|
||||
"${prefKey}_label" to action.name,
|
||||
"${prefKey}_title" to action.name,
|
||||
)
|
||||
onSaved()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import android.widget.Toast
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.config.getConfigString
|
||||
import com.fan.edgex.config.putConfigsSync
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
|
||||
@Composable
|
||||
fun ShellCommandSheet(
|
||||
open: Boolean,
|
||||
prefKey: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colors = LocalEdgeXColors.current
|
||||
var command by remember { mutableStateOf("") }
|
||||
var runAsRoot by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(open) {
|
||||
if (open) {
|
||||
val existing = context.getConfigString(prefKey)
|
||||
if (existing.startsWith("shell:")) {
|
||||
val parts = existing.removePrefix("shell:").split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
runAsRoot = parts[0] == "true"
|
||||
command = parts[1]
|
||||
}
|
||||
} else {
|
||||
command = ""
|
||||
runAsRoot = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EdgeXBottomSheet(
|
||||
open = open,
|
||||
title = stringResource(R.string.action_shell_command),
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = command,
|
||||
onValueChange = { command = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
placeholder = { Text(stringResource(R.string.hint_shell_command), color = colors.onSurfaceDim) },
|
||||
singleLine = false,
|
||||
maxLines = 5,
|
||||
shape = RoundedCornerShape(EdgeXRadius.sm),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colors.accent,
|
||||
unfocusedBorderColor = colors.outline,
|
||||
cursorColor = colors.accent,
|
||||
),
|
||||
)
|
||||
EdgeXListGroup {
|
||||
EdgeXSwitchRow(
|
||||
title = stringResource(R.string.label_run_as_root),
|
||||
subtitle = stringResource(R.string.desc_run_as_root),
|
||||
checked = runAsRoot,
|
||||
onCheckedChange = { runAsRoot = it },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val trimmed = command.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
Toast.makeText(context, context.getString(R.string.toast_shell_command_empty), Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
if (runAsRoot && containsSuCommand(trimmed)) {
|
||||
Toast.makeText(context, context.getString(R.string.toast_shell_su_warning), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
context.putConfigsSync(
|
||||
prefKey to "shell:$runAsRoot:$trimmed",
|
||||
"${prefKey}_label" to trimmed,
|
||||
"${prefKey}_title" to trimmed,
|
||||
)
|
||||
Toast.makeText(context, context.getString(R.string.toast_shell_command_saved), Toast.LENGTH_SHORT).show()
|
||||
onSave()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(EdgeXRadius.md),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.btn_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsSuCommand(command: String): Boolean {
|
||||
val lines = command.split("\n", "\r\n", "\r")
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed == "su" || trimmed.startsWith("su ")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.fan.edgex.ui.compose.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import com.fan.edgex.R
|
||||
import com.fan.edgex.ui.compose.theme.EdgeXRadius
|
||||
import com.fan.edgex.ui.compose.theme.LocalEdgeXColors
|
||||
import com.fan.edgex.utils.UpdateChecker
|
||||
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
release: UpdateChecker.ReleaseInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val colors = LocalEdgeXColors.current
|
||||
val rawBody = remember(release.body, configuration.locales) {
|
||||
UpdateChecker.extractLocalizedBody(release.body, context)
|
||||
}
|
||||
val body = rawBody.ifBlank { stringResource(R.string.update_no_changelog) }
|
||||
val formattedBody = remember(body) { parseReleaseMarkdown(body) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.update_new_version_title, release.versionName),
|
||||
color = colors.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 380.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = formattedBody,
|
||||
color = colors.onSurface2,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, release.htmlUrl.toUri()))
|
||||
}.onFailure {
|
||||
Toast.makeText(context, R.string.toast_cannot_open_browser, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.update_view_release), color = colors.accent)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onSkip) {
|
||||
Text(stringResource(R.string.update_skip_version), color = colors.onSurfaceDim)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(EdgeXRadius.md),
|
||||
containerColor = colors.surface1,
|
||||
titleContentColor = colors.onSurface,
|
||||
textContentColor = colors.onSurface2,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReleaseMarkdown(markdown: String): AnnotatedString = buildAnnotatedString {
|
||||
val lines = markdown.replace("\r\n", "\n").lines()
|
||||
lines.forEachIndexed { index, rawLine ->
|
||||
val line = rawLine.trimEnd()
|
||||
val heading = UpdateChecker.parseMarkdownHeading(line)
|
||||
val bullet = Regex("^\\s*[-*]\\s+(.*)").matchEntire(line)
|
||||
when {
|
||||
heading != null -> {
|
||||
val size = when (heading.first) {
|
||||
1 -> 20.sp
|
||||
2 -> 17.sp
|
||||
else -> 15.sp
|
||||
}
|
||||
pushStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = size))
|
||||
appendInlineMarkdown(heading.second)
|
||||
pop()
|
||||
}
|
||||
bullet != null -> {
|
||||
append("• ")
|
||||
appendInlineMarkdown(bullet.groupValues[1])
|
||||
}
|
||||
else -> appendInlineMarkdown(line)
|
||||
}
|
||||
if (index < lines.lastIndex) append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
||||
val regex = Regex("""\*\*(.+?)\*\*|`(.+?)`|\[(.+?)]\((.+?)\)""")
|
||||
var cursor = 0
|
||||
regex.findAll(text).forEach { match ->
|
||||
if (match.range.first > cursor) append(text.substring(cursor, match.range.first))
|
||||
when {
|
||||
match.groupValues[1].isNotEmpty() -> {
|
||||
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
append(match.groupValues[1])
|
||||
pop()
|
||||
}
|
||||
match.groupValues[2].isNotEmpty() -> {
|
||||
pushStyle(SpanStyle(fontFamily = FontFamily.Monospace))
|
||||
append(match.groupValues[2])
|
||||
pop()
|
||||
}
|
||||
else -> append(match.groupValues[3])
|
||||
}
|
||||
cursor = match.range.last + 1
|
||||
}
|
||||
if (cursor < text.length) append(text.substring(cursor))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user