From 2f9b7bb6b61c6467d829bccaa743975f55a96135 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 1 May 2026 03:40:54 +0000 Subject: [PATCH 1/4] Add AGENTS.md with Cursor Cloud development instructions Co-authored-by: gaoyang2024 --- AGENTS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..29b38e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +### Project overview + +WiTV is an Android TV IPTV player (Java, Gradle). There is no backend service, database, or Node.js tooling — it is a single-module Android app built entirely with Gradle. + +### Environment + +- **JDK 17** is required (`JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`). The VM ships with JDK 21 by default; the update script installs JDK 17 and configures `~/.bashrc`. +- **Android SDK** is installed at `/opt/android-sdk` with platform API 36 and build-tools 36.0.0. `ANDROID_HOME` and `ANDROID_SDK_ROOT` are set via `~/.bashrc`. +- The Gradle wrapper (`./gradlew`) auto-downloads Gradle 8.9; no manual Gradle install is needed. + +### Key commands (see README for full details) + +| Task | Command | +|------|---------| +| Unit tests | `./gradlew testDebugUnitTest --no-daemon` | +| Debug APK build | `./gradlew assembleDebug --no-daemon` | +| Release APK build | `./gradlew assembleRelease --no-daemon` | + +### Gotchas + +- `./gradlew lintDebug` currently fails due to pre-existing `NewApi` lint errors in the codebase. Lint is **not** part of CI — CI only runs `testDebugUnitTest` + `assembleDebug`. +- The app uses `compileSdk 36` which triggers a suppressible warning via `android.suppressUnsupportedCompileSdk=36` in `gradle.properties`. +- Room annotation processor produces a warning about multiple constructors on `FavoriteChannel.java` — this is harmless. +- This is an Android TV app with no web frontend build step; the files in `app/src/main/assets/web/` are plain HTML/JS served by the embedded NanoHTTPD server at runtime. +- There is no Android emulator on the cloud VM, so the APK cannot be run directly. Testing is limited to unit tests and build verification. From 6b4c9b23ba0e8ab0696f4e19b92b45ef3be15ad4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 1 May 2026 06:21:13 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E7=84=A6=E7=82=B9=E5=88=87=E6=8D=A2=E7=9A=84=206=20=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: FocusFinder 返回 null 时焦点卡死 — 增加 handleFocusBoundary 回退逻辑 P0: 主菜单按左键无法进入已展开子菜单 — 增加 DPAD_LEFT 显式处理与 focusSubmenuFirstItem P1: 频道列表自动隐藏后焦点丢失 — hideChannelListIdleRunnable 和 hideOverlay 中恢复焦点 P1: 频道列表按返回键直接弹退出对话框 — BACK 键先关闭频道列表 P2: 焦点路过主菜单项导致子菜单频繁重建 — onMainMenuItemFocused 增加 150ms 防抖 P3: ACTION_UP 事件泄漏 — 增加 onKeyUp 拦截 + dispatchDrawerKey 同时转发 ACTION_UP Co-authored-by: gaoyang2024 --- .../com/whyun/witv/ui/PlayerActivity.java | 37 ++++++++ .../witv/ui/SettingsCollapsibleFragment.java | 86 ++++++++++++++++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java index 2c3691a..8a85f0b 100644 --- a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java +++ b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java @@ -117,6 +117,9 @@ public class PlayerActivity extends FragmentActivity implements PlayerManager.Ca if (channelListPanel != null) { channelListPanel.setVisibility(View.GONE); } + if (playerView != null) { + playerView.requestFocus(); + } }; private final Runnable loadSpeedRefreshRunnable = new Runnable() { @@ -683,6 +686,9 @@ private void hideOverlay() { channelListPanel.setVisibility(View.GONE); } overlayVisible = false; + if (playerView != null) { + playerView.requestFocus(); + } } private boolean isChannelListPanelVisible() { @@ -1203,6 +1209,14 @@ && isDescendantOf(focused, channelListOverlay)) { } break; case KeyEvent.KEYCODE_BACK: + if (isChannelListPanelVisible()) { + cancelChannelListIdleHide(); + channelListPanel.setVisibility(View.GONE); + if (playerView != null) { + playerView.requestFocus(); + } + return true; + } showExitDialog(); return true; case KeyEvent.KEYCODE_INFO: @@ -1241,6 +1255,21 @@ && isDescendantOf(focused, channelListOverlay)) { return super.onKeyDown(keyCode, event); } + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (isSettingsPanelVisible()) { + if (keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_ENTER) { + return true; + } + } + return super.onKeyUp(keyCode, event); + } + @Override public void onBackPressed() { if (isSettingsPanelVisible()) { @@ -1252,6 +1281,14 @@ public void onBackPressed() { hideSettingsPanel(); return; } + if (isChannelListPanelVisible()) { + cancelChannelListIdleHide(); + channelListPanel.setVisibility(View.GONE); + if (playerView != null) { + playerView.requestFocus(); + } + return; + } showExitDialog(); } diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java index c4ae11f..735f2ad 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java @@ -34,6 +34,9 @@ import com.whyun.witv.data.repository.EpgRepository; import com.whyun.witv.player.PlayerManager; +import android.os.Handler; +import android.os.Looper; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -75,6 +78,10 @@ public class SettingsCollapsibleFragment extends Fragment private int openCategory = 0; private int lastOpenedCategory = CAT_ADDRESS; + private final Handler focusHandler = new Handler(Looper.getMainLooper()); + private Runnable pendingSubmenuOpen; + private static final long SUBMENU_OPEN_DEBOUNCE_MS = 150; + @Override public void onAttach(@NonNull Context context) { super.onAttach(context); @@ -145,6 +152,9 @@ public void onResume() { @Override public void onDestroy() { super.onDestroy(); + if (pendingSubmenuOpen != null) { + focusHandler.removeCallbacks(pendingSubmenuOpen); + } executor.shutdown(); } @@ -186,6 +196,8 @@ public void dispatchDrawerKey(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { focused.dispatchKeyEvent(event); + KeyEvent upEvent = new KeyEvent(KeyEvent.ACTION_UP, keyCode); + focused.dispatchKeyEvent(upEvent); return; } @@ -200,6 +212,17 @@ && isDescendant(submenuContainer, focused)) { return; } + // 右侧主菜单按左键:如果子菜单已展开,显式将焦点移入子菜单首项 + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT + && openCategory != 0 + && submenuContainer != null + && submenuContainer.getVisibility() == View.VISIBLE + && focused != null + && isDescendant(mainMenuRecycler, focused)) { + focusSubmenuFirstItem(); + return; + } + int direction = keyCodeToFocusDirection(keyCode); if (direction == 0) { return; @@ -207,9 +230,49 @@ && isDescendant(submenuContainer, focused)) { View next = FocusFinder.getInstance().findNextFocus((ViewGroup) panel, focused, direction); if (next != null) { next.requestFocus(); + } else { + handleFocusBoundary(keyCode, focused); } } + /** + * FocusFinder 返回 null 时的边界回退:在列表首尾项循环焦点, + * 或在主菜单/子菜单之间显式切换。 + */ + private void handleFocusBoundary(int keyCode, View focused) { + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && isDescendant(mainMenuRecycler, focused)) { + if (openCategory != 0 && submenuContainer != null + && submenuContainer.getVisibility() == View.VISIBLE) { + focusSubmenuFirstItem(); + } + } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && isDescendant(submenuContainer, focused)) { + focusMainMenuAtCategory(lastOpenedCategory); + } + } + + private void focusSubmenuFirstItem() { + if (submenuRecycler == null) { + return; + } + submenuRecycler.post(() -> { + if (submenuRecycler.getChildCount() > 0) { + View first = submenuRecycler.getChildAt(0); + if (first.isFocusable()) { + first.requestFocus(); + } else { + // 某些子菜单行(如 hint)不可聚焦,向下找第一个可聚焦项 + for (int i = 1; i < submenuRecycler.getChildCount(); i++) { + View child = submenuRecycler.getChildAt(i); + if (child.isFocusable()) { + child.requestFocus(); + return; + } + } + } + } + }); + } + private static int keyCodeToFocusDirection(int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: @@ -245,6 +308,10 @@ private static boolean isDescendant(View ancestor, View descendant) { /** 关闭整条设置抽屉时收起左侧子菜单,避免下次打开仍停在子层 */ public void onSettingsDrawerDismiss() { + if (pendingSubmenuOpen != null) { + focusHandler.removeCallbacks(pendingSubmenuOpen); + pendingSubmenuOpen = null; + } openCategory = 0; if (submenuContainer != null && mainMenuRecycler != null && menuRow != null) { submenuContainer.setVisibility(View.GONE); @@ -529,7 +596,13 @@ private static String getDeviceIp(Context context) { @Override public void onMainMenuItemClick(int categoryId) { + // 用户明确按确认键进入子菜单,取消防抖中的待执行展开 + if (pendingSubmenuOpen != null) { + focusHandler.removeCallbacks(pendingSubmenuOpen); + pendingSubmenuOpen = null; + } if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) { + focusSubmenuFirstItem(); return; } openSubmenu(categoryId, true); @@ -538,10 +611,19 @@ public void onMainMenuItemClick(int categoryId) { @Override public void onMainMenuItemFocused(int categoryId) { if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) { - rebuildSubmenuIfOpen(); return; } - openSubmenu(categoryId, false); + // 防抖:快速切换焦点时不立即重建子菜单,只在焦点稳定后展开 + if (pendingSubmenuOpen != null) { + focusHandler.removeCallbacks(pendingSubmenuOpen); + } + pendingSubmenuOpen = () -> { + if (!isAdded()) { + return; + } + openSubmenu(categoryId, false); + }; + focusHandler.postDelayed(pendingSubmenuOpen, SUBMENU_OPEN_DEBOUNCE_MS); } private void showHelpDialog() { From 9406dce3061fb8bc4d734a7af3058b43e5cb1a1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 00:55:15 +0000 Subject: [PATCH 3/4] Remove unreachable settings focus fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 白一梓 --- .../witv/ui/SettingsCollapsibleFragment.java | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java index 735f2ad..d74cb73 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java @@ -206,7 +206,6 @@ public void dispatchDrawerKey(int keyCode, KeyEvent event) { && openCategory != 0 && submenuContainer != null && submenuContainer.getVisibility() == View.VISIBLE - && focused != null && isDescendant(submenuContainer, focused)) { focusMainMenuAtCategory(lastOpenedCategory); return; @@ -217,7 +216,6 @@ && isDescendant(submenuContainer, focused)) { && openCategory != 0 && submenuContainer != null && submenuContainer.getVisibility() == View.VISIBLE - && focused != null && isDescendant(mainMenuRecycler, focused)) { focusSubmenuFirstItem(); return; @@ -230,23 +228,6 @@ && isDescendant(mainMenuRecycler, focused)) { View next = FocusFinder.getInstance().findNextFocus((ViewGroup) panel, focused, direction); if (next != null) { next.requestFocus(); - } else { - handleFocusBoundary(keyCode, focused); - } - } - - /** - * FocusFinder 返回 null 时的边界回退:在列表首尾项循环焦点, - * 或在主菜单/子菜单之间显式切换。 - */ - private void handleFocusBoundary(int keyCode, View focused) { - if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && isDescendant(mainMenuRecycler, focused)) { - if (openCategory != 0 && submenuContainer != null - && submenuContainer.getVisibility() == View.VISIBLE) { - focusSubmenuFirstItem(); - } - } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && isDescendant(submenuContainer, focused)) { - focusMainMenuAtCategory(lastOpenedCategory); } } From 7fbb88f4b52dfe7bb2a98d3a04c72c870af4ea1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 00:56:47 +0000 Subject: [PATCH 4/4] Fix stale settings submenu focus debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 白一梓 --- .../witv/ui/SettingsCollapsibleFragment.java | 26 +++++----- .../ui/SettingsCollapsibleFragmentTest.java | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java index d74cb73..c851ba4 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java @@ -152,9 +152,7 @@ public void onResume() { @Override public void onDestroy() { super.onDestroy(); - if (pendingSubmenuOpen != null) { - focusHandler.removeCallbacks(pendingSubmenuOpen); - } + cancelPendingSubmenuOpen(); executor.shutdown(); } @@ -289,10 +287,7 @@ private static boolean isDescendant(View ancestor, View descendant) { /** 关闭整条设置抽屉时收起左侧子菜单,避免下次打开仍停在子层 */ public void onSettingsDrawerDismiss() { - if (pendingSubmenuOpen != null) { - focusHandler.removeCallbacks(pendingSubmenuOpen); - pendingSubmenuOpen = null; - } + cancelPendingSubmenuOpen(); openCategory = 0; if (submenuContainer != null && mainMenuRecycler != null && menuRow != null) { submenuContainer.setVisibility(View.GONE); @@ -578,10 +573,7 @@ private static String getDeviceIp(Context context) { @Override public void onMainMenuItemClick(int categoryId) { // 用户明确按确认键进入子菜单,取消防抖中的待执行展开 - if (pendingSubmenuOpen != null) { - focusHandler.removeCallbacks(pendingSubmenuOpen); - pendingSubmenuOpen = null; - } + cancelPendingSubmenuOpen(); if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) { focusSubmenuFirstItem(); return; @@ -592,12 +584,11 @@ public void onMainMenuItemClick(int categoryId) { @Override public void onMainMenuItemFocused(int categoryId) { if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) { + cancelPendingSubmenuOpen(); return; } // 防抖:快速切换焦点时不立即重建子菜单,只在焦点稳定后展开 - if (pendingSubmenuOpen != null) { - focusHandler.removeCallbacks(pendingSubmenuOpen); - } + cancelPendingSubmenuOpen(); pendingSubmenuOpen = () -> { if (!isAdded()) { return; @@ -607,6 +598,13 @@ public void onMainMenuItemFocused(int categoryId) { focusHandler.postDelayed(pendingSubmenuOpen, SUBMENU_OPEN_DEBOUNCE_MS); } + private void cancelPendingSubmenuOpen() { + if (pendingSubmenuOpen != null) { + focusHandler.removeCallbacks(pendingSubmenuOpen); + pendingSubmenuOpen = null; + } + } + private void showHelpDialog() { Context ctx = requireContext(); float density = ctx.getResources().getDisplayMetrics().density; diff --git a/app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java b/app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java new file mode 100644 index 0000000..359b6f2 --- /dev/null +++ b/app/src/test/java/com/whyun/witv/ui/SettingsCollapsibleFragmentTest.java @@ -0,0 +1,50 @@ +package com.whyun.witv.ui; + +import android.view.View; +import android.widget.FrameLayout; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLooper; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@RunWith(RobolectricTestRunner.class) +public class SettingsCollapsibleFragmentTest { + + @Test + public void onMainMenuItemFocusedCancelsPendingOpenWhenReturningToOpenCategory() + throws Exception { + SettingsCollapsibleFragment fragment = new SettingsCollapsibleFragment(); + FrameLayout submenuContainer = new FrameLayout( + androidx.test.core.app.ApplicationProvider.getApplicationContext()); + submenuContainer.setVisibility(View.VISIBLE); + + setField(fragment, "submenuContainer", submenuContainer); + setField(fragment, "openCategory", SettingsCollapsibleFragment.CAT_ADDRESS); + + fragment.onMainMenuItemFocused(SettingsCollapsibleFragment.CAT_EPG); + fragment.onMainMenuItemFocused(SettingsCollapsibleFragment.CAT_ADDRESS); + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + assertNull(getField(fragment, "pendingSubmenuOpen")); + assertEquals(SettingsCollapsibleFragment.CAT_ADDRESS, getField(fragment, "openCategory")); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } +}