Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions app/src/main/java/com/whyun/witv/ui/PlayerActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -683,6 +686,9 @@ private void hideOverlay() {
channelListPanel.setVisibility(View.GONE);
}
overlayVisible = false;
if (playerView != null) {
playerView.requestFocus();
}
}

private boolean isChannelListPanelVisible() {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()) {
Expand All @@ -1252,6 +1281,14 @@ public void onBackPressed() {
hideSettingsPanel();
return;
}
if (isChannelListPanelVisible()) {
cancelChannelListIdleHide();
channelListPanel.setVisibility(View.GONE);
if (playerView != null) {
playerView.requestFocus();
}
return;
}
showExitDialog();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -145,6 +152,7 @@ public void onResume() {
@Override
public void onDestroy() {
super.onDestroy();
cancelPendingSubmenuOpen();
executor.shutdown();
}

Expand Down Expand Up @@ -186,6 +194,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;
}

Expand All @@ -194,12 +204,21 @@ public void dispatchDrawerKey(int keyCode, KeyEvent event) {
&& openCategory != 0
&& submenuContainer != null
&& submenuContainer.getVisibility() == View.VISIBLE
&& focused != null
&& isDescendant(submenuContainer, focused)) {
focusMainMenuAtCategory(lastOpenedCategory);
return;
}

// 右侧主菜单按左键:如果子菜单已展开,显式将焦点移入子菜单首项
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
&& openCategory != 0
&& submenuContainer != null
&& submenuContainer.getVisibility() == View.VISIBLE
&& isDescendant(mainMenuRecycler, focused)) {
focusSubmenuFirstItem();
return;
}

int direction = keyCodeToFocusDirection(keyCode);
if (direction == 0) {
return;
Expand All @@ -210,6 +229,29 @@ && isDescendant(submenuContainer, focused)) {
}
}
Comment thread
yunnysunny marked this conversation as resolved.

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:
Expand Down Expand Up @@ -245,6 +287,7 @@ private static boolean isDescendant(View ancestor, View descendant) {

/** 关闭整条设置抽屉时收起左侧子菜单,避免下次打开仍停在子层 */
public void onSettingsDrawerDismiss() {
cancelPendingSubmenuOpen();
openCategory = 0;
if (submenuContainer != null && mainMenuRecycler != null && menuRow != null) {
submenuContainer.setVisibility(View.GONE);
Expand Down Expand Up @@ -529,7 +572,10 @@ private static String getDeviceIp(Context context) {

@Override
public void onMainMenuItemClick(int categoryId) {
// 用户明确按确认键进入子菜单,取消防抖中的待执行展开
cancelPendingSubmenuOpen();
if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) {
focusSubmenuFirstItem();
return;
}
openSubmenu(categoryId, true);
Expand All @@ -538,10 +584,25 @@ public void onMainMenuItemClick(int categoryId) {
@Override
public void onMainMenuItemFocused(int categoryId) {
if (openCategory == categoryId && submenuContainer.getVisibility() == View.VISIBLE) {
rebuildSubmenuIfOpen();
cancelPendingSubmenuOpen();
return;
}
openSubmenu(categoryId, false);
// 防抖:快速切换焦点时不立即重建子菜单,只在焦点稳定后展开
cancelPendingSubmenuOpen();
pendingSubmenuOpen = () -> {
if (!isAdded()) {
return;
}
openSubmenu(categoryId, false);
};
focusHandler.postDelayed(pendingSubmenuOpen, SUBMENU_OPEN_DEBOUNCE_MS);
Comment thread
yunnysunny marked this conversation as resolved.
}

private void cancelPendingSubmenuOpen() {
if (pendingSubmenuOpen != null) {
focusHandler.removeCallbacks(pendingSubmenuOpen);
pendingSubmenuOpen = null;
}
}

private void showHelpDialog() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading