From a9085ad83f1a180608b73a6b8e6cbb53105974ef Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 16 Aug 2026 13:44:35 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/FUNDING.yml | 2 + .github/workflows/release.yml | 80 + .gitignore | 44 + .vscode/settings.json | 3 + LICENSE | 674 ++++++++ README.md | 2 + README_CN.md | 98 ++ app/.gitignore | 1 + app/build.gradle.kts | 119 ++ app/proguard-rules.pro | 25 + .../com/fan/edgex/ExampleInstrumentedTest.kt | 24 + .../edgex/ui/compose/EdgeXComposeSmokeTest.kt | 254 +++ app/src/main/AndroidManifest.xml | 182 +++ .../aidl/com/fan/edgex/IKeystoreVerifier.aidl | 5 + .../aidl/com/fan/edgex/IShellCallback.aidl | 5 + .../aidl/com/fan/edgex/IShellExecutor.aidl | 9 + app/src/main/assets/xposed_init | 1 + app/src/main/java/com/fan/edgex/App.kt | 14 + .../com/fan/edgex/action/AppActionExecutor.kt | 183 +++ .../java/com/fan/edgex/config/AppConfig.kt | 145 ++ .../com/fan/edgex/config/ConditionStore.kt | 132 ++ .../edgex/config/ConfigSnapshotReceiver.kt | 21 + .../java/com/fan/edgex/config/ConfigStore.kt | 177 ++ .../com/fan/edgex/config/FreezerBootstrap.kt | 60 + .../config/GestureZoneGeometryCalculator.kt | 94 ++ .../edgex/config/HookClipboardHistoryStore.kt | 78 + .../fan/edgex/config/HookConfigSnapshot.kt | 113 ++ .../fan/edgex/config/ModuleActivationState.kt | 43 + .../com/fan/edgex/config/MultiActionStore.kt | 99 ++ .../fan/edgex/config/ShellExecutorService.kt | 109 ++ .../fan/edgex/config/ThemeColorResolver.kt | 54 + .../java/com/fan/edgex/hook/ClipboardHook.kt | 71 + .../com/fan/edgex/hook/ClipboardOverlay.kt | 517 ++++++ .../com/fan/edgex/hook/ConditionEvaluator.kt | 129 ++ .../com/fan/edgex/hook/CopyPanelOverlay.kt | 432 +++++ .../fan/edgex/hook/DebugOverlayController.kt | 314 ++++ .../com/fan/edgex/hook/EdgeGestureDetector.kt | 581 +++++++ .../com/fan/edgex/hook/FlashlightManager.kt | 155 ++ .../com/fan/edgex/hook/GameModeManager.kt | 106 ++ .../fan/edgex/hook/GestureActionDispatcher.kt | 1047 ++++++++++++ .../java/com/fan/edgex/hook/GestureManager.kt | 438 +++++ .../com/fan/edgex/hook/GlobalActionHelper.kt | 138 ++ .../fan/edgex/hook/HookConfigRepository.kt | 127 ++ .../java/com/fan/edgex/hook/KeyManager.kt | 541 ++++++ .../com/fan/edgex/hook/LocalOverlayRuntime.kt | 106 ++ .../fan/edgex/hook/LockscreenActionPolicy.kt | 14 + .../main/java/com/fan/edgex/hook/MainHook.kt | 391 +++++ .../main/java/com/fan/edgex/hook/ModuleRes.kt | 29 + .../com/fan/edgex/hook/NativeTouchHandoff.kt | 87 + .../edgex/hook/PartialScreenshotOverlay.kt | 968 +++++++++++ .../fan/edgex/hook/PremiumInstallMetadata.kt | 65 + .../com/fan/edgex/hook/PremiumPluginLoader.kt | 263 +++ .../java/com/fan/edgex/hook/PremiumRuntime.kt | 43 + .../edgex/hook/PremiumSignatureVerifier.kt | 76 + .../java/com/fan/edgex/hook/ScrollHook.kt | 110 ++ .../fan/edgex/hook/UniversalCopyManager.kt | 414 +++++ .../java/com/fan/edgex/hook/XposedInit.kt | 13 + .../com/fan/edgex/license/DeviceKeystore.kt | 64 + .../edgex/license/KeystoreVerifierService.kt | 21 + .../com/fan/edgex/license/PremiumActivator.kt | 366 +++++ .../com/fan/edgex/overlay/ArcLayoutView.kt | 221 +++ .../com/fan/edgex/overlay/DrawerManager.kt | 32 + .../com/fan/edgex/overlay/DrawerWindow.kt | 647 ++++++++ .../com/fan/edgex/overlay/EdgeLightingView.kt | 334 ++++ .../com/fan/edgex/overlay/OverlayTheme.kt | 47 + .../fan/edgex/overlay/PanelOverlayManager.kt | 505 ++++++ .../java/com/fan/edgex/overlay/PieManager.kt | 37 + .../java/com/fan/edgex/overlay/PieView.kt | 275 ++++ .../java/com/fan/edgex/overlay/PieWindow.kt | 75 + .../com/fan/edgex/premium/PremiumInstall.kt | 10 + .../edgex/service/NotificationEdgeService.kt | 294 ++++ .../service/NotificationLifecycleManager.kt | 144 ++ .../fan/edgex/ui/ActionSelectionActivity.kt | 339 ++++ .../com/fan/edgex/ui/AppIconPickerActivity.kt | 138 ++ .../com/fan/edgex/ui/AppSelectionActivity.kt | 152 ++ .../java/com/fan/edgex/ui/ColorPickerView.kt | 275 ++++ .../fan/edgex/ui/ConditionActionActivity.kt | 98 ++ .../fan/edgex/ui/ConditionPickerActivity.kt | 89 + .../edgex/ui/EdgeLightingAppFilterActivity.kt | 168 ++ .../edgex/ui/EdgeLightingSettingsActivity.kt | 449 +++++ .../com/fan/edgex/ui/FastScrollActivity.kt | 77 + .../java/com/fan/edgex/ui/FreezerActivity.kt | 306 ++++ .../java/com/fan/edgex/ui/GesturesActivity.kt | 169 ++ .../java/com/fan/edgex/ui/KeysActivity.kt | 183 +++ .../java/com/fan/edgex/ui/MainActivity.kt | 28 + .../fan/edgex/ui/MultiActionEditActivity.kt | 395 +++++ .../com/fan/edgex/ui/MultiActionIconUtils.kt | 76 + .../fan/edgex/ui/MultiActionsListActivity.kt | 239 +++ .../com/fan/edgex/ui/MusicControlActivity.kt | 80 + .../com/fan/edgex/ui/PanelConfigActivity.kt | 352 ++++ .../com/fan/edgex/ui/PieSettingsActivity.kt | 161 ++ .../java/com/fan/edgex/ui/PremiumActivity.kt | 285 ++++ .../com/fan/edgex/ui/ShellCommandActivity.kt | 94 ++ .../fan/edgex/ui/ShortcutSelectionActivity.kt | 304 ++++ .../com/fan/edgex/ui/SubGestureActivity.kt | 78 + .../java/com/fan/edgex/ui/ThemeActivity.kt | 115 ++ .../java/com/fan/edgex/ui/ThemeManager.kt | 163 ++ .../java/com/fan/edgex/ui/compose/EdgeXApp.kt | 348 ++++ .../components/ActionSelectionSheet.kt | 240 +++ .../ui/compose/components/AppPickerSheet.kt | 169 ++ .../components/AppShortcutPickerSheet.kt | 271 +++ .../components/ConditionPickerSheet.kt | 64 + .../ui/compose/components/EdgeXComponents.kt | 440 +++++ .../edgex/ui/compose/components/EdgeXIcons.kt | 99 ++ .../ui/compose/components/FastScrollSheet.kt | 26 + .../components/ForegroundAppConditionSheet.kt | 215 +++ .../compose/components/MusicControlSheet.kt | 29 + .../components/SecondaryActionSheet.kt | 160 ++ .../compose/components/ShellCommandSheet.kt | 127 ++ .../ui/compose/components/UpdateDialog.kt | 143 ++ .../ui/compose/screens/ColorPickerDialog.kt | 140 ++ .../ui/compose/screens/ConditionSheet.kt | 206 +++ .../ui/compose/screens/EdgeLightingScreen.kt | 488 ++++++ .../edgex/ui/compose/screens/FreezerScreen.kt | 501 ++++++ .../ui/compose/screens/GesturesScreen.kt | 1077 ++++++++++++ .../edgex/ui/compose/screens/HomeScreen.kt | 488 ++++++ .../ui/compose/screens/KeysPieScreens.kt | 909 ++++++++++ .../ui/compose/screens/MultiThemeScreens.kt | 1455 +++++++++++++++++ .../edgex/ui/compose/screens/PanelScreens.kt | 662 ++++++++ .../ui/compose/screens/PremiumAboutScreens.kt | 703 ++++++++ .../ui/compose/screens/SubGestureSheet.kt | 253 +++ .../ui/compose/screens/ThemeColorSettings.kt | 91 ++ .../fan/edgex/ui/compose/theme/EdgeXTheme.kt | 435 +++++ .../com/fan/edgex/utils/ActivationDialog.kt | 104 ++ .../java/com/fan/edgex/utils/DonateDialog.kt | 322 ++++ .../java/com/fan/edgex/utils/ProcessUtils.kt | 16 + .../java/com/fan/edgex/utils/UpdateChecker.kt | 145 ++ app/src/main/java/com/fan/edgex/utils/Xlog.kt | 56 + .../main/res/drawable-nodpi/ic_alipay_qr.jpeg | Bin 0 -> 114803 bytes .../main/res/drawable-nodpi/ic_wechat_qr.png | Bin 0 -> 156188 bytes app/src/main/res/drawable/bg_card.xml | 8 + .../res/drawable/bg_color_swatch_rounded.xml | 8 + .../bg_edge_lighting_notification_dot.xml | 5 + .../bg_edge_lighting_notification_preview.xml | 5 + .../bg_edge_lighting_phone_preview.xml | 8 + .../bg_edge_lighting_phone_speaker.xml | 5 + app/src/main/res/drawable/bg_edit_text.xml | 9 + app/src/main/res/drawable/bg_preview_dark.xml | 5 + app/src/main/res/drawable/bg_theme_swatch.xml | 8 + .../res/drawable/circle_background_red.xml | 5 + .../res/drawable/circle_background_teal.xml | 5 + .../circle_background_white_alpha.xml | 5 + app/src/main/res/drawable/ic_about.xml | 10 + app/src/main/res/drawable/ic_action_dot.xml | 10 + app/src/main/res/drawable/ic_add.xml | 9 + app/src/main/res/drawable/ic_alipay.xml | 11 + app/src/main/res/drawable/ic_app_shortcut.xml | 11 + app/src/main/res/drawable/ic_apps.xml | 10 + app/src/main/res/drawable/ic_arc_drawer.xml | 11 + app/src/main/res/drawable/ic_arrow_back.xml | 10 + .../main/res/drawable/ic_arrow_drop_down.xml | 10 + .../main/res/drawable/ic_arrow_drop_up.xml | 10 + app/src/main/res/drawable/ic_bluetooth.xml | 10 + .../main/res/drawable/ic_brightness_down.xml | 16 + .../main/res/drawable/ic_brightness_up.xml | 14 + app/src/main/res/drawable/ic_camera.xml | 10 + .../main/res/drawable/ic_chevron_right.xml | 9 + app/src/main/res/drawable/ic_clear_recent.xml | 11 + app/src/main/res/drawable/ic_condition.xml | 19 + app/src/main/res/drawable/ic_content_copy.xml | 10 + app/src/main/res/drawable/ic_dark_mode.xml | 11 + app/src/main/res/drawable/ic_delete.xml | 10 + .../main/res/drawable/ic_developer_mode.xml | 11 + app/src/main/res/drawable/ic_donate.xml | 10 + app/src/main/res/drawable/ic_duplicate.xml | 9 + .../main/res/drawable/ic_edge_bottom_full.xml | 13 + .../main/res/drawable/ic_edge_bottom_left.xml | 13 + .../main/res/drawable/ic_edge_bottom_mid.xml | 13 + .../res/drawable/ic_edge_bottom_right.xml | 13 + .../main/res/drawable/ic_edge_left_bottom.xml | 15 + .../main/res/drawable/ic_edge_left_full.xml | 13 + .../main/res/drawable/ic_edge_left_mid.xml | 15 + .../main/res/drawable/ic_edge_left_top.xml | 15 + .../main/res/drawable/ic_edge_lighting.xml | 33 + app/src/main/res/drawable/ic_edge_panel.xml | 10 + .../res/drawable/ic_edge_right_bottom.xml | 15 + .../main/res/drawable/ic_edge_right_full.xml | 13 + .../main/res/drawable/ic_edge_right_mid.xml | 15 + .../main/res/drawable/ic_edge_right_top.xml | 15 + .../main/res/drawable/ic_edge_top_full.xml | 13 + .../main/res/drawable/ic_edge_top_left.xml | 13 + app/src/main/res/drawable/ic_edge_top_mid.xml | 13 + .../main/res/drawable/ic_edge_top_right.xml | 13 + app/src/main/res/drawable/ic_edit.xml | 9 + app/src/main/res/drawable/ic_eth.xml | 23 + app/src/main/res/drawable/ic_execute.xml | 9 + app/src/main/res/drawable/ic_expand_more.xml | 10 + app/src/main/res/drawable/ic_fast_scroll.xml | 10 + app/src/main/res/drawable/ic_flashlight.xml | 30 + app/src/main/res/drawable/ic_fluid_effect.xml | 28 + app/src/main/res/drawable/ic_freezer.xml | 10 + app/src/main/res/drawable/ic_game_mode.xml | 10 + app/src/main/res/drawable/ic_gesture.xml | 10 + app/src/main/res/drawable/ic_google_play.xml | 9 + app/src/main/res/drawable/ic_home.xml | 11 + app/src/main/res/drawable/ic_if.xml | 11 + app/src/main/res/drawable/ic_image.xml | 9 + app/src/main/res/drawable/ic_info.xml | 19 + app/src/main/res/drawable/ic_keyboard.xml | 15 + app/src/main/res/drawable/ic_kill_app.xml | 10 + app/src/main/res/drawable/ic_ko_fi.xml | 11 + app/src/main/res/drawable/ic_launch_app.xml | 11 + .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 29 + app/src/main/res/drawable/ic_link.xml | 10 + app/src/main/res/drawable/ic_location.xml | 10 + app/src/main/res/drawable/ic_mobile_data.xml | 11 + app/src/main/res/drawable/ic_more_vert.xml | 9 + app/src/main/res/drawable/ic_multi_action.xml | 11 + app/src/main/res/drawable/ic_music.xml | 11 + app/src/main/res/drawable/ic_music_next.xml | 14 + .../main/res/drawable/ic_music_play_pause.xml | 17 + .../main/res/drawable/ic_music_previous.xml | 14 + app/src/main/res/drawable/ic_music_stop.xml | 11 + app/src/main/res/drawable/ic_next_app.xml | 23 + app/src/main/res/drawable/ic_nfc.xml | 11 + .../main/res/drawable/ic_notifications.xml | 11 + .../res/drawable/ic_partial_screenshot.xml | 10 + app/src/main/res/drawable/ic_paste.xml | 10 + app/src/main/res/drawable/ic_person.xml | 10 + app/src/main/res/drawable/ic_pie_menu.xml | 19 + app/src/main/res/drawable/ic_power.xml | 15 + app/src/main/res/drawable/ic_prev_app.xml | 23 + app/src/main/res/drawable/ic_recents.xml | 12 + app/src/main/res/drawable/ic_refreeze.xml | 11 + app/src/main/res/drawable/ic_restart_alt.xml | 11 + app/src/main/res/drawable/ic_save.xml | 9 + .../main/res/drawable/ic_screen_landscape.xml | 10 + .../main/res/drawable/ic_screen_portrait.xml | 10 + .../main/res/drawable/ic_screen_rotation.xml | 10 + .../main/res/drawable/ic_scroll_to_bottom.xml | 10 + .../main/res/drawable/ic_scroll_to_top.xml | 10 + app/src/main/res/drawable/ic_search.xml | 10 + app/src/main/res/drawable/ic_settings.xml | 10 + app/src/main/res/drawable/ic_side_bar.xml | 20 + .../main/res/drawable/ic_side_bar_left.xml | 20 + .../main/res/drawable/ic_side_bar_right.xml | 20 + app/src/main/res/drawable/ic_sub_gesture.xml | 27 + .../main/res/drawable/ic_supporter_extra.xml | 11 + app/src/main/res/drawable/ic_terminal.xml | 17 + app/src/main/res/drawable/ic_theme.xml | 10 + app/src/main/res/drawable/ic_vibration.xml | 10 + app/src/main/res/drawable/ic_volume_down.xml | 11 + app/src/main/res/drawable/ic_volume_up.xml | 11 + app/src/main/res/drawable/ic_wechat_pay.xml | 11 + app/src/main/res/drawable/ic_wifi.xml | 10 + .../res/layout/activity_action_selection.xml | 94 ++ .../res/layout/activity_app_icon_picker.xml | 120 ++ .../res/layout/activity_condition_action.xml | 117 ++ .../activity_edge_lighting_app_filter.xml | 90 + .../activity_edge_lighting_settings.xml | 592 +++++++ app/src/main/res/layout/activity_freezer.xml | 80 + app/src/main/res/layout/activity_gestures.xml | 119 ++ app/src/main/res/layout/activity_keys.xml | 62 + app/src/main/res/layout/activity_main.xml | 935 +++++++++++ .../res/layout/activity_multi_action_edit.xml | 118 ++ .../layout/activity_multi_actions_list.xml | 78 + .../main/res/layout/activity_pie_settings.xml | 56 + app/src/main/res/layout/activity_premium.xml | 241 +++ .../res/layout/activity_shell_command.xml | 119 ++ .../layout/activity_shortcut_selection.xml | 79 + .../main/res/layout/activity_sub_gesture.xml | 153 ++ app/src/main/res/layout/activity_theme.xml | 196 +++ .../main/res/layout/dialog_app_options.xml | 59 + .../main/res/layout/dialog_color_picker.xml | 132 ++ .../main/res/layout/item_action_selection.xml | 33 + app/src/main/res/layout/item_app.xml | 24 + app/src/main/res/layout/item_app_icon.xml | 28 + app/src/main/res/layout/item_app_list.xml | 64 + .../main/res/layout/item_gesture_action.xml | 44 + .../res/layout/item_gesture_zone_bottom.xml | 101 ++ .../res/layout/item_gesture_zone_left.xml | 95 ++ .../res/layout/item_gesture_zone_right.xml | 95 ++ .../main/res/layout/item_gesture_zone_top.xml | 101 ++ app/src/main/res/layout/item_key.xml | 87 + app/src/main/res/layout/item_multi_action.xml | 55 + .../res/layout/item_multi_action_step.xml | 44 + app/src/main/res/layout/item_theme_preset.xml | 34 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2333 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2333 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1562 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1562 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3010 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3010 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 4522 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 4522 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5801 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 5801 bytes app/src/main/res/values-night/colors.xml | 18 + app/src/main/res/values-night/themes.xml | 18 + app/src/main/res/values-zh-rCN/strings.xml | 615 +++++++ app/src/main/res/values/colors.xml | 30 + app/src/main/res/values/strings.xml | 614 +++++++ app/src/main/res/values/themes.xml | 18 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../java/com/fan/edgex/ExampleUnitTest.kt | 17 + .../fan/edgex/config/AppConfigContractTest.kt | 34 + .../fan/edgex/config/ConditionStoreTest.kt | 45 + .../GestureZoneGeometryCalculatorTest.kt | 101 ++ .../edgex/config/ThemeColorResolverTest.kt | 50 + .../fan/edgex/hook/ConditionEvaluatorTest.kt | 34 + .../edgex/hook/LockscreenActionPolicyTest.kt | 28 + .../edgex/hook/PremiumInstallMetadataTest.kt | 45 + .../hook/PremiumSignatureVerifierTest.kt | 96 ++ .../NotificationLifecycleManagerTest.kt | 276 ++++ .../com/fan/edgex/utils/UpdateCheckerTest.kt | 18 + build.gradle.kts | 7 + buildSrc/build.gradle.kts | 7 + buildSrc/src/main/kotlin/Configs.kt | 15 + gradle.properties | 25 + gradle/libs.versions.toml | 37 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 +++ gradlew.bat | 94 ++ premium-api/.gitignore | 1 + premium-api/build.gradle.kts | 22 + .../com/fan/edgex/premium/IPremiumPlugin.kt | 37 + settings.gradle.kts | 33 + 322 files changed, 36601 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_CN.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/fan/edgex/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/fan/edgex/ui/compose/EdgeXComposeSmokeTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/aidl/com/fan/edgex/IKeystoreVerifier.aidl create mode 100644 app/src/main/aidl/com/fan/edgex/IShellCallback.aidl create mode 100644 app/src/main/aidl/com/fan/edgex/IShellExecutor.aidl create mode 100644 app/src/main/assets/xposed_init create mode 100644 app/src/main/java/com/fan/edgex/App.kt create mode 100644 app/src/main/java/com/fan/edgex/action/AppActionExecutor.kt create mode 100644 app/src/main/java/com/fan/edgex/config/AppConfig.kt create mode 100644 app/src/main/java/com/fan/edgex/config/ConditionStore.kt create mode 100644 app/src/main/java/com/fan/edgex/config/ConfigSnapshotReceiver.kt create mode 100644 app/src/main/java/com/fan/edgex/config/ConfigStore.kt create mode 100644 app/src/main/java/com/fan/edgex/config/FreezerBootstrap.kt create mode 100644 app/src/main/java/com/fan/edgex/config/GestureZoneGeometryCalculator.kt create mode 100644 app/src/main/java/com/fan/edgex/config/HookClipboardHistoryStore.kt create mode 100644 app/src/main/java/com/fan/edgex/config/HookConfigSnapshot.kt create mode 100644 app/src/main/java/com/fan/edgex/config/ModuleActivationState.kt create mode 100644 app/src/main/java/com/fan/edgex/config/MultiActionStore.kt create mode 100644 app/src/main/java/com/fan/edgex/config/ShellExecutorService.kt create mode 100644 app/src/main/java/com/fan/edgex/config/ThemeColorResolver.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/ClipboardHook.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/ClipboardOverlay.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/ConditionEvaluator.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/CopyPanelOverlay.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/DebugOverlayController.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/EdgeGestureDetector.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/FlashlightManager.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/GameModeManager.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/GestureActionDispatcher.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/GestureManager.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/GlobalActionHelper.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/HookConfigRepository.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/KeyManager.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/LocalOverlayRuntime.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/LockscreenActionPolicy.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/MainHook.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/ModuleRes.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/NativeTouchHandoff.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/PartialScreenshotOverlay.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/PremiumInstallMetadata.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/PremiumPluginLoader.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/PremiumRuntime.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/PremiumSignatureVerifier.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/ScrollHook.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/UniversalCopyManager.kt create mode 100644 app/src/main/java/com/fan/edgex/hook/XposedInit.kt create mode 100644 app/src/main/java/com/fan/edgex/license/DeviceKeystore.kt create mode 100644 app/src/main/java/com/fan/edgex/license/KeystoreVerifierService.kt create mode 100644 app/src/main/java/com/fan/edgex/license/PremiumActivator.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/ArcLayoutView.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/DrawerManager.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/DrawerWindow.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/EdgeLightingView.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/OverlayTheme.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/PanelOverlayManager.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/PieManager.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/PieView.kt create mode 100644 app/src/main/java/com/fan/edgex/overlay/PieWindow.kt create mode 100644 app/src/main/java/com/fan/edgex/premium/PremiumInstall.kt create mode 100644 app/src/main/java/com/fan/edgex/service/NotificationEdgeService.kt create mode 100644 app/src/main/java/com/fan/edgex/service/NotificationLifecycleManager.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ActionSelectionActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/AppIconPickerActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/AppSelectionActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ColorPickerView.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ConditionActionActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ConditionPickerActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/EdgeLightingAppFilterActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/EdgeLightingSettingsActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/FastScrollActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/FreezerActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/GesturesActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/KeysActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/MainActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/MultiActionEditActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/MultiActionIconUtils.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/MultiActionsListActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/MusicControlActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/PanelConfigActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/PieSettingsActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/PremiumActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ShellCommandActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ShortcutSelectionActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/SubGestureActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ThemeActivity.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/ThemeManager.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/EdgeXApp.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/ActionSelectionSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/AppPickerSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/AppShortcutPickerSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/ConditionPickerSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/EdgeXComponents.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/EdgeXIcons.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/FastScrollSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/ForegroundAppConditionSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/MusicControlSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/SecondaryActionSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/ShellCommandSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/components/UpdateDialog.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/ColorPickerDialog.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/ConditionSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/EdgeLightingScreen.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/FreezerScreen.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/GesturesScreen.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/HomeScreen.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/KeysPieScreens.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/MultiThemeScreens.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/PanelScreens.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/PremiumAboutScreens.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/SubGestureSheet.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/screens/ThemeColorSettings.kt create mode 100644 app/src/main/java/com/fan/edgex/ui/compose/theme/EdgeXTheme.kt create mode 100644 app/src/main/java/com/fan/edgex/utils/ActivationDialog.kt create mode 100644 app/src/main/java/com/fan/edgex/utils/DonateDialog.kt create mode 100644 app/src/main/java/com/fan/edgex/utils/ProcessUtils.kt create mode 100644 app/src/main/java/com/fan/edgex/utils/UpdateChecker.kt create mode 100644 app/src/main/java/com/fan/edgex/utils/Xlog.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_alipay_qr.jpeg create mode 100644 app/src/main/res/drawable-nodpi/ic_wechat_qr.png create mode 100644 app/src/main/res/drawable/bg_card.xml create mode 100644 app/src/main/res/drawable/bg_color_swatch_rounded.xml create mode 100644 app/src/main/res/drawable/bg_edge_lighting_notification_dot.xml create mode 100644 app/src/main/res/drawable/bg_edge_lighting_notification_preview.xml create mode 100644 app/src/main/res/drawable/bg_edge_lighting_phone_preview.xml create mode 100644 app/src/main/res/drawable/bg_edge_lighting_phone_speaker.xml create mode 100644 app/src/main/res/drawable/bg_edit_text.xml create mode 100644 app/src/main/res/drawable/bg_preview_dark.xml create mode 100644 app/src/main/res/drawable/bg_theme_swatch.xml create mode 100644 app/src/main/res/drawable/circle_background_red.xml create mode 100644 app/src/main/res/drawable/circle_background_teal.xml create mode 100644 app/src/main/res/drawable/circle_background_white_alpha.xml create mode 100644 app/src/main/res/drawable/ic_about.xml create mode 100644 app/src/main/res/drawable/ic_action_dot.xml create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_alipay.xml create mode 100644 app/src/main/res/drawable/ic_app_shortcut.xml create mode 100644 app/src/main/res/drawable/ic_apps.xml create mode 100644 app/src/main/res/drawable/ic_arc_drawer.xml create mode 100644 app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_arrow_drop_down.xml create mode 100644 app/src/main/res/drawable/ic_arrow_drop_up.xml create mode 100644 app/src/main/res/drawable/ic_bluetooth.xml create mode 100644 app/src/main/res/drawable/ic_brightness_down.xml create mode 100644 app/src/main/res/drawable/ic_brightness_up.xml create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_clear_recent.xml create mode 100644 app/src/main/res/drawable/ic_condition.xml create mode 100644 app/src/main/res/drawable/ic_content_copy.xml create mode 100644 app/src/main/res/drawable/ic_dark_mode.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_developer_mode.xml create mode 100644 app/src/main/res/drawable/ic_donate.xml create mode 100644 app/src/main/res/drawable/ic_duplicate.xml create mode 100644 app/src/main/res/drawable/ic_edge_bottom_full.xml create mode 100644 app/src/main/res/drawable/ic_edge_bottom_left.xml create mode 100644 app/src/main/res/drawable/ic_edge_bottom_mid.xml create mode 100644 app/src/main/res/drawable/ic_edge_bottom_right.xml create mode 100644 app/src/main/res/drawable/ic_edge_left_bottom.xml create mode 100644 app/src/main/res/drawable/ic_edge_left_full.xml create mode 100644 app/src/main/res/drawable/ic_edge_left_mid.xml create mode 100644 app/src/main/res/drawable/ic_edge_left_top.xml create mode 100644 app/src/main/res/drawable/ic_edge_lighting.xml create mode 100644 app/src/main/res/drawable/ic_edge_panel.xml create mode 100644 app/src/main/res/drawable/ic_edge_right_bottom.xml create mode 100644 app/src/main/res/drawable/ic_edge_right_full.xml create mode 100644 app/src/main/res/drawable/ic_edge_right_mid.xml create mode 100644 app/src/main/res/drawable/ic_edge_right_top.xml create mode 100644 app/src/main/res/drawable/ic_edge_top_full.xml create mode 100644 app/src/main/res/drawable/ic_edge_top_left.xml create mode 100644 app/src/main/res/drawable/ic_edge_top_mid.xml create mode 100644 app/src/main/res/drawable/ic_edge_top_right.xml create mode 100644 app/src/main/res/drawable/ic_edit.xml create mode 100644 app/src/main/res/drawable/ic_eth.xml create mode 100644 app/src/main/res/drawable/ic_execute.xml create mode 100644 app/src/main/res/drawable/ic_expand_more.xml create mode 100644 app/src/main/res/drawable/ic_fast_scroll.xml create mode 100644 app/src/main/res/drawable/ic_flashlight.xml create mode 100644 app/src/main/res/drawable/ic_fluid_effect.xml create mode 100644 app/src/main/res/drawable/ic_freezer.xml create mode 100644 app/src/main/res/drawable/ic_game_mode.xml create mode 100644 app/src/main/res/drawable/ic_gesture.xml create mode 100644 app/src/main/res/drawable/ic_google_play.xml create mode 100644 app/src/main/res/drawable/ic_home.xml create mode 100644 app/src/main/res/drawable/ic_if.xml create mode 100644 app/src/main/res/drawable/ic_image.xml create mode 100644 app/src/main/res/drawable/ic_info.xml create mode 100644 app/src/main/res/drawable/ic_keyboard.xml create mode 100644 app/src/main/res/drawable/ic_kill_app.xml create mode 100644 app/src/main/res/drawable/ic_ko_fi.xml create mode 100644 app/src/main/res/drawable/ic_launch_app.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_link.xml create mode 100644 app/src/main/res/drawable/ic_location.xml create mode 100644 app/src/main/res/drawable/ic_mobile_data.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_multi_action.xml create mode 100644 app/src/main/res/drawable/ic_music.xml create mode 100644 app/src/main/res/drawable/ic_music_next.xml create mode 100644 app/src/main/res/drawable/ic_music_play_pause.xml create mode 100644 app/src/main/res/drawable/ic_music_previous.xml create mode 100644 app/src/main/res/drawable/ic_music_stop.xml create mode 100644 app/src/main/res/drawable/ic_next_app.xml create mode 100644 app/src/main/res/drawable/ic_nfc.xml create mode 100644 app/src/main/res/drawable/ic_notifications.xml create mode 100644 app/src/main/res/drawable/ic_partial_screenshot.xml create mode 100644 app/src/main/res/drawable/ic_paste.xml create mode 100644 app/src/main/res/drawable/ic_person.xml create mode 100644 app/src/main/res/drawable/ic_pie_menu.xml create mode 100644 app/src/main/res/drawable/ic_power.xml create mode 100644 app/src/main/res/drawable/ic_prev_app.xml create mode 100644 app/src/main/res/drawable/ic_recents.xml create mode 100644 app/src/main/res/drawable/ic_refreeze.xml create mode 100644 app/src/main/res/drawable/ic_restart_alt.xml create mode 100644 app/src/main/res/drawable/ic_save.xml create mode 100644 app/src/main/res/drawable/ic_screen_landscape.xml create mode 100644 app/src/main/res/drawable/ic_screen_portrait.xml create mode 100644 app/src/main/res/drawable/ic_screen_rotation.xml create mode 100644 app/src/main/res/drawable/ic_scroll_to_bottom.xml create mode 100644 app/src/main/res/drawable/ic_scroll_to_top.xml create mode 100644 app/src/main/res/drawable/ic_search.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/drawable/ic_side_bar.xml create mode 100644 app/src/main/res/drawable/ic_side_bar_left.xml create mode 100644 app/src/main/res/drawable/ic_side_bar_right.xml create mode 100644 app/src/main/res/drawable/ic_sub_gesture.xml create mode 100644 app/src/main/res/drawable/ic_supporter_extra.xml create mode 100644 app/src/main/res/drawable/ic_terminal.xml create mode 100644 app/src/main/res/drawable/ic_theme.xml create mode 100644 app/src/main/res/drawable/ic_vibration.xml create mode 100644 app/src/main/res/drawable/ic_volume_down.xml create mode 100644 app/src/main/res/drawable/ic_volume_up.xml create mode 100644 app/src/main/res/drawable/ic_wechat_pay.xml create mode 100644 app/src/main/res/drawable/ic_wifi.xml create mode 100644 app/src/main/res/layout/activity_action_selection.xml create mode 100644 app/src/main/res/layout/activity_app_icon_picker.xml create mode 100644 app/src/main/res/layout/activity_condition_action.xml create mode 100644 app/src/main/res/layout/activity_edge_lighting_app_filter.xml create mode 100644 app/src/main/res/layout/activity_edge_lighting_settings.xml create mode 100644 app/src/main/res/layout/activity_freezer.xml create mode 100644 app/src/main/res/layout/activity_gestures.xml create mode 100644 app/src/main/res/layout/activity_keys.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_multi_action_edit.xml create mode 100644 app/src/main/res/layout/activity_multi_actions_list.xml create mode 100644 app/src/main/res/layout/activity_pie_settings.xml create mode 100644 app/src/main/res/layout/activity_premium.xml create mode 100644 app/src/main/res/layout/activity_shell_command.xml create mode 100644 app/src/main/res/layout/activity_shortcut_selection.xml create mode 100644 app/src/main/res/layout/activity_sub_gesture.xml create mode 100644 app/src/main/res/layout/activity_theme.xml create mode 100644 app/src/main/res/layout/dialog_app_options.xml create mode 100644 app/src/main/res/layout/dialog_color_picker.xml create mode 100644 app/src/main/res/layout/item_action_selection.xml create mode 100644 app/src/main/res/layout/item_app.xml create mode 100644 app/src/main/res/layout/item_app_icon.xml create mode 100644 app/src/main/res/layout/item_app_list.xml create mode 100644 app/src/main/res/layout/item_gesture_action.xml create mode 100644 app/src/main/res/layout/item_gesture_zone_bottom.xml create mode 100644 app/src/main/res/layout/item_gesture_zone_left.xml create mode 100644 app/src/main/res/layout/item_gesture_zone_right.xml create mode 100644 app/src/main/res/layout/item_gesture_zone_top.xml create mode 100644 app/src/main/res/layout/item_key.xml create mode 100644 app/src/main/res/layout/item_multi_action.xml create mode 100644 app/src/main/res/layout/item_multi_action_step.xml create mode 100644 app/src/main/res/layout/item_theme_preset.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/fan/edgex/ExampleUnitTest.kt create mode 100644 app/src/test/java/com/fan/edgex/config/AppConfigContractTest.kt create mode 100644 app/src/test/java/com/fan/edgex/config/ConditionStoreTest.kt create mode 100644 app/src/test/java/com/fan/edgex/config/GestureZoneGeometryCalculatorTest.kt create mode 100644 app/src/test/java/com/fan/edgex/config/ThemeColorResolverTest.kt create mode 100644 app/src/test/java/com/fan/edgex/hook/ConditionEvaluatorTest.kt create mode 100644 app/src/test/java/com/fan/edgex/hook/LockscreenActionPolicyTest.kt create mode 100644 app/src/test/java/com/fan/edgex/hook/PremiumInstallMetadataTest.kt create mode 100644 app/src/test/java/com/fan/edgex/hook/PremiumSignatureVerifierTest.kt create mode 100644 app/src/test/java/com/fan/edgex/service/NotificationLifecycleManagerTest.kt create mode 100644 app/src/test/java/com/fan/edgex/utils/UpdateCheckerTest.kt create mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Configs.kt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 premium-api/.gitignore create mode 100644 premium-api/build.gradle.kts create mode 100644 premium-api/src/main/java/com/fan/edgex/premium/IPremiumPlugin.kt create mode 100644 settings.gradle.kts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6fbac22 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [fcmfcm1999] +ko_fi: fantasy1999 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0839d22 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: | + TAG=${GITHUB_REF#refs/tags/v} + echo "name=$TAG" >> $GITHUB_OUTPUT + # Extract major.minor.patch as version code base + VERSION_CODE=$(echo $TAG | sed 's/\.//g' | sed 's/[^0-9]//g') + echo "code=$VERSION_CODE" >> $GITHUB_OUTPUT + + - name: Get current date + id: date + run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Setup Signature for Release + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + if [ ! -z "$KEYSTORE_BASE64" ]; then + echo "$KEYSTORE_BASE64" | base64 --decode > app/release.jks + echo "RELEASE_STORE_FILE=release.jks" >> local.properties + echo "RELEASE_STORE_PASSWORD=$KEYSTORE_PASSWORD" >> local.properties + echo "RELEASE_KEY_ALIAS=$KEY_ALIAS" >> local.properties + echo "RELEASE_KEY_PASSWORD=$KEY_PASSWORD" >> local.properties + else + echo "Secret KEYSTORE_BASE64 is missing, you must configure secrets in GitHub Repo Settings!" + fi + + - name: Build Release APK + run: ./gradlew assembleRelease + env: + VERSION_NAME: ${{ steps.version.outputs.name }} + VERSION_CODE: ${{ steps.version.outputs.code }} + BUILD_DATE: ${{ steps.date.outputs.date }} + + - name: Rename APK with version and date + id: apk + run: | + APK_PATH=$(find app/build/outputs/apk/release -name '*.apk' | head -1) + NEW_NAME="EdgeX-v${{ steps.version.outputs.name }}-${{ steps.date.outputs.date }}.apk" + NEW_PATH="app/build/outputs/apk/release/$NEW_NAME" + mv "$APK_PATH" "$NEW_PATH" + echo "path=$NEW_PATH" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.apk.outputs.path }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b88913 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.idea +buildSrc/.gradle +buildSrc/.kotlin +buildSrc/build +# Keystore files +*.jks +*.keystore +.github/copilot-instructions.md +CLAUDE.md +todo.md +AGENTS.md +scripts +.claude +/cloudflare-worker/ +/netlify-premium/ +/premium-backend/ +/supabase/ +# Premium plugin source and build tools — not distributed in this repo +/premium/ +/tools/ + +# Local Netlify folder +.netlify +.codegraph +.antigravitycli +.agent +.agents +.codex +.trellis \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0e14d8e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "disabled" +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7eaddf --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +$env:JAVA_HOME="D:\Environment\Java\java17"; ./gradlew assembleDebug + diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..e590cd0 --- /dev/null +++ b/README_CN.md @@ -0,0 +1,98 @@ +# EdgeX + +> 向 Xposed Edge 致敬。EdgeX 是一个面向 Android 15+ 的 LSPosed/Xposed 边缘手势增强模块,通过 `system_server` 中的系统输入 Hook,把屏幕边缘手势、硬件按键和常用 Android 动作集中到一个可配置模块中。 + + + +## 简介 + +EdgeX 不是普通的悬浮窗工具,而是一个需要在 LSPosed/Xposed 中启用的系统增强模块。应用本体负责配置,实际的手势识别、按键拦截、动作执行和系统侧浮层都运行在被注入的 `system_server` 进程中: + +- `android`(system_server):处理边缘触摸、硬件按键、动作分发、冰箱抽屉、剪贴板历史浮层、全局复制选择层等系统侧能力。 +- `com.fan.edgex`:提供配置界面和跨进程设置存储。 + +适合希望在现代 Android 上复刻 Xposed Edge 式快捷操作的 Root / LSPosed 用户:返回、主页、最近任务、截屏、启动应用、执行 Shell 命令、打开冰箱抽屉、查看剪贴板历史、全局复制等都可以绑定到手势或按键。 + +## 非 Root 版本 + +如果你想使用不需要 Root 或 LSPosed 的边缘手势应用,可以尝试本项目的非 Root 版本 EdgeY: + +[Google Play 上的 EdgeY](https://play.google.com/store/apps/details?id=com.fan.EdgeY) + +## 功能 + +- **边缘手势**:支持左、右、上、下四条屏幕边缘的分段区域和全边缘低优先级区域。 +- **手势类型**:支持单击、双击、长按和与边缘方向匹配的滑动动作。 +- **硬件按键**:支持音量加、音量减、电源键的单击、双击、长按动作配置。 +- **系统动作**:返回、主页、最近任务、展开通知栏、锁屏、截屏、音量、亮度等。 +- **应用与快捷方式**:启动指定应用,触发应用快捷方式,支持受限场景下通过 Root 读取快捷方式。 +- **Pie 与自定义面板**:通过手势或按键打开径向 Pie 菜单和自定义面板。 +- **动作流程**:保存多个动作组合,并支持按条件执行不同动作。 +- **应用切换**:直接切换到上一个或下一个最近应用。 +- **冰箱抽屉**:在侧边抽屉中管理冻结应用,快速解冻并启动,之后可重新冻结。 +- **剪贴板历史**:在底部浮层中展示最近 50 条剪贴板记录,点击任意条目即可将文本注入当前输入框,也可逐条删除。 +- **全局复制**:从当前界面提取可访问文本,弹出选择层后复制需要的内容。 +- **Shell 命令**:为手势或按键绑定自定义 Shell 命令,可选择普通执行或通过 `su` 执行。 +- **音乐控制**:播放/暂停、停止、上一曲、下一曲等媒体按键动作。 +- **调试与主题**:提供手势区域调试显示、SystemUI 重启入口、动作触发震动反馈和主题色配置。 + +## 环境要求 + +- Android 15 及以上。 +- LSPosed / Xposed 环境,Xposed API 82 及以上。 +- 当前构建配置:`minSdk 35`、`targetSdk 36`、`compileSdk 36`。 +- LSPosed 作用域勾选: + - `android` / System Framework(system_server) + +### Root 相关说明 + +- 冰箱冻结/解冻通常需要 Root。EdgeX 会优先使用系统接口,并在需要时回退到 `su` 流程。 +- 应用快捷方式优先通过 Android API 读取;如果系统限制访问,会尝试通过 `dumpsys shortcut` 读取,这通常也需要 Root。 +- Shell 命令是否需要 Root 取决于你绑定的命令本身,以及该动作是否配置为通过 `su` 执行。 +- 不同 ROM、SELinux 策略和 LSPosed 版本可能影响部分动作的可用性。 + +## 安装与启用 + +1. 安装 EdgeX APK。 +2. 打开 LSPosed,启用 EdgeX 模块。 +3. 在作用域中勾选 `android`(System Framework)。 +4. 重启设备。首次启用或修改作用域后,建议完整重启。 +5. 打开 EdgeX,开启手势或按键总开关并配置动作。 + +## 使用建议 + +- 第一次配置时,可以先在主页面打开调试模式,确认边缘触发区域是否符合预期。 +- 手势无效时,优先检查 LSPosed 作用域、模块是否已启用、设备是否已重启。 +- 冰箱和 Root 快捷方式异常时,检查 `su` 授权以及 Root 管理器日志。 +- 如果系统侧浮层或动作状态异常,可以使用应用内的「重启 SystemUI」入口快速刷新。 + +## 已验证环境 + +| 设备 | Android | Xposed 环境 | Root 方案 | +|------------------------|---------|----------------------------------------------------------------|-----------------------------------------------| +| Pixel 9 | 16 | [`LSPosed 1.9.2-it(7455)`](https://github.com/LSPosed/Lsposed) | [KernelSU](https://github.com/tiann/KernelSU) | +| Android Virtual Device | 16 | [`Vector 2.0(3021)`](https://github.com/JingMatrix/Vector) | [Magisk](https://github.com/topjohnwu/Magisk) | + +以上只是当前开发验证环境,不代表唯一支持组合。其他设备和 ROM 可能需要额外适配。 + +## 反馈 + +提交 Issue 时建议附上: + +- 设备型号、Android 版本、ROM 名称。 +- LSPosed / Xposed 版本。 +- 已勾选的作用域。 +- 触发方式,例如「右侧中部,左划」。 +- Xposed 日志或复现步骤。 + +## 支持 + +如果 EdgeX 对你有帮助,欢迎通过 [Ko-fi](https://ko-fi.com/fantasy1999) 支持项目开发。 + +## 致谢 + +EdgeX 的功能参考了 Xposed Edge / Xposed Edge Pro 的交互模式,并针对 Android 15+ 和当前 LSPosed 环境重新实现。 + +## License + +本项目基于 [GNU General Public License v3.0](LICENSE) 开源。 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e54c0cf --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,119 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +fun buildConfigString(value: String): String = + "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + +val premiumApiUrls = localProperties.getProperty("PREMIUM_API_URLS") + ?: System.getenv("PREMIUM_API_URLS") + ?: "https://activation-server-production-29da.up.railway.app" + + +android { + namespace = Configs.namespace + compileSdk = Configs.compileSdk + + buildFeatures { + buildConfig = true + aidl = true + compose = true + } + + defaultConfig { + applicationId = Configs.applicationId + minSdk = Configs.minSdk + targetSdk = Configs.targetSdk + versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: Configs.versionCode + versionName = System.getenv("VERSION_NAME") ?: Configs.versionName + buildConfigField( + "String", + "PREMIUM_API_URLS", + buildConfigString(premiumApiUrls) + ) + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + getByName("debug") { + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + create("release") { + val keyPath = localProperties.getProperty("RELEASE_STORE_FILE") + if (keyPath != null) { + storeFile = file(keyPath) + storePassword = localProperties.getProperty("RELEASE_STORE_PASSWORD") + keyAlias = localProperties.getProperty("RELEASE_KEY_ALIAS") + keyPassword = localProperties.getProperty("RELEASE_KEY_PASSWORD") + } else { + // Fallback to debug signature for community contributors building a release + storeFile = getByName("debug").storeFile + storePassword = getByName("debug").storePassword + keyAlias = getByName("debug").keyAlias + keyPassword = getByName("debug").keyPassword + } + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = Configs.javaVersion + targetCompatibility = Configs.javaVersion + } + kotlinOptions { + jvmTarget = Configs.jvmTarget + } +} + +dependencies { + compileOnly("de.robv.android.xposed:api:82") + implementation(project(":premium-api")) + implementation("com.github.topjohnwu.libsu:core:6.0.0") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.appcompat) + implementation(libs.material) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..719ed82 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# Keep Xposed hook entry points +-keep class com.fan.edgex.hook.MainHook { *; } +-keep class * implements de.robv.android.xposed.IXposedHookLoadPackage { *; } +-keep class * implements de.robv.android.xposed.IXposedHookZygoteInit { *; } +-keep class * implements de.robv.android.xposed.IXposedHookInitPackageResources { *; } + +# Keep all hook/overlay/ui classes (referenced via reflection by Xposed) +-keep class com.fan.edgex.** { *; } + +# Xposed API +-keep class de.robv.android.xposed.** { *; } +-dontwarn de.robv.android.xposed.** + +# Keep line numbers for crash debugging +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# Keep the entire Kotlin stdlib in the release DEX. +# This module runs as an Xposed hook inside system_server; the dynamically-loaded +# premium DEX resolves all Kotlin runtime classes through the module ClassLoader. +# R8 aggressively removes individual stdlib classes it considers unreferenced +# (Intrinsics, Result, collections helpers, etc.) — keeping the whole package +# avoids a chain of NoClassDefFoundError failures for each removed class. +-keep class kotlin.** { *; } +-keep class kotlin.jvm.** { *; } \ No newline at end of file diff --git a/app/src/androidTest/java/com/fan/edgex/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/fan/edgex/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..7e95623 --- /dev/null +++ b/app/src/androidTest/java/com/fan/edgex/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/fan/edgex/ui/compose/EdgeXComposeSmokeTest.kt b/app/src/androidTest/java/com/fan/edgex/ui/compose/EdgeXComposeSmokeTest.kt new file mode 100644 index 0000000..74f00f4 --- /dev/null +++ b/app/src/androidTest/java/com/fan/edgex/ui/compose/EdgeXComposeSmokeTest.kt @@ -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() + + 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() + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5d07d78 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/com/fan/edgex/IKeystoreVerifier.aidl b/app/src/main/aidl/com/fan/edgex/IKeystoreVerifier.aidl new file mode 100644 index 0000000..738cb57 --- /dev/null +++ b/app/src/main/aidl/com/fan/edgex/IKeystoreVerifier.aidl @@ -0,0 +1,5 @@ +package com.fan.edgex; + +interface IKeystoreVerifier { + byte[] sign(in byte[] challenge); +} diff --git a/app/src/main/aidl/com/fan/edgex/IShellCallback.aidl b/app/src/main/aidl/com/fan/edgex/IShellCallback.aidl new file mode 100644 index 0000000..8a05939 --- /dev/null +++ b/app/src/main/aidl/com/fan/edgex/IShellCallback.aidl @@ -0,0 +1,5 @@ +package com.fan.edgex; + +oneway interface IShellCallback { + void onResult(boolean success, String output); +} diff --git a/app/src/main/aidl/com/fan/edgex/IShellExecutor.aidl b/app/src/main/aidl/com/fan/edgex/IShellExecutor.aidl new file mode 100644 index 0000000..9c73a8d --- /dev/null +++ b/app/src/main/aidl/com/fan/edgex/IShellExecutor.aidl @@ -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); +} diff --git a/app/src/main/assets/xposed_init b/app/src/main/assets/xposed_init new file mode 100644 index 0000000..6ddf3a9 --- /dev/null +++ b/app/src/main/assets/xposed_init @@ -0,0 +1 @@ +com.fan.edgex.hook.MainHook \ No newline at end of file diff --git a/app/src/main/java/com/fan/edgex/App.kt b/app/src/main/java/com/fan/edgex/App.kt new file mode 100644 index 0000000..e66c365 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/App.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/fan/edgex/action/AppActionExecutor.kt b/app/src/main/java/com/fan/edgex/action/AppActionExecutor.kt new file mode 100644 index 0000000..dcdc26c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/action/AppActionExecutor.kt @@ -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, 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()) + } + } +} diff --git a/app/src/main/java/com/fan/edgex/config/AppConfig.kt b/app/src/main/java/com/fan/edgex/config/AppConfig.kt new file mode 100644 index 0000000..932af74 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/AppConfig.kt @@ -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? { + 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? { + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/config/ConditionStore.kt b/app/src/main/java/com/fan/edgex/config/ConditionStore.kt new file mode 100644 index 0000000..38d759e --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/ConditionStore.kt @@ -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 = + 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 { + 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): List = + packageNames.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .sorted() + .toList() + + private fun parseJsonStringArray(value: String): List? { + 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().takeIf { index == value.length } + } + val result = mutableListOf() + 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, +) diff --git a/app/src/main/java/com/fan/edgex/config/ConfigSnapshotReceiver.kt b/app/src/main/java/com/fan/edgex/config/ConfigSnapshotReceiver.kt new file mode 100644 index 0000000..8276133 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/ConfigSnapshotReceiver.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/fan/edgex/config/ConfigStore.kt b/app/src/main/java/com/fan/edgex/config/ConfigStore.kt new file mode 100644 index 0000000..c5ca444 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/ConfigStore.kt @@ -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): 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() + + 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) { + HookConfigSnapshot.writeFromPreferences(this) + sendConfigBroadcast(changedValues, fullSnapshot = false) +} + +private fun Context.runtimeValuesAfterChange(changedValues: Map): Map { + 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, + 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): Boolean = + AppConfig.GESTURES.any { gesture -> + AppConfig.isActiveActionValue(values[AppConfig.gestureAction(zone, gesture)].orEmpty()) + } + +private fun keyHasConfiguredAction( + keyCode: Int, + changedValues: Map, + 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): Boolean = + AppConfig.KEY_TRIGGERS.any { trigger -> + AppConfig.isActiveActionValue(values[AppConfig.keyAction(keyCode, trigger)].orEmpty()) + } + +private fun Context.sendConfigBroadcast(valuesByKey: Map, 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) + }) +} diff --git a/app/src/main/java/com/fan/edgex/config/FreezerBootstrap.kt b/app/src/main/java/com/fan/edgex/config/FreezerBootstrap.kt new file mode 100644 index 0000000..36bed3c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/FreezerBootstrap.kt @@ -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() + } +} diff --git a/app/src/main/java/com/fan/edgex/config/GestureZoneGeometryCalculator.kt b/app/src/main/java/com/fan/edgex/config/GestureZoneGeometryCalculator.kt new file mode 100644 index 0000000..36f50ab --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/GestureZoneGeometryCalculator.kt @@ -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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/app/src/main/java/com/fan/edgex/config/HookClipboardHistoryStore.kt b/app/src/main/java/com/fan/edgex/config/HookClipboardHistoryStore.kt new file mode 100644 index 0000000..26d3d51 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/HookClipboardHistoryStore.kt @@ -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 = + read(historyFileForHook(), maxItems) + + fun writeForHook(items: List, 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 { + 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): 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) + } +} diff --git a/app/src/main/java/com/fan/edgex/config/HookConfigSnapshot.kt b/app/src/main/java/com/fan/edgex/config/HookConfigSnapshot.kt new file mode 100644 index 0000000..15bc1e1 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/HookConfigSnapshot.kt @@ -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 = + read(snapshotFileForHook()) + + fun readFromContext(context: Context): Map = + read(snapshotFile(context)) + + fun writeForHook(values: Map): Boolean = + write(systemSnapshotFile(), valuesForHook(values)) + + fun isHookRuntimeKey(key: String): Boolean = + key != KEY_VERSION && !key.endsWith("_label") + + private fun write(context: Context, values: Map): Boolean { + return write(snapshotFile(context), values) + } + + private fun write(file: File, values: Map): 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 { + 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): Map = + 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) + } +} diff --git a/app/src/main/java/com/fan/edgex/config/ModuleActivationState.kt b/app/src/main/java/com/fan/edgex/config/ModuleActivationState.kt new file mode 100644 index 0000000..aeb6749 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/ModuleActivationState.kt @@ -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) + } +} diff --git a/app/src/main/java/com/fan/edgex/config/MultiActionStore.kt b/app/src/main/java/com/fan/edgex/config/MultiActionStore.kt new file mode 100644 index 0000000..8e34f3c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/MultiActionStore.kt @@ -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, + 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 { + 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): 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 = + 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 = + parseSteps(resolveConfig("multi_action_${id}_steps")) +} diff --git a/app/src/main/java/com/fan/edgex/config/ShellExecutorService.kt b/app/src/main/java/com/fan/edgex/config/ShellExecutorService.kt new file mode 100644 index 0000000..3bbabff --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/ShellExecutorService.kt @@ -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 + } +} diff --git a/app/src/main/java/com/fan/edgex/config/ThemeColorResolver.kt b/app/src/main/java/com/fan/edgex/config/ThemeColorResolver.kt new file mode 100644 index 0000000..6f34a37 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/config/ThemeColorResolver.kt @@ -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) +} diff --git a/app/src/main/java/com/fan/edgex/hook/ClipboardHook.kt b/app/src/main/java/com/fan/edgex/hook/ClipboardHook.kt new file mode 100644 index 0000000..84be932 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/ClipboardHook.kt @@ -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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/ClipboardOverlay.kt b/app/src/main/java/com/fan/edgex/hook/ClipboardOverlay.kt new file mode 100644 index 0000000..84c6119 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/ClipboardOverlay.kt @@ -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? = null + private var autoDismissRunnable: Runnable? = null + + // ── History ──────────────────────────────────────────────────────────────── + + private val history = mutableListOf() + 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 { + 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) { + 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, + 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) { + 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}") + } + } + +} diff --git a/app/src/main/java/com/fan/edgex/hook/ConditionEvaluator.kt b/app/src/main/java/com/fan/edgex/hook/ConditionEvaluator.kt new file mode 100644 index 0000000..13acbc2 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/ConditionEvaluator.kt @@ -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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/CopyPanelOverlay.kt b/app/src/main/java/com/fan/edgex/hook/CopyPanelOverlay.kt new file mode 100644 index 0000000..b976f19 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/CopyPanelOverlay.kt @@ -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? = null + private var dismissRunnable: Runnable? = null + + private var hintView: WeakReference? = null + private var copyButton: WeakReference? = null + private var selectAllButton: WeakReference? = 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) { + 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) { + 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, + 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) { + 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, + 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 + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/DebugOverlayController.kt b/app/src/main/java/com/fan/edgex/hook/DebugOverlayController.kt new file mode 100644 index 0000000..2ffef5c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/DebugOverlayController.kt @@ -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 + } + + private var initialized = false + private var receiverRegistered = false + private var systemUiContext: Context? = null + private val debugViews = mutableListOf() + + 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 + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/EdgeGestureDetector.kt b/app/src/main/java/com/fan/edgex/hook/EdgeGestureDetector.kt new file mode 100644 index 0000000..4877700 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/EdgeGestureDetector.kt @@ -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 + } + + 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? { + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/FlashlightManager.kt b/app/src/main/java/com/fan/edgex/hook/FlashlightManager.kt new file mode 100644 index 0000000..46ee819 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/FlashlightManager.kt @@ -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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/GameModeManager.kt b/app/src/main/java/com/fan/edgex/hook/GameModeManager.kt new file mode 100644 index 0000000..e8a2f85 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/GameModeManager.kt @@ -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) + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/GestureActionDispatcher.kt b/app/src/main/java/com/fan/edgex/hook/GestureActionDispatcher.kt new file mode 100644 index 0000000..aac91f1 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/GestureActionDispatcher.kt @@ -0,0 +1,1047 @@ +package com.fan.edgex.hook + +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.app.KeyguardManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.drawable.Drawable +import android.media.AudioManager +import android.net.wifi.WifiManager +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.os.UserHandle +import android.os.VibrationEffect +import android.os.Vibrator +import android.telephony.TelephonyManager +import android.view.KeyEvent +import android.widget.Toast +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.ThemeColorResolver +import com.fan.edgex.config.HookConfigSnapshot +import com.fan.edgex.config.ConditionStore +import com.fan.edgex.config.ForegroundAppConditionConfig +import com.fan.edgex.overlay.DrawerManager +import com.fan.edgex.overlay.PanelOverlayManager +import com.fan.edgex.overlay.PieManager +import com.fan.edgex.overlay.PieView +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers + +internal class GestureActionDispatcher( + private val resolveConfig: (String) -> String, + private val handlerProvider: () -> Handler, + private val log: (String) -> Unit, +) { + @Volatile private var shellExecutor: IShellExecutor? = null + @Volatile private var serviceBound = false + private var serviceContext: Context? = null + private val pendingCommands = ArrayDeque>() + private val pendingUnlockActions = ArrayDeque() + private var idleUnbindRunnable: Runnable? = null + private val SHELL_SERVICE_IDLE_TIMEOUT_MS = 5 * 60 * 1000L + + private data class PendingUnlockAction( + val action: String, + val context: Context, + val touchX: Float, + val touchY: Float, + ) + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + shellExecutor = IShellExecutor.Stub.asInterface(binder) + drainPendingCommands() + } + override fun onServiceDisconnected(name: ComponentName) { + shellExecutor = null + serviceBound = false + serviceContext?.let { + handlerProvider().postDelayed({ bindShellService(it) }, 2000) + } + } + } + + private fun drainPendingCommands() { + while (pendingCommands.isNotEmpty()) { + val (action, ctx) = pendingCommands.removeFirst() + doExecuteShellCommand(action, ctx) + } + } + + private fun scheduleIdleUnbind() { + val handler = handlerProvider() + idleUnbindRunnable?.let { handler.removeCallbacks(it) } + val runnable = Runnable { + idleUnbindRunnable = null + unbindShellService() + } + idleUnbindRunnable = runnable + handler.postDelayed(runnable, SHELL_SERVICE_IDLE_TIMEOUT_MS) + } + + private fun unbindShellService() { + val ctx = serviceContext ?: return + if (!serviceBound) return + try { + ctx.unbindService(serviceConnection) + } catch (e: Exception) { + log("ShellExecutorService unbind failed: ${e.message}") + } + shellExecutor = null + serviceBound = false + } + + fun bindShellService(context: Context) { + if (serviceBound) return + serviceContext = context + val intent = Intent().apply { + component = ComponentName( + BuildConfig.APPLICATION_ID, + "${BuildConfig.APPLICATION_ID}.config.ShellExecutorService", + ) + addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + } + val bound = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + if (bound) serviceBound = true + } + fun triggerGestureAction( + zone: String, + gestureType: String, + context: Context, + touchX: Float, + touchY: Float, + ) { + val configKey = AppConfig.gestureAction(zone, gestureType) + var action = resolveConfig(configKey) + + // If not found in the specific zone, check the fallback zone. + // This mirrors the resolveAction() fallback in GestureManager so that a touch + // landing in an enabled specific zone (e.g. "right_mid") can still execute an + // action that was configured on the full-edge fallback zone (e.g. "right"). + if (action.isEmpty() || action == "none") { + val fallbackZone = AppConfig.fallbackEdgeZone(zone) + if (fallbackZone != null) { + action = resolveConfig(AppConfig.gestureAction(fallbackZone, gestureType)) + } + } + + log("[Gesture] triggerAction key=$configKey action='$action'") + if (action.isNotEmpty() && action != "none") { + handlerProvider().post { + performAction(action, context, touchX, touchY) + } + } + } + + fun executeKeyAction(action: String, context: Context) { + handlerProvider().post { + performAction(action, context, 0f, 0f) + } + } + + fun adjustBrightness(context: Context, up: Boolean) = + com.fan.edgex.action.AppActionExecutor.adjustBrightness(context, up) + + fun adjustVolume(context: Context, up: Boolean) = + com.fan.edgex.action.AppActionExecutor.adjustVolume(context, up) + + private fun vibrateActionFeedback(context: Context) { + if (resolveConfig(AppConfig.HAPTIC_FEEDBACK) != "true") return + try { + val vibrator = context.getSystemService(Vibrator::class.java) ?: return + val effect = when (resolveConfig(AppConfig.HAPTIC_FEEDBACK_TYPE)) { + AppConfig.HAPTIC_FEEDBACK_TYPE_TICK -> VibrationEffect.EFFECT_TICK + AppConfig.HAPTIC_FEEDBACK_TYPE_HEAVY_CLICK -> VibrationEffect.EFFECT_HEAVY_CLICK + AppConfig.HAPTIC_FEEDBACK_TYPE_DOUBLE_CLICK -> VibrationEffect.EFFECT_DOUBLE_CLICK + else -> VibrationEffect.EFFECT_CLICK + } + vibrator.vibrate(VibrationEffect.createPredefined(effect)) + } catch (_: Throwable) { + } + } + + private fun performAction(action: String, context: Context, touchX: Float, touchY: Float) { + vibrateActionFeedback(context) + dispatchAction(action, context, touchX, touchY) + } + + private fun dispatchAction(action: String, context: Context, touchX: Float, touchY: Float) { + if (LockscreenActionPolicy.requiresUnlock(action) && isKeyguardLocked(context)) { + pendingUnlockActions.addLast(PendingUnlockAction(action, context, touchX, touchY)) + log("Action queued until unlock: '$action'") + return + } + + when { + action == "back" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_BACK) + } + action == "home" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_HOME) + } + action == "recent" || action == "recents" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_RECENTS) + } + action == "notifications" || action == "expand_notifications" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_NOTIFICATIONS) + } + action == "quick_settings" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_QUICK_SETTINGS) + } + action == "power_dialog" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_POWER_DIALOG) + } + action == "lock_screen" -> { + GlobalActionHelper.performGlobalAction(context, GlobalActionHelper.GLOBAL_ACTION_LOCK_SCREEN) + } + action == "kill_app" -> { + killForegroundApp(context) + } + action == "prev_app" -> { + switchApp(context, forward = false) + } + action == "next_app" -> { + switchApp(context, forward = true) + } + action == "clear_background" -> { + clearBackgroundApps(context) + } + action.startsWith("music_control:") -> { + com.fan.edgex.action.AppActionExecutor.dispatchMusicControl(context, action) + } + action.startsWith("fast_scroll:") -> { + injectScrollEvent(context, action == "fast_scroll:to_top", touchX, touchY) + } + action == "brightness_up" || action == "brightness_down" -> { + adjustBrightness(context, action == "brightness_up") + } + action == "volume_up" || action == "volume_down" -> { + adjustVolume(context, action == "volume_up") + } + action == "screenshot" -> { + performScreenshot(context) + } + action == AppConfig.PARTIAL_SCREENSHOT_ACTION -> { + PartialScreenshotOverlay.show(context) + } + action == "refreeze" -> { + performRefreeze(context) + } + action == "universal_copy" -> { + UniversalCopyManager.collectAllTexts(context) { result -> + when (result.status) { + UniversalCopyManager.CollectStatus.FOUND -> { + TextSelectionOverlay.show(context, result.blocks) + } + UniversalCopyManager.CollectStatus.NO_TEXT -> { + showToast(context, ModuleRes.getString(R.string.toast_no_text_found)) + } + UniversalCopyManager.CollectStatus.UNAVAILABLE -> { + showToast(context, ModuleRes.getString(R.string.toast_copy_unavailable)) + } + } + } + } + action.startsWith("shell:") -> { + doExecuteShellCommand(action, context) + } + action.startsWith("app_shortcut:") -> { + launchShortcut(context, action) + } + action.startsWith("launch_app:") -> { + launchApp(context, action) + } + action == "clipboard" -> { + ClipboardOverlay.show(context) + } + action == "freezer_drawer" -> { + DrawerManager.showDrawer(context, resolveConfig) + } + action == AppConfig.CUSTOM_PANEL_ACTION -> { + PanelOverlayManager.showCustomPanel(context, resolveConfig) { selected -> + dispatchAction(selected, context, touchX, touchY) + } + } + action == AppConfig.SIDE_BAR_LEFT_ACTION -> { + PanelOverlayManager.showSideBar(context, resolveConfig, "left") { selected -> + dispatchAction(selected, context, touchX, touchY) + } + } + action == AppConfig.SIDE_BAR_RIGHT_ACTION -> { + PanelOverlayManager.showSideBar(context, resolveConfig, "right") { selected -> + dispatchAction(selected, context, touchX, touchY) + } + } + action.startsWith("multi_action:") -> { + executeMultiAction(action, context, touchX, touchY) + } + action.startsWith("condition:") -> { + executeConditionAction(action, context, touchX, touchY) + } + action == "toggle_flashlight" -> { + FlashlightManager.toggle(context, handlerProvider()) + } + action == "toggle_wifi" -> { + toggleWifi(context) + } + action == "toggle_mobile_data" -> { + toggleMobileData(context) + } + action == "game_mode" -> { + GameModeManager.enable(context, handlerProvider()) + } + } + } + + fun onUserUnlocked(context: Context) { + handlerProvider().post { + if (isKeyguardLocked(context) || pendingUnlockActions.isEmpty()) return@post + + val actions = buildList { + while (pendingUnlockActions.isNotEmpty()) { + add(pendingUnlockActions.removeFirst()) + } + } + log("Executing ${actions.size} action(s) queued during lockscreen") + + var delay = 0L + actions.forEach { pending -> + handlerProvider().postDelayed({ + dispatchAction( + pending.action, + pending.context, + pending.touchX, + pending.touchY, + ) + }, delay) + delay += stepSettleDuration(pending.action) + } + } + } + + private fun isKeyguardLocked(context: Context): Boolean = try { + context.getSystemService(KeyguardManager::class.java)?.isKeyguardLocked == true + } catch (_: Throwable) { + false + } + + private fun executeConditionAction(action: String, context: Context, touchX: Float, touchY: Float) { + val id = action.removePrefix("condition:") + if (id.isBlank()) return + val condCode = resolveConfig(ConditionStore.condIfKey(id)) + if (condCode.isBlank()) return + val foregroundAppConfig = if (condCode == ConditionStore.FOREGROUND_APP) { + ForegroundAppConditionConfig( + packageNames = ConditionStore.decodePackageNames( + resolveConfig(ConditionStore.foregroundPackagesKey(id)), + ), + ) + } else { + null + } + val result = ConditionEvaluator.evaluate(condCode, context, foregroundAppConfig) + val nextAction = if (result) { + resolveConfig(ConditionStore.condThenKey(id)) + } else { + resolveConfig(ConditionStore.condElseKey(id)) + } + if (nextAction.isNotBlank() && nextAction != "none") { + dispatchAction(nextAction, context, touchX, touchY) + } + } + + private fun executeMultiAction(action: String, context: Context, touchX: Float, touchY: Float) { + val id = action.removePrefix("multi_action:") + if (id.isBlank()) return + val steps = com.fan.edgex.config.MultiActionStore.getStepsFromConfig(resolveConfig, id) + if (steps.isEmpty()) return + val handler = handlerProvider() + var delay = 0L + for (step in steps) { + if (step.code.isBlank() || step.code == "none") continue + val code = step.code + de.robv.android.xposed.XposedBridge.log("EdgeX: multi_action step='$code' scheduledAt=${delay}ms") + handler.postDelayed({ + try { + de.robv.android.xposed.XposedBridge.log("EdgeX: multi_action executing step='$code'") + dispatchAction(code, context, touchX, touchY) + } catch (t: Throwable) { + log("multi_action step '$code' failed: ${t.message}") + } + }, delay) + delay += stepSettleDuration(code) + } + } + + // How long to wait after this action before firing the next step. + // Navigation actions (HOME, BACK, etc.) animate for ~300ms; give 600ms to be safe. + // App launches need ~500ms for the window to fully appear. + // Everything else (brightness, volume, media) is near-instant. + private fun stepSettleDuration(code: String): Long = + com.fan.edgex.action.AppActionExecutor.stepSettleDuration(code) + + fun showPie(context: Context, anchorX: Float, anchorY: Float, edge: String) { + val rings = (1..AppConfig.PIE_RINGS).map { ring -> + PieView.Ring((0 until AppConfig.PIE_SLOTS_PER_RING).mapNotNull { slot -> + val action = resolveConfig(AppConfig.pieSlot(edge, ring, slot)) + if (action.isEmpty() || action == "none") null + else { + val label = resolveConfig(AppConfig.pieSlotLabel(edge, ring, slot)).ifEmpty { pieActionToLabel(action) } + val icon = loadActionIcon(context, action) + PieView.Slot(label, action, icon) + } + }) + } + if (rings.all { it.slots.isEmpty() }) return + PieManager.show(context, anchorX, anchorY, edge, rings, resolvePieColor(), resolvePieSizeScale()) + } + + private fun resolvePieColor(): Int { + return ThemeColorResolver.resolveConfiguredColor(AppConfig.PIE_COLOR, resolveConfig) + } + + private fun resolvePieSizeScale(): Float = + resolveConfig(AppConfig.PIE_SIZE_SCALE) + .toFloatOrNull() + ?.coerceIn(0.8f, 1.2f) + ?: AppConfig.PIE_SIZE_SCALE_DEFAULT + + private fun loadActionIcon(context: Context, action: String): Drawable? { + if (action.startsWith("launch_app:")) { + val pkg = action.substringAfter("launch_app:") + try { return context.packageManager.getApplicationIcon(pkg) } catch (_: Exception) {} + } + val resId = actionToIconRes(action) + if (resId == 0) return null + return ModuleRes.getDrawable(resId) + } + + private fun actionToIconRes(action: String): Int = when { + action == "back" -> R.drawable.ic_arrow_back + action == "home" -> R.drawable.ic_home + action == "recents" -> R.drawable.ic_recents + action == "screenshot" -> R.drawable.ic_camera + action == AppConfig.PARTIAL_SCREENSHOT_ACTION -> R.drawable.ic_partial_screenshot + action == "lock_screen" -> R.drawable.ic_power + action == "expand_notifications" -> R.drawable.ic_notifications + 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 == "clipboard" -> R.drawable.ic_paste + action == "universal_copy" -> R.drawable.ic_content_copy + action == "freezer_drawer" -> R.drawable.ic_freezer + action == "refreeze" -> R.drawable.ic_refreeze + 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 == "clear_background" -> R.drawable.ic_clear_recent + action == "sub_gesture" -> R.drawable.ic_sub_gesture + action.startsWith("music_control:") -> when (action.substringAfter("music_control:")) { + "play_pause" -> R.drawable.ic_music_play_pause + "stop" -> R.drawable.ic_music_stop + "previous" -> R.drawable.ic_music_previous + "next" -> R.drawable.ic_music_next + else -> R.drawable.ic_music + } + action.startsWith("fast_scroll:") -> when (action.substringAfter("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("shell:") -> R.drawable.ic_terminal + action.startsWith("app_shortcut:") -> R.drawable.ic_app_shortcut + action.startsWith("launch_app:") -> R.drawable.ic_launch_app + 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_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 + else -> 0 + } + + fun commitPieAction(context: Context) { + val action = PieManager.commit() ?: return + performAction(action, context, 0f, 0f) + } + + private fun pieActionToLabel(action: String): String = when { + action == "back" -> "Back" + action == "home" -> "Home" + action == "recents" || action == "recent" -> "Recents" + action == "screenshot" -> "Screenshot" + action == AppConfig.PARTIAL_SCREENSHOT_ACTION -> "Partial SS" + action == "lock_screen" -> "Lock" + action == "expand_notifications" -> "Notifs" + action == "kill_app" -> "Kill App" + action == "prev_app" -> "Prev App" + action == "next_app" -> "Next App" + action == "clipboard" -> "Clipboard" + action == "universal_copy" -> "Copy" + action == "freezer_drawer" -> "Freezer" + action == "refreeze" -> "Refreeze" + action == "brightness_up" -> "Bright+" + action == "brightness_down" -> "Bright-" + action == "volume_up" -> "Vol+" + action == "volume_down" -> "Vol-" + action == "clear_background" -> "Clear" + action.startsWith("music_control:") -> "Music" + action.startsWith("fast_scroll:") -> when (action.substringAfter("fast_scroll:")) { + "to_top" -> "Scroll Up" + "to_bottom" -> "Scroll Down" + else -> "Scroll" + } + action.startsWith("shell:") -> "Shell" + action.startsWith("launch_app:") -> "Launch" + action.startsWith("app_shortcut:") -> "Shortcut" + action == "toggle_flashlight" -> "Flashlight" + action == "toggle_wifi" -> "Wi-Fi" + action == "toggle_mobile_data" -> "Data" + action == "game_mode" -> "GameMode" + else -> action.take(8) + } + + @Suppress("DEPRECATION") + private fun toggleWifi(context: Context) { + try { + val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager + if (wifiManager == null) { + showToast(context, ModuleRes.getString(R.string.toast_wifi_toggle_failed)) + return + } + val enable = !wifiManager.isWifiEnabled + val success = wifiManager.setWifiEnabled(enable) + if (success) { + showToast( + context, + ModuleRes.getString( + if (enable) R.string.wifi_toast_on else R.string.wifi_toast_off, + ), + ) + } else { + showToast(context, ModuleRes.getString(R.string.toast_wifi_toggle_failed)) + } + } catch (t: Throwable) { + log("toggleWifi failed: ${t.message}") + showToast(context, ModuleRes.getString(R.string.toast_wifi_toggle_failed)) + } + } + + @Suppress("DEPRECATION") + @SuppressLint("MissingPermission") + private fun toggleMobileData(context: Context) { + try { + val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + if (telephonyManager == null) { + showToast(context, ModuleRes.getString(R.string.toast_mobile_data_toggle_failed)) + return + } + val enable = !telephonyManager.isDataEnabled + telephonyManager.setDataEnabled(enable) + showToast( + context, + ModuleRes.getString( + if (enable) R.string.mobile_data_toast_on else R.string.mobile_data_toast_off, + ), + ) + } catch (t: Throwable) { + log("toggleMobileData failed: ${t.message}") + showToast(context, ModuleRes.getString(R.string.toast_mobile_data_toggle_failed)) + } + } + + private fun killForegroundApp(context: Context) { + try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + @Suppress("DEPRECATION") + val tasks = activityManager.getRunningTasks(1) + if (tasks.isNullOrEmpty()) return + val pkg = tasks[0].topActivity?.packageName ?: return + if (pkg == context.packageName) return + XposedHelpers.callMethod(activityManager, "forceStopPackage", pkg) + } catch (e: Exception) { + log("killForegroundApp failed: ${e.message}") + } + } + + @SuppressLint("MissingPermission") + private fun switchApp(context: Context, forward: Boolean) { + try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + @Suppress("DEPRECATION") + val tasks = activityManager.getRunningTasks(50) + if (tasks.isNullOrEmpty()) return + + val homePkgs = getHomeLauncherPackages(context) + val filtered = tasks.filter { task -> + val pkg = task.topActivity?.packageName ?: return@filter false + pkg != context.packageName && pkg !in homePkgs + } + if (filtered.size < 2) return + + val currentPkg = filtered[0].topActivity?.packageName ?: return + + val sorted = filtered.sortedBy { it.topActivity?.packageName ?: "" } + val idx = sorted.indexOfFirst { it.topActivity?.packageName == currentPkg } + val step = if (forward) 1 else -1 + val nextIdx = ((if (idx < 0) 0 else idx) + step + sorted.size) % sorted.size + val target = sorted[nextIdx] + if (target.topActivity?.packageName == currentPkg) return + val targetTaskId = target.taskId + + @Suppress("DEPRECATION") + activityManager.moveTaskToFront(targetTaskId, 0) + } catch (t: Throwable) { + log("switchApp failed: ${t.message}") + } + } + + private fun getHomeLauncherPackages(context: Context): Set { + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + return try { + context.packageManager.queryIntentActivities(intent, 0) + .mapNotNull { it.activityInfo?.packageName } + .toSet() + } catch (_: Throwable) { + emptySet() + } + } + + private fun doExecuteShellCommand(action: String, context: Context) { + val content = action.removePrefix("shell:") + val parts = content.split(":", limit = 2) + if (parts.size != 2) { + showToast(context, ModuleRes.getString(R.string.toast_shell_invalid_format)) + return + } + val runAsRoot = parts[0] == "true" + val command = parts[1] + if (command.isBlank()) { + showToast(context, ModuleRes.getString(R.string.toast_empty_command)) + return + } + + val executor = shellExecutor + if (executor == null) { + pendingCommands.addLast(action to context) + bindShellService(context) + return + } + + scheduleIdleUnbind() + executor.execute(command, runAsRoot, object : IShellCallback.Stub() { + override fun onResult(success: Boolean, output: String?) { + if (success) { + output?.trim()?.takeIf { it.isNotBlank() }?.let { + showToast(context, it.take(200)) + } + } else { + showToast(context, ModuleRes.getString(R.string.toast_command_failed, output?.trim()?.take(200).orEmpty())) + } + } + }) + } + + private fun showToast(context: Context, text: String) { + handlerProvider().post { + try { + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } catch (_: Throwable) { + } + } + } + + private fun launchShortcut(context: Context, action: String) { + try { + val parts = action.split(":", limit = 3) + if (parts.size != 3) { + showToast(context, ModuleRes.getString(R.string.toast_shortcut_format_error)) + return + } + + val packageName = parts[1] + val shortcutId = parts[2] + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { + val launcherApps = + context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as android.content.pm.LauncherApps + try { + launcherApps.startShortcut( + packageName, + shortcutId, + null, + null, + currentUserHandle(), + ) + } catch (e: Throwable) { + log("Failed to launch shortcut: ${e.message}") + try { + val intent = context.packageManager.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + showToast(context, ModuleRes.getString(R.string.toast_cannot_launch_shortcut)) + } + } catch (_: Throwable) { + showToast(context, ModuleRes.getString(R.string.toast_launch_failed)) + } + } + } else { + showToast(context, ModuleRes.getString(R.string.toast_requires_android_71)) + } + } catch (e: Throwable) { + e.printStackTrace() + showToast(context, ModuleRes.getString(R.string.toast_shortcut_launch_failed, e.message)) + } + } + + private fun currentUserHandle(): UserHandle { + val currentUserId = runCatching { + XposedHelpers.callStaticMethod( + android.app.ActivityManager::class.java, + "getCurrentUser", + ) as Int + }.getOrDefault(0) + return runCatching { + XposedHelpers.callStaticMethod(UserHandle::class.java, "of", currentUserId) as UserHandle + }.getOrDefault(android.os.Process.myUserHandle()) + } + + private fun performRefreeze(context: Context) { + val handler = Handler(Looper.getMainLooper()) + Thread { + try { + val pm = context.packageManager + val packageSet = linkedSetOf() + val listStr = readConfigValue(context, AppConfig.FREEZER_APP_LIST) + if (listStr.isNotEmpty()) { + packageSet.addAll( + listStr.split(",") + .map { pkg -> pkg.trim() } + .filter { pkg -> pkg.isNotEmpty() }, + ) + } + + if (packageSet.isEmpty()) { + handler.post { + Toast.makeText( + context, + ModuleRes.getString(R.string.toast_freezer_list_empty), + Toast.LENGTH_SHORT, + ).show() + } + return@Thread + } + + var count = 0 + for (pkg in packageSet) { + try { + val info = pm.getApplicationInfo(pkg, 0) + if (info.enabled) { + var success = false + try { + pm.setApplicationEnabledSetting( + pkg, + android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + 0, + ) + success = true + } catch (e: Exception) { + XposedBridge.log("EdgeX: PM API freeze FAILED for $pkg: ${e.message}") + } + if (success) count++ + } + } catch (_: android.content.pm.PackageManager.NameNotFoundException) { + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (count > 0) { + handler.post { + Toast.makeText( + context, + ModuleRes.getString(R.string.toast_refrozen_apps, count), + Toast.LENGTH_SHORT, + ).show() + } + } else { + handler.post { + Toast.makeText( + context, + ModuleRes.getString(R.string.toast_no_apps_to_freeze), + Toast.LENGTH_SHORT, + ).show() + } + } + } catch (e: Exception) { + e.printStackTrace() + handler.post { + Toast.makeText( + context, + ModuleRes.getString(R.string.toast_freeze_error, e.message), + Toast.LENGTH_SHORT, + ).show() + } + } + }.start() + } + + private fun clearBackgroundApps(context: Context) { + try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val activityTaskManager = getActivityTaskManagerService() + @Suppress("DEPRECATION") + val recentTasks = XposedHelpers.callMethod( + activityManager, "getRecentTasks", + 100, android.app.ActivityManager.RECENT_IGNORE_UNAVAILABLE, + ) as List<*> + var count = 0 + for ((index, task) in recentTasks.withIndex()) { + if (index == 0 || task == null) continue + try { + val taskId = getRecentTaskId(task) + if (taskId < 0) continue + val removed = XposedHelpers.callMethod(activityTaskManager, "removeTask", taskId) as? Boolean + if (removed != false) count++ + } catch (t: Throwable) { + log("removeTask failed: ${t.message}") + } + } + if (count > 0) { + showToast(context, ModuleRes.getString(R.string.toast_cleared_background, count)) + } + } catch (t: Throwable) { + log("clearBackgroundApps failed: ${t.message}") + } + } + + private fun getActivityTaskManagerService(): Any { + try { + val activityTaskManager = XposedHelpers.findClass( + "android.app.ActivityTaskManager", + ClassLoader.getSystemClassLoader(), + ) + val service = XposedHelpers.callStaticMethod(activityTaskManager, "getService") + if (service != null) return service + } catch (t: Throwable) { + log("ActivityTaskManager.getService failed: ${t.message}") + } + + try { + val service = XposedHelpers.callStaticMethod(android.app.ActivityManager::class.java, "getTaskService") + if (service != null) return service + } catch (t: Throwable) { + log("ActivityManager.getTaskService failed: ${t.message}") + } + + val serviceManager = XposedHelpers.findClass("android.os.ServiceManager", ClassLoader.getSystemClassLoader()) + val binder = XposedHelpers.callStaticMethod(serviceManager, "getService", "activity_task") + val stub = XposedHelpers.findClass( + "android.app.IActivityTaskManager.Stub", + ClassLoader.getSystemClassLoader(), + ) + return XposedHelpers.callStaticMethod(stub, "asInterface", binder) + } + + private fun getRecentTaskId(task: Any): Int { + for (field in listOf("taskId", "persistentId", "id")) { + try { + val id = XposedHelpers.getIntField(task, field) + if (id >= 0) return id + } catch (_: Throwable) { + } + } + return -1 + } + + + private fun launchApp(context: Context, action: String) { + try { + val packageName = action.removePrefix("launch_app:") + if (packageName.isBlank()) return + val intent = context.packageManager.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + showToast(context, ModuleRes.getString(R.string.toast_app_not_found)) + } + } catch (e: Throwable) { + log("launchApp failed: ${e.message}") + } + } + + private fun readConfigValue(context: Context, key: String): String { + val cached = resolveConfig(key) + if (cached.isNotEmpty()) return cached + + val snapshot = HookConfigSnapshot.readFromHookFile() + if (snapshot.containsKey(key)) return snapshot.getValue(key) + + log("Config value missing without Provider fallback: $key") + return "" + } + + private fun performScreenshot(context: Context) { + val errors = mutableListOf() + + if (injectScreenshotChord(context, errors)) return + + try { + val result = GlobalActionHelper.performGlobalAction( + context, + GlobalActionHelper.GLOBAL_ACTION_TAKE_SCREENSHOT, + ) + if (result) return + errors.add("GLOBAL_ACTION_TAKE_SCREENSHOT: false") + } catch (t: Throwable) { + errors.add("GLOBAL_ACTION_TAKE_SCREENSHOT: ${t.message}") + } + + val now = SystemClock.uptimeMillis() + val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SYSRQ, 0) + val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SYSRQ, 0) + + try { + 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, down, 0) + injectMethod.invoke(inputManager, up, 0) + return + } + } catch (t: Throwable) { + errors.add("INPUT_SERVICE: ${t.message}") + } + + 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, down, 0) + injectMethod.invoke(global, up, 0) + return + } catch (t: Throwable) { + errors.add("InputManagerGlobal: ${t.message}") + } + + try { + Runtime.getRuntime().exec("input keyevent 120") + } catch (e: Exception) { + errors.add("shell: ${e.message}") + log("screenshot failed -> ${errors.joinToString(" | ")}") + } + } + + private fun injectScreenshotChord(context: Context, errors: MutableList): Boolean { + val now = SystemClock.uptimeMillis() + val events = arrayOf( + KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_DOWN, 0), + KeyEvent(now, now + 30, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_POWER, 0), + KeyEvent(now, now + 160, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_POWER, 0), + KeyEvent(now, now + 170, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_VOLUME_DOWN, 0), + ) + + return try { + val inputManager = context.getSystemService(Context.INPUT_SERVICE) + if (inputManager != null) { + val injectMethod = inputManager.javaClass.getMethod( + "injectInputEvent", + Class.forName("android.view.InputEvent"), + Int::class.javaPrimitiveType, + ) + events.forEach { event -> + KeyManager.markInjectedEvent(event) + injectMethod.invoke(inputManager, event, 0) + } + true + } else { + errors.add("screenshot chord: INPUT_SERVICE null") + false + } + } catch (t: Throwable) { + errors.add("screenshot chord: ${t.message}") + false + } + } + + private fun injectScrollEvent(context: Context, toTop: Boolean, touchX: Float, touchY: Float) { + var targetX = touchX + var targetY = touchY + if (targetX == 0f && targetY == 0f) { + val metrics = context.resources.displayMetrics + targetX = metrics.widthPixels / 2f + targetY = metrics.heightPixels / 2f + } + + val now = SystemClock.uptimeMillis() + val properties = arrayOf(android.view.MotionEvent.PointerProperties().apply { + id = 0 + toolType = android.view.MotionEvent.TOOL_TYPE_MOUSE + }) + val coords = arrayOf(android.view.MotionEvent.PointerCoords().apply { + x = targetX + y = targetY + setAxisValue(android.view.MotionEvent.AXIS_VSCROLL, if (toTop) 100000.0f else -100000.0f) + }) + val event = android.view.MotionEvent.obtain( + now, now, + android.view.MotionEvent.ACTION_SCROLL, + 1, + properties, coords, + 0, 0, 0f, 0f, 0, 0, + 8194, // InputDevice.SOURCE_MOUSE + 0 + ) + + try { + 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) + return + } + } catch (t: Throwable) { + log("injectScroll INPUT_SERVICE: ${t.message}") + } + + 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) + } catch (t: Throwable) { + log("injectScroll InputManagerGlobal: ${t.message}") + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/GestureManager.kt b/app/src/main/java/com/fan/edgex/hook/GestureManager.kt new file mode 100644 index 0000000..ad808e4 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/GestureManager.kt @@ -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 { + 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 { + 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 + } + +} diff --git a/app/src/main/java/com/fan/edgex/hook/GlobalActionHelper.kt b/app/src/main/java/com/fan/edgex/hook/GlobalActionHelper.kt new file mode 100644 index 0000000..d89ce64 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/GlobalActionHelper.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/HookConfigRepository.kt b/app/src/main/java/com/fan/edgex/hook/HookConfigRepository.kt new file mode 100644 index 0000000..acf9d21 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/HookConfigRepository.kt @@ -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) -> Unit, + private val log: (String) -> Unit, +) { + private val configCache = ConcurrentHashMap() + 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, values: Array, 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/KeyManager.kt b/app/src/main/java/com/fan/edgex/hook/KeyManager.kt new file mode 100644 index 0000000..6522add --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/KeyManager.kt @@ -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() + + // Track press times for timing calculations + private val keyDownTimes = mutableMapOf() + + // 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() + private val pendingUpEvents = mutableMapOf() + + // Track if we consumed the key (should not forward) + private val keyConsumed = mutableMapOf() + + // Track injected events to avoid infinite loop + // We store (downTime, eventTime) pairs of events we injected + private val injectedEventTimes = mutableSetOf() + + // 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() + private val doubleTapRunnables = mutableMapOf() + + // Config cache + private var keysEnabled = false + private val keyEnabled = mutableMapOf() + private val keyActions = mutableMapOf() // "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) { + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/LocalOverlayRuntime.kt b/app/src/main/java/com/fan/edgex/hook/LocalOverlayRuntime.kt new file mode 100644 index 0000000..d15fb2b --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/LocalOverlayRuntime.kt @@ -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() + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/LockscreenActionPolicy.kt b/app/src/main/java/com/fan/edgex/hook/LockscreenActionPolicy.kt new file mode 100644 index 0000000..aa92fe8 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/LockscreenActionPolicy.kt @@ -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 + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/MainHook.kt b/app/src/main/java/com/fan/edgex/hook/MainHook.kt new file mode 100644 index 0000000..c2a3dd3 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/MainHook.kt @@ -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() + } + } + +} diff --git a/app/src/main/java/com/fan/edgex/hook/ModuleRes.kt b/app/src/main/java/com/fan/edgex/hook/ModuleRes.kt new file mode 100644 index 0000000..6d1ae7f --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/ModuleRes.kt @@ -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 } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/NativeTouchHandoff.kt b/app/src/main/java/com/fan/edgex/hook/NativeTouchHandoff.kt new file mode 100644 index 0000000..7338537 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/NativeTouchHandoff.kt @@ -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}") + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/PartialScreenshotOverlay.kt b/app/src/main/java/com/fan/edgex/hook/PartialScreenshotOverlay.kt new file mode 100644 index 0000000..d3ebb57 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/PartialScreenshotOverlay.kt @@ -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? = null + private var wmRef: WeakReference? = 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() + private val redoStack = ArrayDeque() + 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) + } + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/PremiumInstallMetadata.kt b/app/src/main/java/com/fan/edgex/hook/PremiumInstallMetadata.kt new file mode 100644 index 0000000..00a3b4c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/PremiumInstallMetadata.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/fan/edgex/hook/PremiumPluginLoader.kt b/app/src/main/java/com/fan/edgex/hook/PremiumPluginLoader.kt new file mode 100644 index 0000000..b6eb7b1 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/PremiumPluginLoader.kt @@ -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() +} diff --git a/app/src/main/java/com/fan/edgex/hook/PremiumRuntime.kt b/app/src/main/java/com/fan/edgex/hook/PremiumRuntime.kt new file mode 100644 index 0000000..b6b6a8a --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/PremiumRuntime.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/PremiumSignatureVerifier.kt b/app/src/main/java/com/fan/edgex/hook/PremiumSignatureVerifier.kt new file mode 100644 index 0000000..e41d749 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/PremiumSignatureVerifier.kt @@ -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() +} diff --git a/app/src/main/java/com/fan/edgex/hook/ScrollHook.kt b/app/src/main/java/com/fan/edgex/hook/ScrollHook.kt new file mode 100644 index 0000000..954fbe1 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/ScrollHook.kt @@ -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}") + } + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/UniversalCopyManager.kt b/app/src/main/java/com/fan/edgex/hook/UniversalCopyManager.kt new file mode 100644 index 0000000..4469ace --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/UniversalCopyManager.kt @@ -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 = 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 + ) + + /** + * 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 { + val items = mutableListOf() + 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) { + 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): List { + if (items.size <= 1) return items + val seen = LinkedHashSet() + val result = mutableListOf() + 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) + } +} diff --git a/app/src/main/java/com/fan/edgex/hook/XposedInit.kt b/app/src/main/java/com/fan/edgex/hook/XposedInit.kt new file mode 100644 index 0000000..4c3249c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/hook/XposedInit.kt @@ -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) + } +} diff --git a/app/src/main/java/com/fan/edgex/license/DeviceKeystore.kt b/app/src/main/java/com/fan/edgex/license/DeviceKeystore.kt new file mode 100644 index 0000000..b7ba879 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/license/DeviceKeystore.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/fan/edgex/license/KeystoreVerifierService.kt b/app/src/main/java/com/fan/edgex/license/KeystoreVerifierService.kt new file mode 100644 index 0000000..12322d0 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/license/KeystoreVerifierService.kt @@ -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 +} diff --git a/app/src/main/java/com/fan/edgex/license/PremiumActivator.kt b/app/src/main/java/com/fan/edgex/license/PremiumActivator.kt new file mode 100644 index 0000000..e1936a9 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/license/PremiumActivator.kt @@ -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 = 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 = 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 = 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 = 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 { + 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 withApiFallback( + preferredBaseUrl: String? = null, + block: (String) -> T, + ): T = + withApiFallbackWithBase(preferredBaseUrl, block).second + + private inline fun withApiFallbackWithBase( + preferredBaseUrl: String? = null, + block: (String) -> T, + ): Pair { + 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) +} diff --git a/app/src/main/java/com/fan/edgex/overlay/ArcLayoutView.kt b/app/src/main/java/com/fan/edgex/overlay/ArcLayoutView.kt new file mode 100644 index 0000000..413ed40 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/ArcLayoutView.kt @@ -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) + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/DrawerManager.kt b/app/src/main/java/com/fan/edgex/overlay/DrawerManager.kt new file mode 100644 index 0000000..fa7c8a1 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/DrawerManager.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/DrawerWindow.kt b/app/src/main/java/com/fan/edgex/overlay/DrawerWindow.kt new file mode 100644 index 0000000..96fa1ae --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/DrawerWindow.kt @@ -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 = 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, + 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() + 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, + 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() + + 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 { + 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 { + return try { + val configuredPackages = linkedSetOf() + 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() + } + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/EdgeLightingView.kt b/app/src/main/java/com/fan/edgex/overlay/EdgeLightingView.kt new file mode 100644 index 0000000..5b4f85c --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/EdgeLightingView.kt @@ -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 + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/OverlayTheme.kt b/app/src/main/java/com/fan/edgex/overlay/OverlayTheme.kt new file mode 100644 index 0000000..8290d50 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/OverlayTheme.kt @@ -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 +} diff --git a/app/src/main/java/com/fan/edgex/overlay/PanelOverlayManager.kt b/app/src/main/java/com/fan/edgex/overlay/PanelOverlayManager.kt new file mode 100644 index 0000000..7792496 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/PanelOverlayManager.kt @@ -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 { + return when (val currentMode = mode) { + PanelMode.Custom -> { + val items = mutableListOf() + 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): 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, 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/PieManager.kt b/app/src/main/java/com/fan/edgex/overlay/PieManager.kt new file mode 100644 index 0000000..691b1ab --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/PieManager.kt @@ -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, 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 + } + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/PieView.kt b/app/src/main/java/com/fan/edgex/overlay/PieView.kt new file mode 100644 index 0000000..ebb1a8e --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/PieView.kt @@ -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) + + 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 = 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? { + 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) + } +} diff --git a/app/src/main/java/com/fan/edgex/overlay/PieWindow.kt b/app/src/main/java/com/fan/edgex/overlay/PieWindow.kt new file mode 100644 index 0000000..85e2200 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/overlay/PieWindow.kt @@ -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, 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 +} diff --git a/app/src/main/java/com/fan/edgex/premium/PremiumInstall.kt b/app/src/main/java/com/fan/edgex/premium/PremiumInstall.kt new file mode 100644 index 0000000..91b7624 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/premium/PremiumInstall.kt @@ -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 +} diff --git a/app/src/main/java/com/fan/edgex/service/NotificationEdgeService.kt b/app/src/main/java/com/fan/edgex/service/NotificationEdgeService.kt new file mode 100644 index 0000000..71439f0 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/service/NotificationEdgeService.kt @@ -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() + + 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() + 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 { + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/service/NotificationLifecycleManager.kt b/app/src/main/java/com/fan/edgex/service/NotificationLifecycleManager.kt new file mode 100644 index 0000000..9cc7acd --- /dev/null +++ b/app/src/main/java/com/fan/edgex/service/NotificationLifecycleManager.kt @@ -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() + + 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 + ) + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/ActionSelectionActivity.kt b/app/src/main/java/com/fan/edgex/ui/ActionSelectionActivity.kt new file mode 100644 index 0000000..eeb3178 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/ActionSelectionActivity.kt @@ -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) = 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(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(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(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(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(R.id.et_search) + val titleBlock = findViewById(R.id.title_block) + val btnSearch = findViewById(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, + val onClick: (ActionItem) -> Unit, + ) : RecyclerView.Adapter() { + + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/AppIconPickerActivity.kt b/app/src/main/java/com/fan/edgex/ui/AppIconPickerActivity.kt new file mode 100644 index 0000000..0062384 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/AppIconPickerActivity.kt @@ -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() + private val filtered = mutableListOf() + + 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(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(R.id.btn_back).setOnClickListener { finish() } + + val recyclerView = findViewById(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(R.id.btn_from_gallery).setOnClickListener { + galleryLauncher.launch("image/*") + } + + val etSearch = findViewById(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, + private val onClick: (AppEntry) -> Unit, + ) : RecyclerView.Adapter() { + + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/AppSelectionActivity.kt b/app/src/main/java/com/fan/edgex/ui/AppSelectionActivity.kt new file mode 100644 index 0000000..12bbbca --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/AppSelectionActivity.kt @@ -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() + private val displayedApps = mutableListOf() + private lateinit var adapter: AppAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_shortcut_selection) + ThemeManager.applyToActivity(this) + + findViewById(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(R.id.btn_back).setOnClickListener { finish() } + findViewById(R.id.tv_title).setText(R.string.header_app_selection) + + val prefKey = intent.getStringExtra("pref_key") ?: "unknown" + + val recyclerView = findViewById(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(R.id.btn_search) + val etSearch = findViewById(R.id.et_search) + val tvTitle = findViewById(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(R.id.et_search).text.toString()) + } + }.start() + } + + inner class AppAdapter( + private val items: List, + private val onClick: (AppItem) -> Unit, + ) : RecyclerView.Adapter() { + + 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 + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/ColorPickerView.kt b/app/src/main/java/com/fan/edgex/ui/ColorPickerView.kt new file mode 100644 index 0000000..f18c247 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/ColorPickerView.kt @@ -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() + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/ConditionActionActivity.kt b/app/src/main/java/com/fan/edgex/ui/ConditionActionActivity.kt new file mode 100644 index 0000000..d5aa250 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/ConditionActionActivity.kt @@ -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(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(R.id.btn_back).setOnClickListener { finish() } + findViewById(R.id.tv_subtitle).text = title + + // 如果 row + val rowIf = findViewById(R.id.row_condition_if) + rowIf.findViewById(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(R.id.row_condition_then) + rowThen.findViewById(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(R.id.row_condition_else) + rowElse.findViewById(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(R.id.row_condition_if).findViewById(R.id.action_subtitle).text = ifLabel + findViewById(R.id.row_condition_then).findViewById(R.id.action_subtitle).text = thenLabel + findViewById(R.id.row_condition_else).findViewById(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() + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/ConditionPickerActivity.kt b/app/src/main/java/com/fan/edgex/ui/ConditionPickerActivity.kt new file mode 100644 index 0000000..a25360d --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/ConditionPickerActivity.kt @@ -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(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(R.id.btn_back).setOnClickListener { finish() } + findViewById(R.id.tv_subtitle).text = getString(R.string.header_condition_if) + + val condId = intent.getStringExtra(EXTRA_COND_ID) ?: run { finish(); return } + + val recyclerView = findViewById(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, + private val onClick: (ConditionItem) -> Unit, + ) : RecyclerView.Adapter() { + + 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" + } +} diff --git a/app/src/main/java/com/fan/edgex/ui/EdgeLightingAppFilterActivity.kt b/app/src/main/java/com/fan/edgex/ui/EdgeLightingAppFilterActivity.kt new file mode 100644 index 0000000..5feed25 --- /dev/null +++ b/app/src/main/java/com/fan/edgex/ui/EdgeLightingAppFilterActivity.kt @@ -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() + private val displayedApps = mutableListOf() + private val selectedPackages = linkedSetOf() + 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(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(R.id.btn_back).setOnClickListener { finish() } + + selectedPackages.addAll(parsePackageList(getConfigString(AppConfig.EDGE_LIGHTING_APP_LIST))) + setupAppList() + loadApps() + } + + private fun setupAppList() { + val recyclerView = findViewById(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(R.id.et_edge_lighting_app_search).addTextChangedListener { + filterApps(it.toString()) + } + findViewById