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..180767ab38 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -0,0 +1,150 @@ +package io.nekohasekai.sagernet.ktx + +import android.content.Context +import io.nekohasekai.sagernet.database.ProxyEntity +import libcore.Libcore +import java.net.InetAddress +import java.util.concurrent.ConcurrentHashMap + +/** + * IP 归属地国旗缓存。 + * + * 查询走本地 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 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) + } + + private fun hostOf(profile: ProxyEntity): String = try { + profile.requireBean().serverAddress.orEmpty() + } catch (e: Exception) { + "" + } + + /** + * 查询节点服务器地址的归属国家,阻塞调用,必须在后台线程执行。 + * 返回 ISO 3166-1 两位代码,查不到返回空串。 + */ + fun query(profile: ProxyEntity): String { + val host = hostOf(profile) + if (host.isBlank()) return "" + + byHost[host]?.let { cached -> + byProfile[profile.id] = cached + return cached + } + + 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) { + null + } + if (ip.isNullOrBlank()) { + failedAt[host] = System.currentTimeMillis() + return "" + } + + val code = try { + Libcore.queryGeoIPCountry(ip) ?: "" + } catch (e: Exception) { + "" + } + + if (code.length == 2) { + 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) { + } + } + + /** + * 清除指定节点的图标缓存(内存 + 持久化)。 + * 「清理测试结果」时调用,让图标与延迟一起消失。 + */ + 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 + } + if (changed) { + try { + editor.apply() + } catch (ignored: Exception) { + } + } + } + + /** 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 a6734cd9fa..6b97a4ddf7 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 @@ -65,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 @@ -339,17 +342,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) @@ -361,11 +354,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() @@ -758,6 +755,8 @@ class ConfigurationFragment @JvmOverloads constructor( if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } + // 国家图标与本轮测试结果一起清理 + CountryFlags.forgetProfiles(profiles) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } @@ -863,6 +862,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 +1158,214 @@ 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 + + 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 @@ -1146,6 +1376,30 @@ 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) + } + } + // 与「清理测试结果」保持一致:图标也一起清掉,随后由本轮测试重新产生 + CountryFlags.forgetProfiles(profilesList) + onMainDispatcher { + getCurrentGroupFragment()?.adapter?.clearTestResults() + } + } test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) repeat(DataStore.connectionTestConcurrent) { @@ -1167,6 +1421,19 @@ class ConfigurationFragment @JvmOverloads constructor( profile.error = e.readableMessage } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(profile) + // 立即落库并刷新该行,让延迟和国旗同时出现 + // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) + try { + ProfileManager.updateProfile(profile) + } catch (e: Exception) { + Logs.w(e) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } + } + test.update(profile) } }) @@ -1412,6 +1679,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 @@ -1698,6 +1968,36 @@ 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) + } + } + + 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) { @@ -2187,6 +2487,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) @@ -2194,6 +2495,8 @@ 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 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) @@ -2203,12 +2506,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) { @@ -2224,6 +2543,12 @@ class ConfigurationFragment @JvmOverloads constructor( ) ) } + urlTestButton.setOnClickListener { + startUrlTest(entity) + } + urlTestButtonDouble.setOnClickListener { + startUrlTest(entity) + } removeButton.setOnClickListener { removeProfile(entity) } @@ -2238,6 +2563,54 @@ class ConfigurationFragment @JvmOverloads constructor( } } + private var urlTestRunning = false + + /** 对当前这一个节点做 URL Test,完成后就地刷新该行状态 */ + private fun startUrlTest(proxyEntity: ProxyEntity) { + if (urlTestRunning || select) return + urlTestRunning = true + urlTestButton.isEnabled = false + urlTestButtonDouble.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 + } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(proxyEntity) + try { + ProfileManager.updateProfile(proxyEntity) + } catch (e: Exception) { + Logs.w(e) + } + runOnMainDispatcher { + 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) + } + } + } + } + } + private fun selectProfile(proxyEntity: ProxyEntity) { val pf = parentFragment as? ConfigurationFragment ?: return runOnDefaultDispatcher { @@ -2316,6 +2689,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) @@ -2366,6 +2790,14 @@ class ConfigurationFragment @JvmOverloads constructor( profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) + val countryFlag = CountryFlags.flagOf(CountryFlags.resolve(proxyEntity)) + if (countryFlag.isEmpty()) { + profileCountry.isGone = true + } else { + profileCountry.text = countryFlag + profileCountry.isVisible = true + } + val rx = proxyEntity.rx val tx = proxyEntity.tx @@ -2425,12 +2857,16 @@ class ConfigurationFragment @JvmOverloads constructor( if (isDoubleColumn) { editButton.isGone = true + urlTestButton.isGone = true + urlTestButtonDouble.isGone = select shareLayout.isGone = true removeButton.isGone = true doubleColumnMenuButton.isVisible = true } else { shareLayout.isGone = selectOrChain editButton.isGone = select + urlTestButton.isGone = select + urlTestButtonDouble.isGone = true removeButton.isGone = select doubleColumnMenuButton.isGone = true } @@ -2448,6 +2884,22 @@ 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 + urlTestButton.isGone = true + urlTestButtonDouble.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/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/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index de0669c10d..29d0147c58 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" /> + + + + + + + + + + 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" /> + + + + + + + + + + + + + + + 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..0287fdc563 --- /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..a276e887ac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -610,4 +610,17 @@ 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..f24a53af06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -696,4 +696,18 @@ 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 + Rename group + Delete group + Exit selection + + Close + diff --git a/libcore/geoip.go b/libcore/geoip.go index 6ea92d5649..94b6984506 100644 --- a/libcore/geoip.go +++ b/libcore/geoip.go @@ -5,6 +5,7 @@ import ( "net" "path/filepath" "strings" + "sync" "github.com/oschwald/maxminddb-golang" C "github.com/sagernet/sing-box/constant" @@ -68,3 +69,58 @@ func init() { return g.Rules(name) } } + +// ---- IP 归属地查询(供 Kotlin 侧显示国旗)---- + +var ( + geoIPQueryOnce sync.Once + geoIPQueryReader *maxminddb.Reader + geoIPQueryErr error +) + +type geoIPQueryRecord struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `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( + filepath.Join(externalAssetsPath, "geoip.db"), + ) + }) + return geoIPQueryReader, geoIPQueryErr +} + +// QueryGeoIPCountry 返回 IP 的 ISO 3166-1 两位国家代码(如 "US"),查不到返回空串 +func QueryGeoIPCountry(ip string) string { + parsed := net.ParseIP(strings.TrimSpace(ip)) + if parsed == nil { + return "" + } + reader, err := getGeoIPQueryReader() + if err != nil || reader == nil { + return "" + } + return lookupCountryCode(reader, parsed) +}