arblant
FoxyVPN-MultiAcc / app / src / main / java / com / vauth / foxyvpn / data / ServerListClient.kt
ServerListClient.kt175 lines · 6.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
package com.vauth.foxyvpn.data

import android.content.Context
import com.vauth.foxyvpn.data.model.ProxyCandidate
import com.vauth.foxyvpn.data.model.VpnCity
import com.vauth.foxyvpn.data.model.VpnCountry
import com.vauth.foxyvpn.data.model.VpnProtocol
import com.vauth.foxyvpn.data.model.VpnServerNode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.IOException

private const val TAG = "ServerListClient"

private const val REMOTE_SETTINGS_URL =
    "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/vpn-serverlist/records"

const val RECOMMENDED_COUNTRY_CODE = "REC"

private val EXCLUDED_COUNTRY_NAMES = setOf("CatchAll Anycast")

class ServerListClient(
    private val context: Context? = null,
    private val client: OkHttpClient = ControlPlaneHttp.client,
) {

    private val cacheFile: File?
        get() = context?.let { File(it.filesDir, "vpn_serverlist_cache.json") }

    suspend fun fetchCountries(): List<VpnCountry> = withContext(Dispatchers.IO) {
        val request = Request.Builder().url(REMOTE_SETTINGS_URL).applyMozillaVpnHeaders().build()
        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) {
                throw IOException("Remote Settings fetch failed: HTTP ${response.code}")
            }
            val raw = response.body?.string().orEmpty()
            val countries = parseRecords(raw)
            if (countries.isNotEmpty()) {
                saveCache(raw)
            }
            AppLogger.i(TAG, "fetched ${countries.size} countries from Remote Settings")
            countries
        }
    }

    fun loadCachedCountries(): List<VpnCountry> {
        val file = cacheFile ?: return emptyList()
        if (!file.exists() || !file.isFile) return emptyList()
        return runCatching {
            val raw = file.readText()
            val parsed = parseRecords(raw)
            if (parsed.isNotEmpty()) {
                AppLogger.d(TAG, "loaded ${parsed.size} countries from local cache")
            }
            parsed
        }.getOrElse {
            AppLogger.w(TAG, "failed to read server list cache", it)
            emptyList()
        }
    }

    private fun saveCache(rawJson: String) {
        val file = cacheFile ?: return
        runCatching {
            val tempFile = File.createTempFile("serverlist", ".tmp", file.parentFile)
            tempFile.writeText(rawJson)
            if (!tempFile.renameTo(file)) {
                tempFile.copyTo(file, overwrite = true)
                tempFile.delete()
            }
            AppLogger.d(TAG, "updated local server list cache")
        }.onFailure {
            AppLogger.w(TAG, "failed to save server list cache", it)
        }
    }

    private fun parseRecords(rawJson: String): List<VpnCountry> {
        if (rawJson.isBlank()) return emptyList()
        val body = JSONObject(rawJson)
        val records = body.optJSONArray("data") ?: JSONArray()
        val countries = mutableListOf<VpnCountry>()
        for (i in 0 until records.length()) {
            val record = records.getJSONObject(i)
            val countryJson = record.optJSONObject("country") ?: record
            val country = parseCountry(countryJson)
            val isExcluded = EXCLUDED_COUNTRY_NAMES.any { it.equals(country.name, ignoreCase = true) }
            if (country.code.isNotBlank() && country.cities.isNotEmpty() && !isExcluded) {
                countries.add(country)
            }
        }
        return countries
    }

    private fun parseCountry(json: JSONObject): VpnCountry = VpnCountry(
        name = json.optString("name"),
        code = json.optString("code"),
        cities = buildList {
            val cities = json.optJSONArray("cities") ?: JSONArray()
            for (i in 0 until cities.length()) add(parseCity(cities.getJSONObject(i)))
        },
    )

    private fun parseCity(json: JSONObject): VpnCity = VpnCity(
        name = json.optString("name"),
        code = json.optString("code"),
        servers = buildList {
            val servers = json.optJSONArray("servers") ?: JSONArray()
            for (i in 0 until servers.length()) add(parseServer(servers.getJSONObject(i)))
        },
    )

    private fun parseServer(json: JSONObject): VpnServerNode = VpnServerNode(
        hostname = json.optString("hostname"),
        port = json.optInt("port", 0),
        quarantined = json.optBoolean("quarantined", false),
        protocols = buildList {
            val protocols = json.optJSONArray("protocols") ?: JSONArray()
            for (i in 0 until protocols.length()) add(parseProtocol(protocols.getJSONObject(i)))
        },
    )

    private fun parseProtocol(json: JSONObject): VpnProtocol = VpnProtocol(
        name = json.optString("name"),
        host = json.optString("host"),
        port = json.optInt("port", 0),
        scheme = json.optString("scheme"),
        templateString = json.optString("templateString"),
    )

    companion object {

        fun defaultConnectTarget(server: VpnServerNode): Pair<String, Int>? {
            for (proto in server.protocols) {
                if (proto.name == "connect") {
                    val host = proto.host.ifBlank { server.hostname }
                    val port = if (proto.port != 0) proto.port else server.port
                    return host to port
                }
            }
            if (server.protocols.isEmpty()) return server.hostname to server.port
            return null
        }

        fun candidatesForCountry(countries: List<VpnCountry>, countryCode: String): List<ProxyCandidate> {
            val out = mutableListOf<ProxyCandidate>()
            for (country in countries) {
                if (!country.code.equals(countryCode, ignoreCase = true)) continue
                for (city in country.cities) {
                    for (server in city.servers) {
                        if (server.quarantined) continue
                        val target = defaultConnectTarget(server) ?: continue
                        out.add(
                            ProxyCandidate(
                                host = target.first,
                                port = target.second,
                                countryCode = country.code,
                                countryName = country.name,
                                cityCode = city.code,
                            ),
                        )
                    }
                }
            }
            return out
        }

        fun candidatesForCity(countries: List<VpnCountry>, countryCode: String, cityCode: String): List<ProxyCandidate> =
            candidatesForCountry(countries, countryCode).filter { it.cityCode.equals(cityCode, ignoreCase = true) }
    }
}