diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/AngConfigManager.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/AngConfigManager.kt index bb478911..890ba7b4 100644 --- a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/AngConfigManager.kt +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/AngConfigManager.kt @@ -26,6 +26,10 @@ import xyz.zarazaex.olc.util.JsonUtil import xyz.zarazaex.olc.util.QRCodeDecoder import xyz.zarazaex.olc.util.Utils import java.net.URI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking object AngConfigManager { @@ -562,9 +566,13 @@ object AngConfigManager { fun updateConfigViaSubAll(): SubscriptionUpdateResult { return try { val subscriptions = MmkvManager.decodeSubscriptions() - subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription -> - acc + updateConfigViaSub(subscription) + // Parallel fetch — each sub downloads concurrently on IO pool + val results = runBlocking(Dispatchers.IO) { + subscriptions.map { sub -> + async { updateConfigViaSub(sub) } + }.awaitAll() } + results.fold(SubscriptionUpdateResult()) { acc, r -> acc + r } } catch (e: Exception) { Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e) SubscriptionUpdateResult() diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/CountryDetector.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/CountryDetector.kt new file mode 100644 index 00000000..49d0480e --- /dev/null +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/CountryDetector.kt @@ -0,0 +1,196 @@ +package xyz.zarazaex.olc.handler + +import android.util.Log +import xyz.zarazaex.olc.AppConfig +import xyz.zarazaex.olc.util.HttpUtil +import xyz.zarazaex.olc.util.JsonUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +object CountryDetector { + + const val UNKNOWN = "??" + + // ── Emoji flag → ISO 2-letter country code ──────────────────────────────── + + /** Extract first flag emoji found in [text] and return its ISO country code (e.g. "RU"). */ + fun extractFlagCode(text: String): String? { + val codePoints = text.codePoints().toArray() + var i = 0 + while (i < codePoints.size - 1) { + val cp1 = codePoints[i] + val cp2 = codePoints[i + 1] + if (cp1 in 0x1F1E6..0x1F1FF && cp2 in 0x1F1E6..0x1F1FF) { + val c1 = ('A'.code + (cp1 - 0x1F1E6)).toChar() + val c2 = ('A'.code + (cp2 - 0x1F1E6)).toChar() + return "$c1$c2" + } + i++ + } + return null + } + + /** Get best country code for a server (emoji first, then cache). */ + fun getCountryCode(remarks: String, serverIp: String?): String { + extractFlagCode(remarks)?.let { return it } + if (!serverIp.isNullOrBlank() && !isPrivateIp(serverIp)) { + MmkvManager.getCountryCache(serverIp)?.let { return it } + } + return UNKNOWN + } + + // ── Flag emoji rendering ────────────────────────────────────────────────── + + /** ISO code → flag emoji string */ + fun codeToFlag(code: String): String { + if (code.length != 2 || code == UNKNOWN) return "🌍" + return try { + val first = 0x1F1E6 + (code[0].uppercaseChar().code - 'A'.code) + val second = 0x1F1E6 + (code[1].uppercaseChar().code - 'A'.code) + String(intArrayOf(first, second), 0, 2) + } catch (e: Exception) { "🌍" } + } + + /** ISO code → human-readable country name (or code if unknown) */ + fun codeToName(code: String): String = COUNTRY_NAMES[code.uppercase()] ?: code + + // ── Background lookup ───────────────────────────────────────────────────── + + private val semaphore = Semaphore(5) + + /** + * Looks up countries for all [ips] not yet cached via ip-api.com/batch. + * Saves results to MmkvManager cache. Called from IO coroutine. + */ + suspend fun lookupAndCacheAll(ips: List) { + val uncached = ips + .filter { !it.isNullOrBlank() && !isPrivateIp(it) } + .distinct() + .filter { MmkvManager.getCountryCache(it) == null } + + if (uncached.isEmpty()) return + + // ip-api.com/batch: max 100 per request, returns [{query, countryCode}] + uncached.chunked(100).forEach { chunk -> + semaphore.withPermit { + try { + lookupBatch(chunk) + } catch (e: Exception) { + Log.w(AppConfig.TAG, "Country batch lookup failed: ${e.message}") + } + } + } + } + + private data class IpApiRequest(val query: String, val fields: String = "countryCode") + + private suspend fun lookupBatch(ips: List) = withContext(Dispatchers.IO) { + val body = JsonUtil.toJson(ips.map { IpApiRequest(it) }) + val response = HttpUtil.postJson("http://ip-api.com/batch", body, 10000) ?: return@withContext + try { + val arr = com.google.gson.JsonParser.parseString(response).asJsonArray + arr.forEach { el -> + val obj = el.asJsonObject + val ip = obj.get("query")?.asString ?: return@forEach + val code = obj.get("countryCode")?.asString?.uppercase() + ?.takeIf { it.length == 2 } ?: return@forEach + MmkvManager.setCountryCache(ip, code) + } + } catch (e: Exception) { + Log.w(AppConfig.TAG, "Country batch parse failed: ${e.message}") + } + } + + // ── Private IP check ───────────────────────────────────────────────────── + + fun isPrivateIp(ip: String): Boolean { + if (ip.contains('.').not()) return false // IPv6 skip for now + return try { + val parts = ip.split('.').map { it.toInt() } + if (parts.size != 4) return false + val a = parts[0]; val b = parts[1] + a == 10 || a == 127 || + (a == 172 && b in 16..31) || + (a == 192 && b == 168) || + (a == 100 && b in 64..127) + } catch (e: Exception) { false } + } + + // ── Country names map ───────────────────────────────────────────────────── + + val COUNTRY_NAMES: Map = mapOf( + "AF" to "Афганистан", "AL" to "Албания", "DZ" to "Алжир", + "AD" to "Андорра", "AO" to "Ангола", "AG" to "Антигуа и Барбуда", + "AR" to "Аргентина", "AM" to "Армения", "AU" to "Австралия", + "AT" to "Австрия", "AZ" to "Азербайджан", "BS" to "Багамы", + "BH" to "Бахрейн", "BD" to "Бангладеш", "BB" to "Барбадос", + "BY" to "Беларусь", "BE" to "Бельгия", "BZ" to "Белиз", + "BJ" to "Бенин", "BT" to "Бутан", "BO" to "Боливия", + "BA" to "Босния и Герцеговина", "BW" to "Ботсвана", + "BR" to "Бразилия", "BN" to "Бруней", "BG" to "Болгария", + "BF" to "Буркина-Фасо", "BI" to "Бурунди", "CV" to "Кабо-Верде", + "KH" to "Камбоджа", "CM" to "Камерун", "CA" to "Канада", + "CF" to "ЦАР", "TD" to "Чад", "CL" to "Чили", + "CN" to "Китай", "CO" to "Колумбия", "KM" to "Коморы", + "CG" to "Конго", "CD" to "ДР Конго", "CR" to "Коста-Рика", + "HR" to "Хорватия", "CU" to "Куба", "CY" to "Кипр", + "CZ" to "Чехия", "DK" to "Дания", "DJ" to "Джибути", + "DM" to "Доминика", "DO" to "Доминикана", "EC" to "Эквадор", + "EG" to "Египет", "SV" to "Сальвадор", "GQ" to "Экв. Гвинея", + "ER" to "Эритрея", "EE" to "Эстония", "SZ" to "Эсватини", + "ET" to "Эфиопия", "FJ" to "Фиджи", "FI" to "Финляндия", + "FR" to "Франция", "GA" to "Габон", "GM" to "Гамбия", + "GE" to "Грузия", "DE" to "Германия", "GH" to "Гана", + "GR" to "Греция", "GD" to "Гренада", "GT" to "Гватемала", + "GN" to "Гвинея", "GW" to "Гвинея-Бисау", "GY" to "Гайана", + "HT" to "Гаити", "HN" to "Гондурас", "HU" to "Венгрия", + "IS" to "Исландия", "IN" to "Индия", "ID" to "Индонезия", + "IR" to "Иран", "IQ" to "Ирак", "IE" to "Ирландия", + "IL" to "Израиль", "IT" to "Италия", "JM" to "Ямайка", + "JP" to "Япония", "JO" to "Иордания", "KZ" to "Казахстан", + "KE" to "Кения", "KI" to "Кирибати", "KP" to "Сев. Корея", + "KR" to "Юж. Корея", "KW" to "Кувейт", "KG" to "Киргизия", + "LA" to "Лаос", "LV" to "Латвия", "LB" to "Ливан", + "LS" to "Лесото", "LR" to "Либерия", "LY" to "Ливия", + "LI" to "Лихтенштейн", "LT" to "Литва", "LU" to "Люксембург", + "MG" to "Мадагаскар", "MW" to "Малави", "MY" to "Малайзия", + "MV" to "Мальдивы", "ML" to "Мали", "MT" to "Мальта", + "MH" to "Маршалловы о-ва", "MR" to "Мавритания", + "MU" to "Маврикий", "MX" to "Мексика", "FM" to "Микронезия", + "MD" to "Молдова", "MC" to "Монако", "MN" to "Монголия", + "ME" to "Черногория", "MA" to "Марокко", "MZ" to "Мозамбик", + "MM" to "Мьянма", "NA" to "Намибия", "NR" to "Науру", + "NP" to "Непал", "NL" to "Нидерланды", "NZ" to "Нов. Зеландия", + "NI" to "Никарагуа", "NE" to "Нигер", "NG" to "Нигерия", + "MK" to "Сев. Македония", "NO" to "Норвегия", "OM" to "Оман", + "PK" to "Пакистан", "PW" to "Палау", "PA" to "Панама", + "PG" to "Папуа — Нов. Гвинея", "PY" to "Парагвай", + "PE" to "Перу", "PH" to "Филиппины", "PL" to "Польша", + "PT" to "Португалия", "QA" to "Катар", "RO" to "Румыния", + "RU" to "Россия", "RW" to "Руанда", + "KN" to "Сент-Китс и Невис", "LC" to "Сент-Люсия", + "VC" to "Сент-Винсент", "WS" to "Самоа", + "SM" to "Сан-Марино", "ST" to "Сан-Томе и Принсипи", + "SA" to "Саудовская Аравия", "SN" to "Сенегал", + "RS" to "Сербия", "SC" to "Сейшелы", "SL" to "Сьерра-Леоне", + "SG" to "Сингапур", "SK" to "Словакия", "SI" to "Словения", + "SB" to "Соломоновы о-ва", "SO" to "Сомали", + "ZA" to "ЮАР", "SS" to "Юж. Судан", "ES" to "Испания", + "LK" to "Шри-Ланка", "SD" to "Судан", "SR" to "Суринам", + "SE" to "Швеция", "CH" to "Швейцария", "SY" to "Сирия", + "TW" to "Тайвань", "TJ" to "Таджикистан", "TZ" to "Танзания", + "TH" to "Таиланд", "TL" to "Восточный Тимор", "TG" to "Того", + "TO" to "Тонга", "TT" to "Тринидад и Тобаго", + "TN" to "Тунис", "TR" to "Турция", "TM" to "Туркменистан", + "TV" to "Тувалу", "UG" to "Уганда", "UA" to "Украина", + "AE" to "ОАЭ", "GB" to "Великобритания", "US" to "США", + "UY" to "Уругвай", "UZ" to "Узбекистан", "VU" to "Вануату", + "VE" to "Венесуэла", "VN" to "Вьетнам", "YE" to "Йемен", + "ZM" to "Замбия", "ZW" to "Зимбабве", + "HK" to "Гонконг", "MO" to "Макао", "PS" to "Палестина", + "XK" to "Косово", "EU" to "Европейский союз", + "NL" to "Нидерланды" + ) +} diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/MmkvManager.kt index 3b0938f8..7d4f4b99 100644 --- a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/MmkvManager.kt +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/handler/MmkvManager.kt @@ -26,6 +26,7 @@ object MmkvManager { private const val ID_SUB = "SUB" private const val ID_ASSET = "ASSET" private const val ID_SETTING = "SETTING" + private const val ID_COUNTRY_CACHE = "COUNTRY_CACHE" private const val KEY_SELECTED_SERVER = "SELECTED_SERVER" private const val KEY_ANG_CONFIGS = "ANG_CONFIGS" private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_" @@ -39,6 +40,7 @@ object MmkvManager { private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) } private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) } private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) } + private val countryCacheStorage by lazy { MMKV.mmkvWithID(ID_COUNTRY_CACHE, MMKV.MULTI_PROCESS_MODE) } //endregion @@ -689,4 +691,23 @@ object MmkvManager { } //endregion + + //region Country Cache + + /** Returns cached ISO country code for [ip], or null if not cached. */ + fun getCountryCache(ip: String): String? = countryCacheStorage.decodeString(ip) + + /** Persists ISO country code for [ip]. */ + fun setCountryCache(ip: String, code: String) { countryCacheStorage.encode(ip, code) } + + /** Loads the user's country filter preference (set of ISO codes to SHOW, empty = show all). */ + fun getCountryFilter(): Set = + settingsStorage.decodeStringSet("pref_country_filter") ?: emptySet() + + /** Saves the user's country filter preference. */ + fun setCountryFilter(codes: Set) { + settingsStorage.encode("pref_country_filter", codes) + } + + //endregion } diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainActivity.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainActivity.kt index bedbb396..21589ed8 100644 --- a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainActivity.kt +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainActivity.kt @@ -31,6 +31,7 @@ import xyz.zarazaex.olc.enums.PermissionType import xyz.zarazaex.olc.extension.toast import xyz.zarazaex.olc.extension.toastError import xyz.zarazaex.olc.handler.AngConfigManager +import xyz.zarazaex.olc.handler.CountryDetector import xyz.zarazaex.olc.handler.MmkvManager import xyz.zarazaex.olc.handler.SettingsChangeManager import xyz.zarazaex.olc.handler.SettingsManager @@ -167,20 +168,30 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect private fun setupViewModel() { mainViewModel.updateTestResultAction.observe(this) { setTestState(it) } + mainViewModel.isTesting.observe(this) { testing -> + setButtonsEnabled(!testing) + if (testing) { + binding.btnSummaryLite.setImageResource(R.drawable.ic_stop_24dp) + } else { + binding.btnSummaryLite.setImageResource(R.drawable.ic_lite_bolt) + } + } + mainViewModel.liteTestFinished.observe(this) { finished -> if (finished && isLiteTesting) { isLiteTesting = false mainViewModel.sortByTestResults() - mainViewModel.reloadServerList() val firstReachable = mainViewModel.serversCache.firstOrNull { cache -> (MmkvManager.decodeServerAffiliationInfo(cache.guid)?.testDelayMillis ?: 0L) > 0L } if (firstReachable != null) { MmkvManager.setSelectServer(firstReachable.guid) + mainViewModel.reloadServerList() // reload AFTER selection so indicator renders correctly showStatus("Подключаемся к быстрейшему серверу") startV2RayWithPermission() } else { + mainViewModel.reloadServerList() showStatus("Нет доступных серверов!") } } @@ -210,6 +221,25 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect binding.tabGroup.isVisible = groups.size > 1 } + private fun setButtonsEnabled(enabled: Boolean) { + binding.fab.isEnabled = enabled + binding.fab.alpha = if (enabled) 1.0f else 0.5f + // btnSummaryLite stays enabled (to allow stopping the test), but visually dim non-test buttons + val menu = binding.toolbar.menu + menu.findItem(R.id.real_ping_all)?.let { + it.isEnabled = enabled + it.icon?.alpha = if (enabled) 255 else 128 + } + menu.findItem(R.id.dedupe_by_ip)?.let { + it.isEnabled = enabled + it.icon?.alpha = if (enabled) 255 else 128 + } + menu.findItem(R.id.sub_update)?.let { + it.isEnabled = enabled + it.icon?.alpha = if (enabled) 255 else 128 + } + } + private fun handleFabAction() { if (isFabOperationInProgress) { return @@ -248,6 +278,14 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect } private fun handleLiteAction() { + // If testing is in progress - stop it + if (mainViewModel.isTesting.value == true) { + mainViewModel.cancelAllTests() + showStatus("Тест остановлен") + isLiteTesting = false + return + } + if (isFabOperationInProgress) { return } @@ -266,10 +304,15 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect launch(Dispatchers.IO) { val result = mainViewModel.updateConfigViaSubAll() + val removed = mainViewModel.removeDuplicateByIpAll() withContext(Dispatchers.Main) { + mainViewModel.reloadServerList() if (result.configCount > 0) { - mainViewModel.reloadServerList() - showStatus("Обновлено ${result.configCount} профилей. Запуск теста...") + val status = if (removed > 0) + "Обновлено ${result.configCount} профилей, удалено $removed дубл. IP. Запуск теста..." + else + "Обновлено ${result.configCount} профилей. Запуск теста..." + showStatus(status) } else { showStatus("Запуск теста...") } @@ -425,11 +468,36 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect true } + R.id.dedupe_by_ip -> { + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Удалить дубликаты по IP") + .setMessage("Будут удалены серверы с одинаковым IP-адресом. Оставим лучший по пингу (или первый попавшийся, если тест не запускался). Продолжить?") + .setPositiveButton(android.R.string.ok) { _, _ -> + showLoading() + lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val removed = mainViewModel.removeDuplicateByIp() + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + mainViewModel.reloadServerList() + showStatus("Удалено дубликатов по IP: $removed") + hideLoading() + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + R.id.sub_update -> { importConfigViaSub() true } + R.id.filter_by_country -> { + showCountryFilterDialog() + true + } + else -> super.onOptionsItemSelected(item) } @@ -524,11 +592,16 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect showLoading() lifecycleScope.launch(Dispatchers.IO) { val result = AngConfigManager.updateConfigViaSubAll() + val removed = mainViewModel.removeDuplicateByIpAll() delay(500L) launch(Dispatchers.Main) { if (result.configCount > 0) { mainViewModel.reloadServerList() - showStatus(getString(R.string.title_update_config_count, result.configCount)) + val status = if (removed > 0) + "${getString(R.string.title_update_config_count, result.configCount)} (удалено $removed дубл. IP)" + else + getString(R.string.title_update_config_count, result.configCount) + showStatus(status) } hideLoading() } @@ -719,7 +792,55 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect tabMediator?.detach() super.onDestroy() } - + + // ── Country filter dialog ───────────────────────────────────────────────── + + private fun showCountryFilterDialog() { + showLoading() + lifecycleScope.launch(Dispatchers.IO) { + // Collect countries from full server list (not only visible) + mainViewModel.refreshCountryCache() // fire bg lookup for unknowns + val allCountries = mainViewModel.collectAllCountries() // code → "🇷🇺 Россия" + val currentFilter = mainViewModel.countryFilter + + withContext(Dispatchers.Main) { + hideLoading() + if (allCountries.isEmpty()) { + showStatus("Нет серверов с известной страной") + return@withContext + } + + val codes = allCountries.keys.toTypedArray() + val labels = allCountries.values.toTypedArray() + // empty filter = show all → treat as all checked + val checked = BooleanArray(codes.size) { + currentFilter.isEmpty() || codes[it] in currentFilter + } + + androidx.appcompat.app.AlertDialog.Builder(this@MainActivity) + .setTitle("Фильтр по странам") + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> + checked[which] = isChecked + } + .setPositiveButton("Применить") { _, _ -> + val selected = codes.filterIndexed { i, _ -> checked[i] }.toSet() + // if all selected or none selected → no filter (show all) + val filter = if (selected.size == codes.size || selected.isEmpty()) emptySet() else selected + mainViewModel.applyCountryFilter(filter) + val msg = if (filter.isEmpty()) "Показаны все страны" + else "Фильтр: ${filter.joinToString { CountryDetector.codeToFlag(it) }}" + showStatus(msg) + } + .setNeutralButton("Сбросить") { _, _ -> + mainViewModel.applyCountryFilter(emptySet()) + showStatus("Показаны все страны") + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + } + private fun checkForUpdatesOnStartup() { showStatus("Проверка обновлений...") lifecycleScope.launch { diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainRecyclerAdapter.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainRecyclerAdapter.kt index 5677e4bf..03915023 100644 --- a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainRecyclerAdapter.kt +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/ui/MainRecyclerAdapter.kt @@ -86,16 +86,13 @@ class MainRecyclerAdapter( override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean { val oldProfile = oldData[oldPos].profile val newProfile = parsedNewData[newPos].profile + val oldGuid = oldData[oldPos].guid + val newGuid = parsedNewData[newPos].guid return oldProfile == newProfile && oldProfile.isFavorite == newProfile.isFavorite && - MmkvManager.decodeServerAffiliationInfo( - oldData[oldPos].guid - ) - ?.testDelayMillis == - MmkvManager.decodeServerAffiliationInfo( - parsedNewData[newPos].guid - ) - ?.testDelayMillis + (oldGuid == MmkvManager.getSelectServer()) == (newGuid == MmkvManager.getSelectServer()) && + MmkvManager.decodeServerAffiliationInfo(oldGuid)?.testDelayMillis == + MmkvManager.decodeServerAffiliationInfo(newGuid)?.testDelayMillis } override fun getChangePayload(oldPos: Int, newPos: Int): Any? { @@ -125,8 +122,13 @@ class MainRecyclerAdapter( if (payloads.isNotEmpty() && holder is MainViewHolder) { for (payload in payloads) { if (payload == PAYLOAD_FAVORITE) { - val isFav = data[position].profile.isFavorite - animateFavorite(holder.itemMainBinding.ivFavorite, isFav) + val item = data.getOrNull(holder.bindingAdapterPosition) ?: data.getOrNull(position) ?: continue + val isFav = item.profile.isFavorite + // Set correct icon immediately, then animate scale bounce + holder.itemMainBinding.ivFavorite.setImageResource( + if (isFav) R.drawable.ic_star_filled else R.drawable.ic_star_empty + ) + animateFavorite(holder.itemMainBinding.ivFavorite) } } } else { @@ -134,23 +136,20 @@ class MainRecyclerAdapter( } } - private fun animateFavorite(view: android.widget.ImageView, isFavorite: Boolean) { + private fun animateFavorite(view: android.widget.ImageView) { + view.animate().cancel() view.animate() - .scaleX(1.3f) - .scaleY(1.3f) - .setDuration(150) - .withEndAction { - view.setImageResource( - if (isFavorite) R.drawable.ic_star_filled - else R.drawable.ic_star_empty - ) - view.animate() - .scaleX(1.0f) - .scaleY(1.0f) - .setDuration(150) - .start() - } - .start() + .scaleX(1.4f) + .scaleY(1.4f) + .setDuration(120) + .withEndAction { + view.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .setDuration(120) + .start() + } + .start() } override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/util/HttpUtil.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/util/HttpUtil.kt index 5ec0d3ad..a674e060 100644 --- a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/util/HttpUtil.kt +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/util/HttpUtil.kt @@ -250,5 +250,27 @@ object HttpUtil { } } } + + /** + * POST JSON body to [url] and return response as String or null. + */ + fun postJson(url: String, body: String, timeout: Int = 10000): String? { + var conn: java.net.HttpURLConnection? = null + return try { + conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = timeout + conn.readTimeout = timeout + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Connection", "close") + conn.outputStream.use { it.write(body.toByteArray()) } + conn.inputStream.bufferedReader().readText() + } catch (_: Exception) { + null + } finally { + conn?.disconnect() + } + } } diff --git a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/viewmodel/MainViewModel.kt b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/viewmodel/MainViewModel.kt index fc9998d8..edd7b451 100644 --- a/V2rayNG/app/src/main/java/xyz/zarazaex/olc/viewmodel/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/xyz/zarazaex/olc/viewmodel/MainViewModel.kt @@ -23,6 +23,7 @@ import xyz.zarazaex.olc.dto.TestServiceMessage import xyz.zarazaex.olc.extension.matchesPattern import xyz.zarazaex.olc.extension.serializable import xyz.zarazaex.olc.handler.AngConfigManager +import xyz.zarazaex.olc.handler.CountryDetector import xyz.zarazaex.olc.handler.MmkvManager import xyz.zarazaex.olc.handler.SettingsManager import xyz.zarazaex.olc.handler.SpeedtestManager @@ -38,14 +39,18 @@ import java.util.Collections import java.util.regex.PatternSyntaxException class MainViewModel(application: Application) : AndroidViewModel(application) { - private var serverList = mutableListOf() // MmkvManager.decodeServerList() + private var serverList = mutableListOf() var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty() var keywordFilter = "" + /** ISO codes to show (empty = show all) */ + var countryFilter: Set = MmkvManager.getCountryFilter() + private set val serversCache = mutableListOf() val isRunning by lazy { MutableLiveData() } val updateListAction by lazy { MutableLiveData() } val updateTestResultAction by lazy { MutableLiveData() } val liteTestFinished = MutableLiveData() + val isTesting by lazy { MutableLiveData().also { it.value = false } } private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) } /** @@ -158,10 +163,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val searchRegex = try { if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null } catch (e: PatternSyntaxException) { - null // Fallback to literal search if regex is invalid + null } + val activeCountryFilter = countryFilter for (guid in serverList) { val profile = MmkvManager.decodeServerConfig(guid) ?: continue + + // Country filter + if (activeCountryFilter.isNotEmpty()) { + val code = CountryDetector.getCountryCode(profile.remarks, profile.server) + if (code !in activeCountryFilter) continue + } + if (kw.isEmpty()) { serversCache.add(ServersCache(guid, profile)) continue @@ -181,6 +194,42 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + /** Sets a new country filter and reloads list. Pass empty set to show all. */ + fun applyCountryFilter(codes: Set) { + countryFilter = codes + MmkvManager.setCountryFilter(codes) + reloadServerList() + } + + /** + * Returns all known countries from the current full server list (for showing in filter dialog). + * Key = ISO code, Value = human-readable name + flag. + */ + fun collectAllCountries(): Map { + val result = mutableMapOf() + for (guid in serverList) { + val profile = MmkvManager.decodeServerConfig(guid) ?: continue + val code = CountryDetector.getCountryCode(profile.remarks, profile.server) + if (code != CountryDetector.UNKNOWN) { + result[code] = "${CountryDetector.codeToFlag(code)} ${CountryDetector.codeToName(code)}" + } + } + return result.toSortedMap() + } + + /** Trigger background geo-lookup for IPs not yet cached. */ + fun refreshCountryCache() { + viewModelScope.launch(Dispatchers.IO) { + val ips = serverList.mapNotNull { + MmkvManager.decodeServerConfig(it)?.server?.trim() + }.distinct() + CountryDetector.lookupAndCacheAll(ips) + withContext(Dispatchers.Main) { + reloadServerList() + } + } + } + /** * Updates the configuration via subscription for all servers. * @return Detailed result of the subscription update operation. @@ -191,27 +240,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } else if (subscriptionId.startsWith("group_")) { val allSubs = MmkvManager.decodeSubscriptions() val groupSubs = when (subscriptionId) { - "group_white" -> allSubs.filter { - it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) || + "group_white" -> allSubs.filter { + it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) || it.subscription.remarks.startsWith("WHITE", ignoreCase = true) } - "group_black" -> allSubs.filter { - it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) || + "group_black" -> allSubs.filter { + it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) || it.subscription.remarks.startsWith("BLACK", ignoreCase = true) } else -> emptyList() } - var totalResult = SubscriptionUpdateResult() - groupSubs.forEach { sub -> - val result = AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription)) - totalResult = SubscriptionUpdateResult( - configCount = totalResult.configCount + result.configCount, - successCount = totalResult.successCount + result.successCount, - failureCount = totalResult.failureCount + result.failureCount, - skipCount = totalResult.skipCount + result.skipCount - ) + // Parallel fetch for group subs (sequential, called from IO context) + return groupSubs.fold(SubscriptionUpdateResult()) { acc, sub -> + acc + AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription)) } - return totalResult } else { val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult() return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem)) @@ -263,6 +305,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Cancels all running ping tests. + */ + fun cancelAllTests() { + MessageUtil.sendMsg2TestService( + getApplication(), + TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL) + ) + isTesting.value = false + } + /** * Tests the real ping for all servers. */ @@ -271,35 +324,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { getApplication(), TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL) ) - MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) - updateListAction.value = -1 - viewModelScope.launch(Dispatchers.Default) { - if (serversCache.isEmpty()) { - withContext(Dispatchers.Main) { reloadServerList() } - } - if (serversCache.isEmpty()) { - return@launch - } - - val actualSubId = if (subscriptionId.startsWith("group_")) { - "" - } else { - subscriptionId - } - - MessageUtil.sendMsg2TestService( - getApplication(), - TestServiceMessage( - key = AppConfig.MSG_MEASURE_CONFIG, - subscriptionId = actualSubId, - serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) { - serversCache.map { it.guid } - } else { - emptyList() + // Auto-deduplicate by IP before scanning so we don't waste time on dupes + viewModelScope.launch(Dispatchers.IO) { + val removed = removeDuplicateByIpAll() + withContext(Dispatchers.Main) { + if (removed > 0) { + reloadServerList() + } + MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) + updateListAction.value = -1 + isTesting.value = true + + viewModelScope.launch(Dispatchers.Default) { + if (serversCache.isEmpty()) { + withContext(Dispatchers.Main) { reloadServerList() } } - ) - ) + if (serversCache.isEmpty()) { + withContext(Dispatchers.Main) { isTesting.value = false } + return@launch + } + + val actualSubId = if (subscriptionId.startsWith("group_")) "" else subscriptionId + + MessageUtil.sendMsg2TestService( + getApplication(), + TestServiceMessage( + key = AppConfig.MSG_MEASURE_CONFIG, + subscriptionId = actualSubId, + serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) { + serversCache.map { it.guid } + } else { + emptyList() + } + ) + ) + } + } } } @@ -436,6 +497,99 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return deleteServer.count() } + /** + * Removes servers with duplicate IP addresses (same `server` field), + * keeping the one with the best ping result (or the first encountered if untested). + * @return Number of removed servers. + */ + fun removeDuplicateByIp(): Int { + // Group all currently visible servers by their IP address + val byIp = LinkedHashMap>() + for (sc in serversCache) { + val ip = sc.profile.server?.trim()?.lowercase() ?: continue + byIp.getOrPut(ip) { mutableListOf() }.add(sc) + } + + val toDelete = mutableListOf() + for ((_, group) in byIp) { + if (group.size <= 1) continue + val best = group.minWithOrNull(compareBy( + { !it.profile.isFavorite }, + { + val d = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L + when { + d > 0L -> d + d == 0L -> Long.MAX_VALUE - 1 + else -> Long.MAX_VALUE + } + } + ))!! + group.filter { it.guid != best.guid }.forEach { toDelete.add(it.guid) } + } + + for (guid in toDelete) { + MmkvManager.removeServer(guid) + } + return toDelete.size + } + + /** + * Removes duplicate servers by IP across ALL subscriptions (for use after sub update / before scan). + * Per-subscription deduplication: within each sub keeps the best (favorite > lowest ping > first). + */ + /** + * Removes servers with duplicate IP addresses across ALL subscriptions globally. + * Keeps the best one per IP (favorite > lowest ping > first encountered). + * @return Number of removed servers. + */ + fun removeDuplicateByIpAll(): Int { + // Collect every server GUID across all subscriptions + data class Entry(val guid: String, val ip: String, val isFav: Boolean) + + val allEntries = mutableListOf() + val allSubIds = MmkvManager.decodeSubsList().toMutableList() + // Add the default (no-sub) slot if not already present + if (!allSubIds.contains(AppConfig.DEFAULT_SUBSCRIPTION_ID)) { + allSubIds.add(0, AppConfig.DEFAULT_SUBSCRIPTION_ID) + } + + for (subId in allSubIds) { + for (guid in MmkvManager.decodeServerList(subId)) { + val profile = MmkvManager.decodeServerConfig(guid) ?: continue + val ip = profile.server?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: continue + allEntries.add(Entry(guid, ip, profile.isFavorite)) + } + } + + // Group by IP globally + val byIp = LinkedHashMap>() + for (e in allEntries) { + byIp.getOrPut(e.ip) { mutableListOf() }.add(e) + } + + val toDelete = mutableListOf() + for ((_, group) in byIp) { + if (group.size <= 1) continue + val best = group.minWith(compareBy( + { !it.isFav }, + { + val d = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L + when { + d > 0L -> d + d == 0L -> Long.MAX_VALUE - 1 + else -> Long.MAX_VALUE + } + } + )) + group.filter { it.guid != best.guid }.forEach { toDelete.add(it.guid) } + } + + for (guid in toDelete) { + MmkvManager.removeServer(guid) + } + return toDelete.size + } + /** * Removes all servers. * @return The number of removed servers. @@ -457,6 +611,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** * Sorts servers by their test results. */ + /** + * Sorts serversCache in-place by test delay in real time (during a ping test). + * Favorites always come first, then sorted ascending by delay (failed/untested go to bottom). + */ + @Synchronized + fun sortServersCacheInPlace() { + serversCache.sortWith(compareBy( + { !it.profile.isFavorite }, + { + val delay = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L + when { + delay > 0L -> delay + delay == 0L -> Long.MAX_VALUE - 1 // untested + else -> Long.MAX_VALUE // failed + } + } + )) + } + fun sortByTestResults() { if (subscriptionId.isEmpty()) { MmkvManager.decodeSubsList().forEach { guid -> @@ -590,6 +763,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { withContext(Dispatchers.Main) { reloadServerList() + isTesting.value = false liteTestFinished.value = true liteTestFinished.value = false } @@ -626,7 +800,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> { val resultPair = intent.serializable>("content") ?: return MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second) - updateListAction.value = getPosition(resultPair.first) + sortServersCacheInPlace() + updateListAction.value = -1 } AppConfig.MSG_MEASURE_CONFIG_BATCH -> { @@ -634,6 +809,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { update.results.forEach { result -> MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay) } + sortServersCacheInPlace() updateListAction.value = -1 } @@ -647,6 +823,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val content = intent.getStringExtra("content") if (content == "0") { onTestsFinished() + } else { + // cancelled or finished with non-zero count still in queue — mark as not testing + isTesting.value = false } } } diff --git a/V2rayNG/app/src/main/res/menu/menu_main.xml b/V2rayNG/app/src/main/res/menu/menu_main.xml index d161dbf7..54a60820 100644 --- a/V2rayNG/app/src/main/res/menu/menu_main.xml +++ b/V2rayNG/app/src/main/res/menu/menu_main.xml @@ -13,6 +13,18 @@ android:title="Проверить задержку профилей" app:showAsAction="always" /> + + + +