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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt
Original file line number Diff line number Diff line change
@@ -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<Long, String>() // profileId -> ISO 代码
private val byHost = ConcurrentHashMap<String, String>() // serverAddress -> ISO 代码
private val failedAt = ConcurrentHashMap<String, Long>() // 失败时间戳,冷却重试

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<ProxyEntity>) {
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')))
}
}
Loading