diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index ba3e08d..ace0bed 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -12,6 +12,7 @@ import android.view.ViewGroup; import android.view.ViewParent; import android.widget.Button; import android.widget.EditText; +import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.ScrollView; @@ -20,6 +21,7 @@ import android.widget.Toast; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.viewpager2.widget.ViewPager2; @@ -65,6 +67,7 @@ public class MainActivity extends AppCompatActivity { private Button tabMe; private ViewPager2 rootPager; private SwipeRefreshLayout screenRefresh; + private RecyclerView screenList; private ScrollView screenScroll; private LinearLayout screenContent; @@ -461,7 +464,7 @@ public class MainActivity extends AppCompatActivity { switch (activeTab) { case "devices": - updateHeader("设备", "这里管理已接入设备与账号状态。"); + updateHeader("设备", ""); configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); renderDevicesRoot(); break; @@ -533,17 +536,18 @@ public class MainActivity extends AppCompatActivity { } private void renderConversationsRoot() { - if (screenContent == null) { + if (screenList == null) { return; } - screenContent.removeAllViews(); - screenContent.addView(buildConversationSearchInput()); + List items = new ArrayList<>(); + items.add(() -> buildConversationSearchInput()); if (conversationSelectionMode) { - appendConversationSelectionControls(); + appendConversationSelectionControls(items); } JSONArray filteredConversations = filterConversationItems(conversationsData, conversationSearchQuery); if (filteredConversations == null || filteredConversations.length() == 0) { - screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。")); + items.add(() -> BossUi.buildEmptyCard(this, "当前没有会话数据。")); + showListPage(items); return; } @@ -560,7 +564,7 @@ public class MainActivity extends AppCompatActivity { } if (pinnedItems.length() > 0) { - screenContent.addView(BossUi.buildConversationSectionHeader( + items.add(() -> BossUi.buildConversationSectionHeader( this, "置顶会话", pinnedConversationsCollapsed ? "展开" : "收起", @@ -570,13 +574,14 @@ public class MainActivity extends AppCompatActivity { } )); if (!pinnedConversationsCollapsed) { - appendConversationRows(pinnedItems); + appendConversationRows(items, pinnedItems); } } - appendConversationRows(regularItems); + appendConversationRows(items, regularItems); + showListPage(items); } - private void appendConversationRows(JSONArray items) { + private void appendConversationRows(List rootItems, JSONArray items) { for (int i = 0; i < items.length(); i++) { JSONObject item = items.optJSONObject(i); if (item == null) continue; @@ -584,7 +589,7 @@ public class MainActivity extends AppCompatActivity { String conversationType = item.optString("conversationType", ""); String folderKey = item.optString("folderKey", ""); WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); - screenContent.addView(BossUi.buildConversationRow( + rootItems.add(() -> BossUi.buildConversationRow( this, row, conversationSelectionMode, @@ -620,8 +625,8 @@ public class MainActivity extends AppCompatActivity { } } - private void appendConversationSelectionControls() { - screenContent.addView(buildSelectionSummaryView()); + private void appendConversationSelectionControls(List items) { + items.add(() -> buildSelectionSummaryView()); Button cancelButton = BossUi.buildMiniActionButton(this, "取消", false); cancelButton.setOnClickListener(v -> exitConversationSelectionMode()); @@ -630,7 +635,7 @@ public class MainActivity extends AppCompatActivity { createButton.setEnabled(selectedConversationProjectIds.size() >= 2); createButton.setOnClickListener(v -> createStandaloneGroupChatFromSelection()); - screenContent.addView(BossUi.buildInlineActionRow(this, cancelButton, createButton)); + items.add(() -> BossUi.buildInlineActionRow(this, cancelButton, createButton)); } private LinearLayout buildSelectionSummaryView() { @@ -826,12 +831,13 @@ public class MainActivity extends AppCompatActivity { } private void renderDevicesRoot() { - if (screenContent == null) { + if (screenList == null) { return; } - screenContent.removeAllViews(); + List items = new ArrayList<>(); if (devicesData == null || devicesData.length() == 0) { - screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。")); + items.add(() -> BossUi.buildEmptyCard(this, "当前没有接入设备。")); + showListPage(items); return; } @@ -840,7 +846,7 @@ public class MainActivity extends AppCompatActivity { if (item == null) continue; String deviceId = item.optString("id", ""); WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item); - screenContent.addView(BossUi.buildDeviceCard( + items.add(() -> BossUi.buildDeviceCard( this, row, v -> { @@ -853,12 +859,14 @@ public class MainActivity extends AppCompatActivity { null )); } + showListPage(items); } private void renderMeRoot() { if (screenContent == null) { return; } + showScrollPage(); screenContent.removeAllViews(); String displayName = sessionData == null ? apiClient.getDisplayName() @@ -1117,6 +1125,7 @@ public class MainActivity extends AppCompatActivity { private void syncActivePageViews(String tab) { if (rootPagerAdapter == null) { screenRefresh = null; + screenList = null; screenScroll = null; screenContent = null; return; @@ -1124,12 +1133,14 @@ public class MainActivity extends AppCompatActivity { RootPageViewHolder holder = rootPagerAdapter.findHolder(tab); if (holder == null) { screenRefresh = null; + screenList = null; screenScroll = null; screenContent = null; rootPager.post(() -> { RootPageViewHolder pendingHolder = rootPagerAdapter.findHolder(activeTab); if (pendingHolder != null) { screenRefresh = pendingHolder.refresh; + screenList = pendingHolder.list; screenScroll = pendingHolder.scroll; screenContent = pendingHolder.content; renderCurrentTab(); @@ -1139,10 +1150,43 @@ public class MainActivity extends AppCompatActivity { return; } screenRefresh = holder.refresh; + screenList = holder.list; screenScroll = holder.scroll; screenContent = holder.content; } + private void showListPage(List items) { + if (screenList == null || screenScroll == null) { + return; + } + screenList.setVisibility(View.VISIBLE); + screenScroll.setVisibility(View.GONE); + RootListAdapter adapter = adapterForCurrentTab(); + if (adapter != null) { + adapter.submit(items); + } + } + + private void showScrollPage() { + if (screenList == null || screenScroll == null) { + return; + } + RootListAdapter adapter = adapterForCurrentTab(); + if (adapter != null) { + adapter.submit(new ArrayList<>()); + } + screenList.setVisibility(View.GONE); + screenScroll.setVisibility(View.VISIBLE); + } + + private @Nullable RootListAdapter adapterForCurrentTab() { + if (rootPagerAdapter == null) { + return null; + } + RootPageViewHolder holder = rootPagerAdapter.findHolder(activeTab); + return holder == null ? null : holder.listAdapter; + } + private static int indexForTab(String tab) { switch (tab) { case "devices": @@ -1224,8 +1268,10 @@ public class MainActivity extends AppCompatActivity { private static final class RootPageViewHolder extends RecyclerView.ViewHolder { final String tab; final SwipeRefreshLayout refresh; + final RecyclerView list; final ScrollView scroll; final LinearLayout content; + final RootListAdapter listAdapter; static RootPageViewHolder create(LayoutInflater inflater, ViewGroup parent, String tab) { View view = inflater.inflate(R.layout.view_root_tab_page, parent, false); @@ -1236,8 +1282,61 @@ public class MainActivity extends AppCompatActivity { super(itemView); this.tab = tab; this.refresh = itemView.findViewById(R.id.root_page_refresh); + this.list = itemView.findViewById(R.id.root_page_list); this.scroll = itemView.findViewById(R.id.root_page_scroll); this.content = itemView.findViewById(R.id.root_page_content); + this.list.setLayoutManager(new LinearLayoutManager(itemView.getContext())); + this.listAdapter = new RootListAdapter(); + this.list.setAdapter(listAdapter); + } + } + + private interface RootListItem { + View build(); + } + + private static final class RootListViewHolder extends RecyclerView.ViewHolder { + final FrameLayout container; + + RootListViewHolder(FrameLayout container) { + super(container); + this.container = container; + } + } + + private static final class RootListAdapter extends RecyclerView.Adapter { + private List items = new ArrayList<>(); + + @Override + public int getItemCount() { + return items.size(); + } + + @Override + public RootListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + FrameLayout container = new FrameLayout(parent.getContext()); + RecyclerView.LayoutParams params = new RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT + ); + container.setLayoutParams(params); + return new RootListViewHolder(container); + } + + @Override + public void onBindViewHolder(RootListViewHolder holder, int position) { + holder.container.removeAllViews(); + View child = items.get(position).build(); + ViewParent parent = child.getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup) parent).removeView(child); + } + holder.container.addView(child); + } + + void submit(List nextItems) { + items = new ArrayList<>(nextItems); + notifyDataSetChanged(); } } } diff --git a/android/app/src/main/res/layout/view_root_tab_page.xml b/android/app/src/main/res/layout/view_root_tab_page.xml index 827c6d3..7fd9dd6 100644 --- a/android/app/src/main/res/layout/view_root_tab_page.xml +++ b/android/app/src/main/res/layout/view_root_tab_page.xml @@ -4,21 +4,37 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + android:layout_height="match_parent"> - - + android:paddingBottom="88dp" + android:scrollbars="vertical" /> + + + + + + diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java index 621fc4d..a02cfe9 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java @@ -3,8 +3,11 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import android.view.View; import android.widget.EditText; -import android.widget.LinearLayout; +import android.widget.FrameLayout; + +import androidx.recyclerview.widget.RecyclerView; import org.json.JSONArray; import org.json.JSONObject; @@ -58,9 +61,19 @@ public class MainActivityConversationSearchTest { ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot"); Shadows.shadowOf(activity.getMainLooper()).idle(); - LinearLayout content = ReflectionHelpers.getField(activity, "screenContent"); - assertTrue(content.getChildAt(0) instanceof EditText); - EditText input = (EditText) content.getChildAt(0); + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + View firstItem = buildRecyclerItem(list, 0); + assertTrue(firstItem instanceof EditText); + EditText input = (EditText) firstItem; assertEquals("搜索项目或线程", String.valueOf(input.getHint())); } + + private static View buildRecyclerItem(RecyclerView recyclerView, int position) { + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + int viewType = adapter.getItemViewType(position); + RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType); + adapter.bindViewHolder(holder, position); + FrameLayout container = (FrameLayout) holder.itemView; + return container.getChildAt(0); + } } diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java index 24524e3..22c3bde 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java @@ -5,9 +5,12 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import android.view.View; +import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; + import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; @@ -31,21 +34,19 @@ public class MainActivityConversationSelectionTest { ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode"); assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode")); - LinearLayout content = ReflectionHelpers.getField(activity, "screenContent"); - assertTrue(viewTreeContainsText(content, "发起群聊")); - assertTrue(viewTreeContainsText(content, "至少选择 2 个线程")); + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + assertTrue(recyclerContainsText(list, "发起群聊")); + assertTrue(recyclerContainsText(list, "至少选择 2 个线程")); ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection", ReflectionHelpers.ClassParameter.from(String.class, "thread-1")); - content = ReflectionHelpers.getField(activity, "screenContent"); - assertTrue(viewTreeContainsText(content, "已选 1 个线程")); - assertTrue(viewTreeContainsText(content, "至少选择 2 个线程")); + assertTrue(recyclerContainsText(list, "已选 1 个线程")); + assertTrue(recyclerContainsText(list, "至少选择 2 个线程")); ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection", ReflectionHelpers.ClassParameter.from(String.class, "thread-2")); - content = ReflectionHelpers.getField(activity, "screenContent"); - assertTrue(viewTreeContainsText(content, "已选 2 个线程")); - assertFalse(viewTreeContainsText(content, "至少选择 2 个线程")); + assertTrue(recyclerContainsText(list, "已选 2 个线程")); + assertFalse(recyclerContainsText(list, "至少选择 2 个线程")); } @Test @@ -56,11 +57,30 @@ public class MainActivityConversationSelectionTest { Shadows.shadowOf(activity.getMainLooper()).idle(); ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode"); - LinearLayout content = ReflectionHelpers.getField(activity, "screenContent"); - View row = content.getChildAt(3); + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + View row = getRecyclerChild(list, 3); assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话")); } + private static View getRecyclerChild(RecyclerView recyclerView, int position) { + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + int viewType = adapter.getItemViewType(position); + RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType); + adapter.bindViewHolder(holder, position); + FrameLayout container = (FrameLayout) holder.itemView; + return container.getChildAt(0); + } + + private static boolean recyclerContainsText(RecyclerView recyclerView, String expectedText) { + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + for (int index = 0; index < adapter.getItemCount(); index += 1) { + if (viewTreeContainsText(getRecyclerChild(recyclerView, index), expectedText)) { + return true; + } + } + return false; + } + private static JSONArray buildConversations() throws Exception { return new JSONArray() .put(new JSONObject() diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityDevicesRootTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityDevicesRootTest.java new file mode 100644 index 0000000..bf90186 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityDevicesRootTest.java @@ -0,0 +1,57 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.view.View; +import android.view.View.MeasureSpec; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.Shadows; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class MainActivityDevicesRootTest { + @Test + public void devicesTab_hidesLegacySubtitleAndUsesRecyclerList() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "devicesData", new JSONArray() + .put(new JSONObject() + .put("id", "mac-studio") + .put("name", "Mac Studio") + .put("status", "online") + .put("platform", "macOS") + .put("account", "17600003315"))); + + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod(activity, "setActiveTab", + ReflectionHelpers.ClassParameter.from(String.class, "devices"), + ReflectionHelpers.ClassParameter.from(boolean.class, false)); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + TextView subtitle = activity.findViewById(R.id.top_subtitle); + assertEquals(View.GONE, subtitle.getVisibility()); + + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + layoutRecyclerView(list); + assertTrue(list.getVisibility() == View.VISIBLE); + assertTrue(list.getAdapter().getItemCount() > 0); + } + + private static void layoutRecyclerView(RecyclerView recyclerView) { + int widthSpec = MeasureSpec.makeMeasureSpec(1080, MeasureSpec.EXACTLY); + int heightSpec = MeasureSpec.makeMeasureSpec(2400, MeasureSpec.EXACTLY); + recyclerView.measure(widthSpec, heightSpec); + recyclerView.layout(0, 0, 1080, 2400); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java index e0f685e..bb6aa9c 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java @@ -4,9 +4,12 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import android.view.View; +import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; + import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; @@ -45,18 +48,37 @@ public class MainActivityPinnedConversationsTest { ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot"); Shadows.shadowOf(activity.getMainLooper()).idle(); - LinearLayout content = ReflectionHelpers.getField(activity, "screenContent"); - assertTrue(viewTreeContainsText(content, "置顶会话")); - assertTrue(viewTreeContainsText(content, "收起")); - assertTrue(viewTreeContainsText(content, "主 Agent")); - assertTrue(viewTreeContainsText(content, "Boss 移动控制台")); + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + assertTrue(recyclerContainsText(list, "置顶会话")); + assertTrue(recyclerContainsText(list, "收起")); + assertTrue(recyclerContainsText(list, "主 Agent")); + assertTrue(recyclerContainsText(list, "Boss 移动控制台")); - View pinnedHeader = content.getChildAt(1); + View pinnedHeader = getRecyclerChild(list, 1); pinnedHeader.performClick(); assertEquals(true, ReflectionHelpers.getField(activity, "pinnedConversationsCollapsed")); - assertTrue(viewTreeContainsText(content, "展开")); - assertTrue("收起后普通会话仍应保留", viewTreeContainsText(content, "Boss 移动控制台")); + assertTrue(recyclerContainsText(list, "展开")); + assertTrue("收起后普通会话仍应保留", recyclerContainsText(list, "Boss 移动控制台")); + } + + private static View getRecyclerChild(RecyclerView recyclerView, int position) { + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + int viewType = adapter.getItemViewType(position); + RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType); + adapter.bindViewHolder(holder, position); + FrameLayout container = (FrameLayout) holder.itemView; + return container.getChildAt(0); + } + + private static boolean recyclerContainsText(RecyclerView recyclerView, String expectedText) { + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + for (int index = 0; index < adapter.getItemCount(); index += 1) { + if (viewTreeContainsText(getRecyclerChild(recyclerView, index), expectedText)) { + return true; + } + } + return false; } private static boolean viewTreeContainsText(View root, String expectedText) {