From 2dc85b706aacec4a51958903fa022ab2f0964b24 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:41:34 +0000 Subject: [PATCH 01/14] feat: add per-profile URL test button next to edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在节点卡片 edit 按钮旁新增测速按钮,点击直接用 UrlTest().doTest() 测当前节点延迟,状态码与全量 URL Test 一致(1 成功/2 插件缺失/3 失败),完成后只刷新该行;双列与选择模式下跟随 edit 隐藏。 --- .../sagernet/ui/ConfigurationFragment.kt | 50 +++++++++++++++++++ app/src/main/res/layout/layout_profile.xml | 11 ++++ 2 files changed, 61 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a6734cd9fa..d9921ed414 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2194,6 +2194,7 @@ class ConfigurationFragment @JvmOverloads constructor( private val card = view as MaterialCardView private val selectedIndicator: View = view.findViewById(R.id.selected_indicator) val editButton: ImageView = view.findViewById(R.id.edit) + val urlTestButton: ImageView = view.findViewById(R.id.url_test) val doubleColumnMenuButton: ImageView = view.findViewById(R.id.double_column_menu) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) @@ -2224,6 +2225,9 @@ class ConfigurationFragment @JvmOverloads constructor( ) ) } + urlTestButton.setOnClickListener { + startUrlTest(entity) + } removeButton.setOnClickListener { removeProfile(entity) } @@ -2238,6 +2242,50 @@ class ConfigurationFragment @JvmOverloads constructor( } } + private var urlTestRunning = false + + /** 对当前这一个节点做 URL Test,完成后就地刷新该行状态 */ + private fun startUrlTest(proxyEntity: ProxyEntity) { + if (urlTestRunning || select) return + urlTestRunning = true + urlTestButton.isEnabled = false + profileStatus.setTextColor( + requireContext().getColorAttr(android.R.attr.textColorSecondary) + ) + profileStatus.text = getString(R.string.connection_test_testing) + val targetId = proxyEntity.id + + runOnDefaultDispatcher { + try { + val ping = UrlTest().doTest(proxyEntity) + proxyEntity.status = 1 + proxyEntity.ping = ping + proxyEntity.error = null + } catch (e: PluginManager.PluginNotFoundException) { + proxyEntity.status = 2 + proxyEntity.error = e.readableMessage + } catch (e: Exception) { + proxyEntity.status = 3 + proxyEntity.error = e.readableMessage + } + try { + ProfileManager.updateProfile(proxyEntity) + } catch (e: Exception) { + Logs.w(e) + } + runOnMainDispatcher { + urlTestRunning = false + if (::entity.isInitialized && entity.id == targetId) { + urlTestButton.isEnabled = true + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + adapter?.notifyItemChanged(pos) + } + } + } + } + } + private fun selectProfile(proxyEntity: ProxyEntity) { val pf = parentFragment as? ConfigurationFragment ?: return runOnDefaultDispatcher { @@ -2425,12 +2473,14 @@ class ConfigurationFragment @JvmOverloads constructor( if (isDoubleColumn) { editButton.isGone = true + urlTestButton.isGone = true shareLayout.isGone = true removeButton.isGone = true doubleColumnMenuButton.isVisible = true } else { shareLayout.isGone = selectOrChain editButton.isGone = select + urlTestButton.isGone = select removeButton.isGone = select doubleColumnMenuButton.isGone = true } diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index de0669c10d..3c229e2a20 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -53,6 +53,17 @@ tools:text="@string/profile_name" /> + + Date: Thu, 5 Nov 2026 07:52:39 +0000 Subject: [PATCH 02/14] feat: clear previous test results before starting URL test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开始 URL Test 前先把当前分组所有节点的 status/ping/error 归零并写库、同步清空 adapter,避免测试中途取消时旧结果与新结果混在一起显示。 --- .../sagernet/ui/ConfigurationFragment.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index d9921ed414..0dc0b7a5f2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1146,6 +1146,28 @@ class ConfigurationFragment @JvmOverloads constructor( val mainJob = runOnDefaultDispatcher { val profilesList = SagerDatabase.proxyDao.getByGroup(group.id) + // 开始测试前先清理上一轮的测试结果,避免中途取消时新旧状态混在一起 + if (profilesList.isNotEmpty()) { + var cleared = false + for (profile in profilesList) { + if (profile.status != 0) { + profile.status = 0 + profile.ping = 0 + profile.error = null + cleared = true + } + } + if (cleared) { + try { + ProfileManager.updateProfile(profilesList) + } catch (e: Exception) { + Logs.w(e) + } + } + onMainDispatcher { + getCurrentGroupFragment()?.adapter?.clearTestResults() + } + } test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) repeat(DataStore.connectionTestConcurrent) { From 44c31a80b428794a32f4995f159899fbcc9ae790 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:50:36 +0000 Subject: [PATCH 03/14] feat: add per-app proxy (Apps VPN) entry to drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把设置页里的「分应用代理」提到侧边栏,位置在 路由 与 设置 之间;点击直接进入 AppManagerActivity 选择应用,菜单项放在 checkableBehavior=none 的独立 group 里,避免点击后改变导航选中态。 --- .../io/nekohasekai/sagernet/ui/MainActivity.kt | 6 ++++++ app/src/main/res/menu/main_drawer_menu.xml | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt index 5b11495849..d747f74fb2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -425,6 +425,12 @@ class MainActivity : ThemedActivity(), R.id.nav_group -> displayFragment(GroupFragment()) R.id.nav_route -> displayFragment(RouteFragment()) + R.id.nav_apps -> { + binding.drawerLayout.closeDrawers() + startActivity(Intent(this, AppManagerActivity::class.java)) + return false + } + R.id.nav_settings -> displayFragment(SettingsFragment()) R.id.nav_traffic -> displayFragment(WebviewFragment()) R.id.nav_tools -> displayFragment(ToolsFragment()) diff --git a/app/src/main/res/menu/main_drawer_menu.xml b/app/src/main/res/menu/main_drawer_menu.xml index 040e8e002f..f7dded62c2 100644 --- a/app/src/main/res/menu/main_drawer_menu.xml +++ b/app/src/main/res/menu/main_drawer_menu.xml @@ -16,7 +16,21 @@ android:id="@+id/nav_route" android:icon="@drawable/ic_maps_directions" android:title="@string/menu_route" /> + + + + + + + Date: Thu, 5 Nov 2026 08:25:47 +0000 Subject: [PATCH 04/14] feat: long press menu with multi-select move to group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 长按节点卡片弹出菜单(移动分组/多选/调整顺序/编辑/分享/删除)。进入多选后工具栏变为「移动到分组 + 全选 + 关闭」,点击卡片切换选中(绿描边),支持批量移动到其它分组并刷新各个已创建分组页面。长按拖拽排序改为菜单项触发,避免与长按菜单冲突。 --- .../sagernet/ui/ConfigurationFragment.kt | 267 +++++++++++++++++- .../main/res/menu/profile_long_click_menu.xml | 21 ++ .../res/menu/profile_multi_select_menu.xml | 18 ++ app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + 5 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/menu/profile_long_click_menu.xml create mode 100644 app/src/main/res/menu/profile_multi_select_menu.xml diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 0dc0b7a5f2..62b814080a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -339,17 +339,7 @@ class ConfigurationFragment @JvmOverloads constructor( } } - val searchView = toolbar.findViewById(R.id.action_search) - if (searchView != null) { - searchView.setOnQueryTextListener(this) - searchView.maxWidth = Int.MAX_VALUE - - searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - cancelSearch(searchView) - } - } - } + setupSearchView() groupPager = view.findViewById(R.id.group_pager) tabLayout = view.findViewById(R.id.group_tab) @@ -863,6 +853,29 @@ class ConfigurationFragment @JvmOverloads constructor( urlTest() } + R.id.action_move_to_group -> { + val currentAdapter = getCurrentGroupFragment()?.adapter + if (currentAdapter != null) { + moveProfilesToGroup( + currentAdapter.selectedIds.mapNotNull { currentAdapter.configurationList[it] } + ) + } + } + + R.id.action_select_all -> { + val currentAdapter = getCurrentGroupFragment()?.adapter + if (currentAdapter != null) { + currentAdapter.selectedIds.clear() + currentAdapter.selectedIds.addAll(currentAdapter.configurationIdList) + currentAdapter.notifyDataSetChanged() + updateSelectionUi() + } + } + + R.id.action_exit_select -> { + exitSelectionMode() + } + R.id.action_global_mode -> { item.isChecked = !item.isChecked DataStore.globalMode = item.isChecked @@ -1136,6 +1149,129 @@ class ConfigurationFragment @JvmOverloads constructor( } } + // ---------------- 多选 & 移动分组 ---------------- + + fun isSelectionMode(): Boolean = getCurrentGroupFragment()?.adapter?.selectionMode == true + + fun enterSelectionMode(initialId: Long) { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + currentAdapter.selectionMode = true + currentAdapter.selectedIds.clear() + currentAdapter.selectedIds.add(initialId) + currentAdapter.notifyDataSetChanged() + updateSelectionUi() + } + + fun exitSelectionMode() { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + currentAdapter.selectionMode = false + currentAdapter.selectedIds.clear() + currentAdapter.notifyDataSetChanged() + restoreToolbarMenu() + } + + fun toggleSelection(proxyEntity: ProxyEntity) { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + if (!currentAdapter.selectionMode) return + if (!currentAdapter.selectedIds.remove(proxyEntity.id)) { + currentAdapter.selectedIds.add(proxyEntity.id) + } + if (currentAdapter.selectedIds.isEmpty()) { + // 取消最后一个即退出多选 + exitSelectionMode() + return + } + val index = currentAdapter.configurationIdList.indexOf(proxyEntity.id) + if (index >= 0) currentAdapter.notifyItemChanged(index) + updateSelectionUi() + } + + private fun updateSelectionUi() { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + if (toolbar.menu.findItem(R.id.action_select_all) == null) { + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.profile_multi_select_menu) + toolbar.setOnMenuItemClickListener(this) + } + toolbar.title = getString(R.string.selected_count, currentAdapter.selectedIds.size) + } + + private fun setupSearchView() { + val sv = toolbar.findViewById(R.id.action_search) ?: return + sv.setOnQueryTextListener(this) + sv.maxWidth = Int.MAX_VALUE + sv.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + cancelSearch(sv) + } + } + } + + private fun restoreToolbarMenu() { + if (select) return + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.add_profile_menu) + toolbar.menu.findItem(R.id.action_global_mode)?.isChecked = DataStore.globalMode + toolbar.setOnMenuItemClickListener(this) + toolbar.title = getString(R.string.app_name) + // 菜单被重新 inflate,SearchView 需要重新绑定监听 + setupSearchView() + } + + fun moveProfilesToGroup(profiles: List) { + if (profiles.isEmpty()) return + val sourceGroupId = profiles.first().groupId + runOnDefaultDispatcher { + val groups = SagerDatabase.groupDao.allGroups().filter { it.id != sourceGroupId } + onMainDispatcher { + if (groups.isEmpty()) { + snackbar(getString(R.string.no_other_group)).show() + return@onMainDispatcher + } + val names = groups.map { it.displayName() }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.move_to_group) + .setItems(names) { _, which -> + performMoveProfiles(profiles, groups[which]) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + } + + private fun performMoveProfiles(profiles: List, target: ProxyGroup) { + runOnDefaultDispatcher { + try { + for (profile in profiles) { + profile.groupId = target.id + profile.userOrder = SagerDatabase.proxyDao.nextOrder(target.id) ?: 1 + ProfileManager.updateProfile(profile) + } + onMainDispatcher { + val currentAdapter = getCurrentGroupFragment()?.adapter + currentAdapter?.apply { + selectionMode = false + selectedIds.clear() + } + restoreToolbarMenu() + snackbar( + getString(R.string.moved_to_group, profiles.size, target.displayName()) + ).show() + } + // 已创建的分组页面都从库里重新加载(reloadProfiles 会直接访问数据库,不能放主线程) + for (fragment in adapter.groupFragments.values.toList()) { + fragment.adapter?.reloadProfiles() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + } + } + @OptIn(DelicateCoroutinesApi::class) fun urlTest() { if (DataStore.runningTest) return else DataStore.runningTest = true @@ -1434,6 +1570,9 @@ class ConfigurationFragment @JvmOverloads constructor( } itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, 0) { + // 长按改为弹菜单,拖拽排序由菜单里的「调整顺序」手动启动 + override fun isLongPressDragEnabled(): Boolean = false + override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder @@ -1720,6 +1859,31 @@ class ConfigurationFragment @JvmOverloads constructor( private val pendingTrafficUpdates = HashSet() private val profileStatePayload = Any() + // 多选移动分组 + var selectionMode = false + val selectedIds = LinkedHashSet() + + fun startDrag(holder: RecyclerView.ViewHolder) { + if (::itemTouchHelper.isInitialized) itemTouchHelper.startDrag(holder) + } + + fun removeProfiles(ids: Collection) { + var removed = false + for (id in ids) { + val index = configurationIdList.indexOf(id) + if (index >= 0) { + configurationIdList.removeAt(index) + configurationList.remove(id) + notifyItemRemoved(index) + removed = true + } + } + if (removed) { + refreshFromPosition(0) + notifyItemRangeChanged(0, itemCount) + } + } + private fun getItem(profileId: Long): ProxyEntity { var profile = configurationList[profileId] if (profile == null) { @@ -2226,12 +2390,28 @@ class ConfigurationFragment @JvmOverloads constructor( init { view.setOnClickListener { val proxyEntity = entity + val owner = parentFragment as? ConfigurationFragment + if (owner != null && owner.isSelectionMode()) { + owner.toggleSelection(proxyEntity) + return@setOnClickListener + } if (select) { (requireActivity() as SelectCallback).returnProfile(proxyEntity.id) } else { selectProfile(proxyEntity) } } + view.setOnLongClickListener { + if (select) return@setOnLongClickListener false + val owner = parentFragment as? ConfigurationFragment + ?: return@setOnLongClickListener false + if (owner.isSelectionMode()) { + owner.toggleSelection(entity) + } else { + showProfileMenu(view, entity, this) + } + true + } profileStatus.setOnClickListener { val proxyEntity = entity if (proxyEntity.status == 3) { @@ -2386,6 +2566,57 @@ class ConfigurationFragment @JvmOverloads constructor( popup.show() } + /** 长按节点卡片的菜单:移动分组 / 多选 / 排序 / 编辑 / 分享 / 删除 */ + private fun showProfileMenu( + anchor: View, + proxyEntity: ProxyEntity, + holder: ConfigurationHolder, + ) { + val owner = parentFragment as? ConfigurationFragment ?: return + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.profile_long_click_menu, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_move_to_group -> { + owner.moveProfilesToGroup(listOf(proxyEntity)) + true + } + + R.id.action_multi_select -> { + owner.enterSelectionMode(proxyEntity.id) + true + } + + R.id.action_drag_sort -> { + adapter?.startDrag(holder) + true + } + + R.id.action_edit -> { + anchor.context.startActivity( + proxyEntity.settingIntent( + anchor.context, proxyGroup.type == GroupType.SUBSCRIPTION + ) + ) + true + } + + R.id.action_share -> { + showShareMenu(anchor, proxyEntity) + true + } + + R.id.action_delete -> { + removeProfile(proxyEntity) + true + } + + else -> false + } + } + popup.show() + } + private fun applySelected(selected: Boolean) { val ctx = card.context val surface = ctx.getColorAttr(R.attr.colorSurface) @@ -2520,6 +2751,20 @@ class ConfigurationFragment @JvmOverloads constructor( removeButton.isEnabled = !started applySelected(selected) + // 多选高亮:只覆盖选中态,未选中保持 applySelected 的描边 + val inSelection = adapter?.selectionMode == true + val picked = inSelection && adapter?.selectedIds?.contains(proxyEntity.id) == true + if (picked) { + card.strokeWidth = (2 * resources.displayMetrics.density).toInt() + card.strokeColor = requireContext().getColour(R.color.material_green_500) + } + if (inSelection) { + // 多选时隐藏操作按钮,避免误触编辑/删除 + editButton.isGone = true + removeButton.isGone = true + shareLayout.isGone = true + } + if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) diff --git a/app/src/main/res/menu/profile_long_click_menu.xml b/app/src/main/res/menu/profile_long_click_menu.xml new file mode 100644 index 0000000000..25f831ef7c --- /dev/null +++ b/app/src/main/res/menu/profile_long_click_menu.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/profile_multi_select_menu.xml b/app/src/main/res/menu/profile_multi_select_menu.xml new file mode 100644 index 0000000000..64d095a18f --- /dev/null +++ b/app/src/main/res/menu/profile_multi_select_menu.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 16b353acd5..5b8c8ac2a6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -610,4 +610,13 @@ QUIC Proxy 模式 仅 v6:旧版 v5 QUIC Proxy 兼容 仅适用于 v4-v6 + + + 移动到分组 + 多选 + 调整顺序 + 全选 + 已选 %d 项 + 没有其他分组 + 已移动 %1$d 个节点到 %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8dc47181c..e82cc9a7ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -696,4 +696,13 @@ v6 only: legacy v5 QUIC Proxy compatibility v4-v6 only + + Move to group + Multi-select + Reorder + Select all + %d selected + No other group + Moved %1$d profiles to %2$s + From abccc86547c066763cb10db0924e2c9869fe5648 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:33:04 +0000 Subject: [PATCH 05/14] feat: long press group tab to rename or delete group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置页顶部分组标签长按弹出菜单:修改分组名(对话框输入,走 GroupManager.updateGroup 自动刷新标签)、删除分组(二次确认,删除后重建分组列表并回退到可用分组)。未分组系统分组不提供菜单。 --- .../sagernet/ui/ConfigurationFragment.kt | 101 +++++++++++++++++- app/src/main/res/menu/group_tab_menu.xml | 9 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/menu/group_tab_menu.xml diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 62b814080a..32dc13f733 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -19,6 +19,8 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -351,11 +353,15 @@ class ConfigurationFragment @JvmOverloads constructor( groupPager.offscreenPageLimit = 2 TabLayoutMediator(tabLayout, groupPager) { tab, position -> - if (adapter.groupList.size > position) { - tab.text = adapter.groupList[position].displayName() - } - tab.view.setOnLongClickListener { // clear toast - true + val group = adapter.groupList.getOrNull(position) + if (group != null) { + tab.text = group.displayName() + tab.view.setOnLongClickListener { anchor -> + showGroupTabMenu(anchor, group) + true + } + } else { + tab.view.setOnLongClickListener { true } } }.attach() @@ -1149,6 +1155,91 @@ class ConfigurationFragment @JvmOverloads constructor( } } + // ---------------- 分组名长按菜单 ---------------- + + private fun showGroupTabMenu(anchor: View, group: ProxyGroup) { + // 「未分组」是系统分组,不提供改名/删除 + if (group.ungrouped) return + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.group_tab_menu, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_rename_group -> { + renameGroup(group) + true + } + + R.id.action_delete_group -> { + confirmDeleteGroup(group) + true + } + + else -> false + } + } + popup.show() + } + + private fun renameGroup(group: ProxyGroup) { + val input = EditText(requireContext()).apply { + setText(group.name ?: "") + setSelection(text.length) + hint = getString(R.string.group_name) + setSingleLine() + } + val container = FrameLayout(requireContext()).apply { + val padding = (20 * resources.displayMetrics.density).toInt() + setPadding(padding, padding / 2, padding, 0) + addView(input) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.rename_group) + .setView(container) + .setPositiveButton(android.R.string.ok) { _, _ -> + val newName = input.text?.toString()?.trim().orEmpty() + if (newName.isEmpty()) { + snackbar(getString(R.string.group_name_required)).show() + return@setPositiveButton + } + runOnDefaultDispatcher { + try { + group.name = newName + GroupManager.updateGroup(group) + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { snackbar(e.readableMessage).show() } + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun confirmDeleteGroup(group: ProxyGroup) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.delete_group) + .setMessage("${getString(R.string.delete_group_prompt)}\n\n${group.displayName()}") + .setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + try { + GroupManager.deleteGroup(group.id) + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { snackbar(e.readableMessage).show() } + return@runOnDefaultDispatcher + } + onMainDispatcher { + if (DataStore.selectedGroup == group.id) { + DataStore.selectedGroup = -1L + } + adapter.reload() + } + } + } + .setNegativeButton(R.string.no, null) + .show() + } + // ---------------- 多选 & 移动分组 ---------------- fun isSelectionMode(): Boolean = getCurrentGroupFragment()?.adapter?.selectionMode == true diff --git a/app/src/main/res/menu/group_tab_menu.xml b/app/src/main/res/menu/group_tab_menu.xml new file mode 100644 index 0000000000..d86ac00e3c --- /dev/null +++ b/app/src/main/res/menu/group_tab_menu.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b8c8ac2a6..5b93762cff 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -619,4 +619,6 @@ 已选 %d 项 没有其他分组 已移动 %1$d 个节点到 %2$s + 修改分组名 + 删除分组 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e82cc9a7ee..a705f9fdd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -704,5 +704,7 @@ %d selected No other group Moved %1$d profiles to %2$s + Rename group + Delete group From c46b14a77821fbfe9c40f7ddad236100594ef7cf Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:39:33 +0000 Subject: [PATCH 06/14] fix: missing string/cancel resource link error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile_multi_select_menu 引用的 @string/cancel 不存在,改为语义化的 exit_selection(退出多选) - 顺带修复原有 bug:mal_close 只在俄语里定义,非俄语环境点 yacd 面板的关闭按钮会崩,补上默认与中文值 --- app/src/main/res/menu/profile_multi_select_menu.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/menu/profile_multi_select_menu.xml b/app/src/main/res/menu/profile_multi_select_menu.xml index 64d095a18f..0287fdc563 100644 --- a/app/src/main/res/menu/profile_multi_select_menu.xml +++ b/app/src/main/res/menu/profile_multi_select_menu.xml @@ -13,6 +13,6 @@ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b93762cff..a276e887ac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -621,4 +621,6 @@ 已移动 %1$d 个节点到 %2$s 修改分组名 删除分组 + 退出多选 + 关闭 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a705f9fdd0..f24a53af06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -706,5 +706,8 @@ Moved %1$d profiles to %2$s Rename group Delete group + Exit selection + + Close From 1bfc61efee2ec8e52d0d858c93322b5a18adff95 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 09:22:10 +0000 Subject: [PATCH 07/14] feat: add per-profile URL test button in dual-column layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双列布局下 edit 隐藏、改用三点菜单,原先单列的测速按钮也被隐藏。在三点菜单旁新增专用测速按钮:双列显示、单列隐藏,多选模式与选择模式下跟随隐藏,测试中与单列按钮同步禁用。 --- .../nekohasekai/sagernet/ui/ConfigurationFragment.kt | 10 ++++++++++ app/src/main/res/layout/layout_profile.xml | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 32dc13f733..12b3bde4a7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2472,6 +2472,7 @@ class ConfigurationFragment @JvmOverloads constructor( private val selectedIndicator: View = view.findViewById(R.id.selected_indicator) val editButton: ImageView = view.findViewById(R.id.edit) val urlTestButton: ImageView = view.findViewById(R.id.url_test) + val urlTestButtonDouble: ImageView = view.findViewById(R.id.url_test_double) val doubleColumnMenuButton: ImageView = view.findViewById(R.id.double_column_menu) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) @@ -2521,6 +2522,9 @@ class ConfigurationFragment @JvmOverloads constructor( urlTestButton.setOnClickListener { startUrlTest(entity) } + urlTestButtonDouble.setOnClickListener { + startUrlTest(entity) + } removeButton.setOnClickListener { removeProfile(entity) } @@ -2542,6 +2546,7 @@ class ConfigurationFragment @JvmOverloads constructor( if (urlTestRunning || select) return urlTestRunning = true urlTestButton.isEnabled = false + urlTestButtonDouble.isEnabled = false profileStatus.setTextColor( requireContext().getColorAttr(android.R.attr.textColorSecondary) ) @@ -2570,6 +2575,7 @@ class ConfigurationFragment @JvmOverloads constructor( urlTestRunning = false if (::entity.isInitialized && entity.id == targetId) { urlTestButton.isEnabled = true + urlTestButtonDouble.isEnabled = true val pos = bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { adapter?.notifyItemChanged(pos) @@ -2818,6 +2824,7 @@ class ConfigurationFragment @JvmOverloads constructor( if (isDoubleColumn) { editButton.isGone = true urlTestButton.isGone = true + urlTestButtonDouble.isGone = select shareLayout.isGone = true removeButton.isGone = true doubleColumnMenuButton.isVisible = true @@ -2825,6 +2832,7 @@ class ConfigurationFragment @JvmOverloads constructor( shareLayout.isGone = selectOrChain editButton.isGone = select urlTestButton.isGone = select + urlTestButtonDouble.isGone = true removeButton.isGone = select doubleColumnMenuButton.isGone = true } @@ -2854,6 +2862,8 @@ class ConfigurationFragment @JvmOverloads constructor( editButton.isGone = true removeButton.isGone = true shareLayout.isGone = true + urlTestButton.isGone = true + urlTestButtonDouble.isGone = true } if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index 3c229e2a20..b17c4562c2 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -87,6 +87,18 @@ android:visibility="gone" app:srcCompat="@drawable/ic_baseline_more_vert_24" /> + + Date: Thu, 5 Nov 2026 13:51:31 +0000 Subject: [PATCH 08/14] feat: show country flag on profile card after URL test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - libcore 新增 QueryGeoIPCountry(ip),用已打包的 geoip.db(MaxMind)离线查 ISO 国家代码,单例 reader 复用 - Kotlin 侧 CountryFlags:URL Test 时解析服务器地址->IP->国家码,按 profileId 与 host 双键缓存,避免重复解析 - 卡片底部协议标识右侧显示国旗 emoji(不显示文字),仅在测试过且查到归属时出现 --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 80 +++++++++++++++++++ .../sagernet/ui/ConfigurationFragment.kt | 15 ++++ app/src/main/res/layout/layout_profile.xml | 10 +++ libcore/geoip.go | 41 ++++++++++ 4 files changed, 146 insertions(+) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt new file mode 100644 index 0000000000..b474cffc53 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -0,0 +1,80 @@ +package io.nekohasekai.sagernet.ktx + +import io.nekohasekai.sagernet.database.ProxyEntity +import libcore.Libcore +import java.net.InetAddress +import java.util.concurrent.ConcurrentHashMap + +/** + * IP 归属地国旗缓存。 + * + * URL Test 时同步查询一次(本地 geoip.db,无需联网),结果按 profileId 与 + * 服务器地址双键缓存;卡片绘制时只读缓存,不做任何解析或查询。 + */ +object CountryFlags { + + private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 + private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 + private val failed = ConcurrentHashMap.newKeySet() // 查询失败的地址,避免反复重试 + + fun get(profileId: Long): String? = byProfile[profileId] + + fun forget(profileId: Long) { + byProfile.remove(profileId) + } + + /** + * 查询节点服务器地址的归属国家,阻塞调用,必须在后台线程执行。 + * 返回 ISO 3166-1 两位代码,查不到返回空串。 + */ + fun query(profile: ProxyEntity): String { + val host = try { + profile.requireBean().serverAddress + } catch (e: Exception) { + return "" + } + if (host.isBlank()) return "" + + byHost[host]?.let { cached -> + byProfile[profile.id] = cached + return cached + } + if (host in failed) return "" + + // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) + val ip = try { + InetAddress.getByName(host).hostAddress + } catch (e: Exception) { + null + } + if (ip.isNullOrBlank()) { + failed.add(host) + return "" + } + + val code = try { + Libcore.queryGeoIPCountry(ip) ?: "" + } catch (e: Exception) { + "" + } + if (code.length == 2) { + byHost[host] = code + byProfile[profile.id] = code + return code + } + failed.add(host) + return "" + } + + /** ISO 国家代码转国旗 emoji,如 "US" -> 🇺🇸 */ + fun flagOf(code: String?): String { + if (code == null || code.length != 2) return "" + val upper = code.uppercase() + val first = upper[0] + val second = upper[1] + if (first !in 'A'..'Z' || second !in 'A'..'Z') return "" + val base = 0x1F1E6 // Regional Indicator Symbol Letter A + return String(Character.toChars(base + (first - 'A'))) + + String(Character.toChars(base + (second - 'A'))) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 12b3bde4a7..a8907cfa76 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -67,6 +67,7 @@ import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.RawUpdater +import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager import io.nekohasekai.sagernet.ktx.FixedGridLayoutManager import io.nekohasekai.sagernet.ktx.Logs @@ -1416,6 +1417,9 @@ class ConfigurationFragment @JvmOverloads constructor( profile.error = e.readableMessage } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(profile) + test.update(profile) } }) @@ -2464,6 +2468,7 @@ class ConfigurationFragment @JvmOverloads constructor( val profileName: TextView = view.findViewById(R.id.profile_name) val profileType: TextView = view.findViewById(R.id.profile_type) + val profileCountry: TextView = view.findViewById(R.id.profile_country) val profileAddress: TextView = view.findViewById(R.id.profile_address) val profileStatus: TextView = view.findViewById(R.id.profile_status) @@ -2566,6 +2571,8 @@ class ConfigurationFragment @JvmOverloads constructor( proxyEntity.status = 3 proxyEntity.error = e.readableMessage } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(proxyEntity) try { ProfileManager.updateProfile(proxyEntity) } catch (e: Exception) { @@ -2764,6 +2771,14 @@ class ConfigurationFragment @JvmOverloads constructor( profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) + val countryFlag = CountryFlags.flagOf(CountryFlags.get(proxyEntity.id)) + if (countryFlag.isEmpty()) { + profileCountry.isGone = true + } else { + profileCountry.text = countryFlag + profileCountry.isVisible = true + } + val rx = proxyEntity.rx val tx = proxyEntity.tx diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index b17c4562c2..29d0147c58 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -187,6 +187,16 @@ android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="?android:textColorSecondary" /> + + Date: Thu, 5 Nov 2026 14:04:06 +0000 Subject: [PATCH 09/14] fix: geoip record format and flag refresh timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) sing-geoip 的 geoip.db 记录值是两位国家码字符串(同 Rules() 里的 networks.Network(&code)),按标准 MaxMind 结构解码必然失败;改为先按 string 解码,失败再按 country.iso_code 结构解码 2) 全量 URL Test 每测完一个节点即刷新该行(notifyProfileChanged),国旗不再等整轮结束 3) 增加 CountryFlags 日志(host -> ip -> code),便于在日志页定位 --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 3 +++ .../sagernet/ui/ConfigurationFragment.kt | 8 ++++++ libcore/geoip.go | 25 +++++++++++++++---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index b474cffc53..8105064f45 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -48,6 +48,7 @@ object CountryFlags { null } if (ip.isNullOrBlank()) { + Logs.d("CountryFlags: $host 无法解析为 IP") failed.add(host) return "" } @@ -55,8 +56,10 @@ object CountryFlags { val code = try { Libcore.queryGeoIPCountry(ip) ?: "" } catch (e: Exception) { + Logs.w("CountryFlags: 查询 $ip 失败", e) "" } + Logs.d("CountryFlags: $host -> $ip -> [$code]") if (code.length == 2) { byHost[host] = code byProfile[profile.id] = code diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a8907cfa76..f7caf0ff58 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1419,6 +1419,9 @@ class ConfigurationFragment @JvmOverloads constructor( // 顺带解析节点服务器的归属国家,供卡片显示国旗 CountryFlags.query(profile) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } test.update(profile) } @@ -1979,6 +1982,11 @@ class ConfigurationFragment @JvmOverloads constructor( } } + fun notifyProfileChanged(profileId: Long) { + val index = configurationIdList.indexOf(profileId) + if (index >= 0) notifyItemChanged(index) + } + private fun getItem(profileId: Long): ProxyEntity { var profile = configurationList[profileId] if (profile == null) { diff --git a/libcore/geoip.go b/libcore/geoip.go index 9ac28e80fc..94b6984506 100644 --- a/libcore/geoip.go +++ b/libcore/geoip.go @@ -84,6 +84,25 @@ type geoIPQueryRecord struct { } `maxminddb:"country"` } +// lookupCountryCode 兼容两种记录格式: +// - sing-geoip 生成的库:值就是两位国家码字符串(见 Rules() 里的 networks.Network(&code)) +// - 标准 MaxMind GeoLite2-Country:{country: {iso_code: "US"}} +func lookupCountryCode(reader *maxminddb.Reader, ip net.IP) string { + var code string + if err := reader.Lookup(ip, &code); err == nil { + if c := strings.ToUpper(strings.TrimSpace(code)); len(c) == 2 { + return c + } + } + var record geoIPQueryRecord + if err := reader.Lookup(ip, &record); err == nil { + if c := strings.ToUpper(strings.TrimSpace(record.Country.ISOCode)); len(c) == 2 { + return c + } + } + return "" +} + func getGeoIPQueryReader() (*maxminddb.Reader, error) { geoIPQueryOnce.Do(func() { geoIPQueryReader, geoIPQueryErr = maxminddb.Open( @@ -103,9 +122,5 @@ func QueryGeoIPCountry(ip string) string { if err != nil || reader == nil { return "" } - var record geoIPQueryRecord - if err = reader.Lookup(parsed, &record); err != nil { - return "" - } - return strings.ToUpper(record.Country.ISOCode) + return lookupCountryCode(reader, parsed) } From a6f6b2b3e4fcf75bba6fb83dd029f1059d53dade Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:34:03 +0000 Subject: [PATCH 10/14] fix: country flag retry and lazy lookup on bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败地址原来进永久黑名单,一次失败后续 URL Test 全部静默跳过(日志里一行都没有),改为 60 秒冷却重试 - 每个提前返回分支都补日志,便于在日志页定位卡在哪一步 - 卡片首次绑定即异步补查(inFlight 去重 + 同域名结果复用),不必等到下次 URL Test --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 80 +++++++++++++++---- .../sagernet/ui/ConfigurationFragment.kt | 7 ++ 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index 8105064f45..e345a40804 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -8,14 +8,17 @@ import java.util.concurrent.ConcurrentHashMap /** * IP 归属地国旗缓存。 * - * URL Test 时同步查询一次(本地 geoip.db,无需联网),结果按 profileId 与 - * 服务器地址双键缓存;卡片绘制时只读缓存,不做任何解析或查询。 + * URL Test 时同步查询一次(本地 geoip.db,无需联网),卡片首次绑定时也会异步补查一次。 + * 结果按 profileId 与服务器地址双键缓存;查询失败只冷却一段时间,不做永久拉黑。 */ object CountryFlags { - private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 - private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 - private val failed = ConcurrentHashMap.newKeySet() // 查询失败的地址,避免反复重试 + private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 + private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 + private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 + private val inFlight = ConcurrentHashMap.newKeySet() // 正在查询中的地址 + + private const val RETRY_COOL_DOWN_MS = 60_000L fun get(profileId: Long): String? = byProfile[profileId] @@ -23,52 +26,101 @@ object CountryFlags { byProfile.remove(profileId) } + private fun hostOf(profile: ProxyEntity): String = try { + profile.requireBean().serverAddress.orEmpty() + } catch (e: Exception) { + "" + } + /** * 查询节点服务器地址的归属国家,阻塞调用,必须在后台线程执行。 * 返回 ISO 3166-1 两位代码,查不到返回空串。 */ fun query(profile: ProxyEntity): String { - val host = try { - profile.requireBean().serverAddress - } catch (e: Exception) { + val host = hostOf(profile) + if (host.isBlank()) { + Logs.d("CountryFlags: profile=${profile.id} 服务器地址为空") return "" } - if (host.isBlank()) return "" byHost[host]?.let { cached -> byProfile[profile.id] = cached return cached } - if (host in failed) return "" + + val failedTime = failedAt[host] + if (failedTime != null && + System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS + ) { + return "" + } // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) val ip = try { InetAddress.getByName(host).hostAddress } catch (e: Exception) { + Logs.w("CountryFlags: $host 域名解析失败", e) null } if (ip.isNullOrBlank()) { - Logs.d("CountryFlags: $host 无法解析为 IP") - failed.add(host) + failedAt[host] = System.currentTimeMillis() return "" } val code = try { Libcore.queryGeoIPCountry(ip) ?: "" } catch (e: Exception) { - Logs.w("CountryFlags: 查询 $ip 失败", e) + Logs.w("CountryFlags: 查询 $ip 归属地异常", e) "" } Logs.d("CountryFlags: $host -> $ip -> [$code]") + if (code.length == 2) { byHost[host] = code byProfile[profile.id] = code + failedAt.remove(host) return code } - failed.add(host) + failedAt[host] = System.currentTimeMillis() return "" } + /** + * 卡片绘制时调用:缓存缺失就后台补查一次,查到后回调(主线程)。 + * 已在查询中或处于冷却期则直接跳过,避免滚动列表时反复解析。 + */ + fun ensure(profile: ProxyEntity, onResolved: (Long) -> Unit) { + if (byProfile.containsKey(profile.id)) return + val host = hostOf(profile) + if (host.isBlank()) return + + // 同一域名已被别的节点查过,直接复用 + byHost[host]?.let { cached -> + byProfile[profile.id] = cached + runOnMainDispatcher { onResolved(profile.id) } + return + } + + val failedTime = failedAt[host] + if (failedTime != null && + System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS + ) return + if (!inFlight.add(host)) return + + runOnDefaultDispatcher { + try { + query(profile) + } finally { + inFlight.remove(host) + } + if (byProfile.containsKey(profile.id)) { + runOnMainDispatcher { + onResolved(profile.id) + } + } + } + } + /** ISO 国家代码转国旗 emoji,如 "US" -> 🇺🇸 */ fun flagOf(code: String?): String { if (code == null || code.length != 2) return "" diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index f7caf0ff58..72942def54 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2782,6 +2782,13 @@ class ConfigurationFragment @JvmOverloads constructor( val countryFlag = CountryFlags.flagOf(CountryFlags.get(proxyEntity.id)) if (countryFlag.isEmpty()) { profileCountry.isGone = true + // 首次绑定就后台补查一次,查到后刷新该行 + val owner = parentFragment as? ConfigurationFragment + CountryFlags.ensure(proxyEntity) { id -> + if (owner != null && owner.isAdded) { + owner.getCurrentGroupFragment()?.adapter?.notifyProfileChanged(id) + } + } } else { profileCountry.text = countryFlag profileCountry.isVisible = true From 352be02291a20f8c4504f90fe528766cae97f114 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:35:45 +0000 Subject: [PATCH 11/14] feat: persist country flags and remove debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询结果按服务器地址持久化到 SharedPreferences(country_flags),重启后直接回填;profileId 映射每次启动按地址重建 - 卡片绑定优先用 resolve()(profileId -> host 兜底),持久化数据无需等待异步回调 - 失败地址改为 60 秒冷却重试,不再永久拉黑(原来一次失败后续测试全部静默跳过) - 移除 CountryFlags 全部日志输出,不污染运行日志 - 卡片首次绑定异步补查,inFlight 去重,同域名多节点复用结果 --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 59 ++++++++++++++----- .../sagernet/ui/ConfigurationFragment.kt | 2 +- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index e345a40804..02d2f71d31 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.ktx +import android.content.Context import io.nekohasekai.sagernet.database.ProxyEntity import libcore.Libcore import java.net.InetAddress @@ -8,20 +9,46 @@ import java.util.concurrent.ConcurrentHashMap /** * IP 归属地国旗缓存。 * - * URL Test 时同步查询一次(本地 geoip.db,无需联网),卡片首次绑定时也会异步补查一次。 - * 结果按 profileId 与服务器地址双键缓存;查询失败只冷却一段时间,不做永久拉黑。 + * 查询走本地 geoip.db(无需联网):服务器地址 -> IP -> ISO 国家码。 + * 结果按 服务器地址 持久化到 SharedPreferences,重启后仍然有效; + * profileId 映射只存在内存里,卡片首次绑定时按地址回填。 */ object CountryFlags { + private const val PREFS_NAME = "country_flags" + private const val RETRY_COOL_DOWN_MS = 60_000L + private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 private val inFlight = ConcurrentHashMap.newKeySet() // 正在查询中的地址 - private const val RETRY_COOL_DOWN_MS = 60_000L + private val prefs by lazy { + app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + init { + // 把上次运行持久化的结果读回内存 + try { + prefs.all.forEach { (host, code) -> + if (host is String && code is String && code.length == 2) { + byHost[host] = code + } + } + } catch (ignored: Exception) { + } + } fun get(profileId: Long): String? = byProfile[profileId] + /** 优先按 profile 取,其次按服务器地址取(持久化数据在重启后走这条路径) */ + fun resolve(profile: ProxyEntity): String? { + byProfile[profile.id]?.let { return it } + val host = hostOf(profile) + if (host.isBlank()) return null + return byHost[host]?.also { byProfile[profile.id] = it } + } + fun forget(profileId: Long) { byProfile.remove(profileId) } @@ -38,10 +65,7 @@ object CountryFlags { */ fun query(profile: ProxyEntity): String { val host = hostOf(profile) - if (host.isBlank()) { - Logs.d("CountryFlags: profile=${profile.id} 服务器地址为空") - return "" - } + if (host.isBlank()) return "" byHost[host]?.let { cached -> byProfile[profile.id] = cached @@ -51,15 +75,12 @@ object CountryFlags { val failedTime = failedAt[host] if (failedTime != null && System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS - ) { - return "" - } + ) return "" // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) val ip = try { InetAddress.getByName(host).hostAddress } catch (e: Exception) { - Logs.w("CountryFlags: $host 域名解析失败", e) null } if (ip.isNullOrBlank()) { @@ -70,21 +91,27 @@ object CountryFlags { val code = try { Libcore.queryGeoIPCountry(ip) ?: "" } catch (e: Exception) { - Logs.w("CountryFlags: 查询 $ip 归属地异常", e) "" } - Logs.d("CountryFlags: $host -> $ip -> [$code]") if (code.length == 2) { - byHost[host] = code - byProfile[profile.id] = code - failedAt.remove(host) + remember(host, profile.id, code) return code } failedAt[host] = System.currentTimeMillis() return "" } + private fun remember(host: String, profileId: Long, code: String) { + byHost[host] = code + byProfile[profileId] = code + failedAt.remove(host) + try { + prefs.edit().putString(host, code).apply() + } catch (ignored: Exception) { + } + } + /** * 卡片绘制时调用:缓存缺失就后台补查一次,查到后回调(主线程)。 * 已在查询中或处于冷却期则直接跳过,避免滚动列表时反复解析。 diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 72942def54..69666d764e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2779,7 +2779,7 @@ class ConfigurationFragment @JvmOverloads constructor( profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) - val countryFlag = CountryFlags.flagOf(CountryFlags.get(proxyEntity.id)) + val countryFlag = CountryFlags.flagOf(CountryFlags.resolve(proxyEntity)) if (countryFlag.isEmpty()) { profileCountry.isGone = true // 首次绑定就后台补查一次,查到后刷新该行 From 0a62c52f9a815c9183b194d149cc650aa9e83455 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:47:27 +0000 Subject: [PATCH 12/14] feat: clear country flags together with test results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清理测试结果时同时清除该分组所有节点的图标缓存(内存 + SharedPreferences),图标与延迟一起消失 - 移除卡片绑定时的自动补查逻辑:图标只在 URL Test 后产生,清理后不会被自动查回来 - 持久化数据保留:重启后仍显示上次测速产生的图标(清理过的除外) --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 46 +++++++------------ .../sagernet/ui/ConfigurationFragment.kt | 9 +--- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index 02d2f71d31..180767ab38 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -21,7 +21,6 @@ object CountryFlags { private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 - private val inFlight = ConcurrentHashMap.newKeySet() // 正在查询中的地址 private val prefs by lazy { app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -113,37 +112,26 @@ object CountryFlags { } /** - * 卡片绘制时调用:缓存缺失就后台补查一次,查到后回调(主线程)。 - * 已在查询中或处于冷却期则直接跳过,避免滚动列表时反复解析。 + * 清除指定节点的图标缓存(内存 + 持久化)。 + * 「清理测试结果」时调用,让图标与延迟一起消失。 */ - fun ensure(profile: ProxyEntity, onResolved: (Long) -> Unit) { - if (byProfile.containsKey(profile.id)) return - val host = hostOf(profile) - if (host.isBlank()) return - - // 同一域名已被别的节点查过,直接复用 - byHost[host]?.let { cached -> - byProfile[profile.id] = cached - runOnMainDispatcher { onResolved(profile.id) } - return + fun forgetProfiles(profiles: List) { + if (profiles.isEmpty()) return + val editor = prefs.edit() + var changed = false + for (profile in profiles) { + byProfile.remove(profile.id) + val host = hostOf(profile) + if (host.isBlank()) continue + byHost.remove(host) + failedAt.remove(host) + editor.remove(host) + changed = true } - - val failedTime = failedAt[host] - if (failedTime != null && - System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS - ) return - if (!inFlight.add(host)) return - - runOnDefaultDispatcher { + if (changed) { try { - query(profile) - } finally { - inFlight.remove(host) - } - if (byProfile.containsKey(profile.id)) { - runOnMainDispatcher { - onResolved(profile.id) - } + editor.apply() + } catch (ignored: Exception) { } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 69666d764e..ee734e0a32 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -755,6 +755,8 @@ class ConfigurationFragment @JvmOverloads constructor( if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } + // 国家图标与本轮测试结果一起清理 + CountryFlags.forgetProfiles(profiles) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } @@ -2782,13 +2784,6 @@ class ConfigurationFragment @JvmOverloads constructor( val countryFlag = CountryFlags.flagOf(CountryFlags.resolve(proxyEntity)) if (countryFlag.isEmpty()) { profileCountry.isGone = true - // 首次绑定就后台补查一次,查到后刷新该行 - val owner = parentFragment as? ConfigurationFragment - CountryFlags.ensure(proxyEntity) { id -> - if (owner != null && owner.isAdded) { - owner.getCurrentGroupFragment()?.adapter?.notifyProfileChanged(id) - } - } } else { profileCountry.text = countryFlag profileCountry.isVisible = true From 03f89ccc89e8c2d287f8c01a94e0267b47db3f6e Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:58:22 +0000 Subject: [PATCH 13/14] fix: clear country flags in url test pre-clean step too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL Test 开始前的清理只重置了 status/ping/error,没有清图标缓存,导致测试前残留上一轮的国旗。补上与「清理测试结果」相同的 forgetProfiles 调用。 --- .../java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index ee734e0a32..c26fd14b1f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1394,6 +1394,8 @@ class ConfigurationFragment @JvmOverloads constructor( Logs.w(e) } } + // 与「清理测试结果」保持一致:图标也一起清掉,随后由本轮测试重新产生 + CountryFlags.forgetProfiles(profilesList) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } From d17f01d01979fcc9e29371774ef47c6a5c561e9a Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 15:12:52 +0000 Subject: [PATCH 14/14] fix: update ping immediately per profile during url test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来每测完一个节点只做 notifyItemChanged,adapter 缓存的还是旧 ProxyEntity(status=0),所以延迟不刷新、只有国旗出现(国旗读独立缓存)。改为测完立即 ProfileManager.updateProfile,走 onUpdated 链路把新对象写回列表并重绘,延迟与国旗同时出现。 --- .../nekohasekai/sagernet/ui/ConfigurationFragment.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index c26fd14b1f..6b97a4ddf7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1423,8 +1423,15 @@ class ConfigurationFragment @JvmOverloads constructor( // 顺带解析节点服务器的归属国家,供卡片显示国旗 CountryFlags.query(profile) - runOnMainDispatcher { - getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + // 立即落库并刷新该行,让延迟和国旗同时出现 + // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) + try { + ProfileManager.updateProfile(profile) + } catch (e: Exception) { + Logs.w(e) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } } test.update(profile)