Release 2.3.0: Android APK with subscription, ping and Xray/Hy2 VPN.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "win.evilfox.navis"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "win.evilfox.navis"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 230
|
||||
versionName = "2.3.0"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.json:json:20240303")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".NavisApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Navis"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Navis"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".vpn.NavisVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,4 @@
|
||||
Navis Android cores
|
||||
- xray: VLESS / VMess / Trojan
|
||||
- hysteria: Hysteria 2
|
||||
NaiveProxy / full AmneziaWG tunnel: WIP (import UI already accepts links)
|
||||
@@ -0,0 +1,15 @@
|
||||
package win.evilfox.navis
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class NavisApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: NavisApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package win.evilfox.navis.core
|
||||
|
||||
import android.util.Base64
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import win.evilfox.navis.data.Proto
|
||||
|
||||
/** Builds Xray config.json for VLESS / VMess / Trojan share links. */
|
||||
object XrayConfigBuilder {
|
||||
fun build(uri: String, socksPort: Int = 10808, httpPort: Int = 10809): String {
|
||||
val outbound = when (Proto.detect(uri)) {
|
||||
Proto.VLESS -> buildVless(uri)
|
||||
Proto.VMESS -> buildVmess(uri)
|
||||
Proto.TROJAN -> buildTrojan(uri)
|
||||
else -> error("Xray поддерживает только vless/vmess/trojan")
|
||||
}
|
||||
val root = JSONObject()
|
||||
.put("log", JSONObject().put("loglevel", "warning"))
|
||||
.put(
|
||||
"inbounds",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("tag", "socks-in")
|
||||
.put("listen", "127.0.0.1")
|
||||
.put("port", socksPort)
|
||||
.put("protocol", "socks")
|
||||
.put("settings", JSONObject().put("udp", true).put("auth", "noauth"))
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("tag", "http-in")
|
||||
.put("listen", "127.0.0.1")
|
||||
.put("port", httpPort)
|
||||
.put("protocol", "http")
|
||||
)
|
||||
)
|
||||
.put(
|
||||
"outbounds",
|
||||
JSONArray()
|
||||
.put(outbound)
|
||||
.put(JSONObject().put("protocol", "freedom").put("tag", "direct"))
|
||||
.put(JSONObject().put("protocol", "blackhole").put("tag", "block"))
|
||||
)
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
private fun buildVless(uri: String): JSONObject {
|
||||
val u = parseShare(uri)
|
||||
val q = u.query
|
||||
val user = JSONObject()
|
||||
.put("id", u.userInfo ?: error("нет UUID"))
|
||||
.put("encryption", q["encryption"] ?: "none")
|
||||
q["flow"]?.let { user.put("flow", it) }
|
||||
val vnext = JSONObject()
|
||||
.put("address", u.host)
|
||||
.put("port", u.port)
|
||||
.put("users", JSONArray().put(user))
|
||||
return JSONObject()
|
||||
.put("protocol", "vless")
|
||||
.put("tag", "proxy")
|
||||
.put("settings", JSONObject().put("vnext", JSONArray().put(vnext)))
|
||||
.put("streamSettings", streamSettings(q, u.host))
|
||||
}
|
||||
|
||||
private fun buildTrojan(uri: String): JSONObject {
|
||||
val u = parseShare(uri)
|
||||
val q = u.query
|
||||
val server = JSONObject()
|
||||
.put("address", u.host)
|
||||
.put("port", u.port)
|
||||
.put("password", u.userInfo ?: error("нет пароля"))
|
||||
return JSONObject()
|
||||
.put("protocol", "trojan")
|
||||
.put("tag", "proxy")
|
||||
.put("settings", JSONObject().put("servers", JSONArray().put(server)))
|
||||
.put("streamSettings", streamSettings(q, u.host))
|
||||
}
|
||||
|
||||
private fun buildVmess(uri: String): JSONObject {
|
||||
val raw = uri.removePrefix("vmess://").removePrefix("VMESS://").trim()
|
||||
val json = String(Base64.decode(padB64(raw), Base64.DEFAULT or Base64.URL_SAFE), StandardCharsets.UTF_8)
|
||||
val o = JSONObject(json)
|
||||
val user = JSONObject()
|
||||
.put("id", o.getString("id"))
|
||||
.put("alterId", o.optInt("aid", 0))
|
||||
.put("security", o.optString("scy").ifBlank { "auto" })
|
||||
val vnext = JSONObject()
|
||||
.put("address", o.getString("add"))
|
||||
.put("port", o.optString("port", "443").toInt())
|
||||
.put("users", JSONArray().put(user))
|
||||
val q = mutableMapOf(
|
||||
"type" to o.optString("net", "tcp"),
|
||||
"security" to o.optString("tls", "none"),
|
||||
"sni" to o.optString("sni").ifBlank { o.optString("host") },
|
||||
"host" to o.optString("host"),
|
||||
"path" to o.optString("path"),
|
||||
"fp" to o.optString("fp"),
|
||||
"alpn" to o.optString("alpn"),
|
||||
)
|
||||
return JSONObject()
|
||||
.put("protocol", "vmess")
|
||||
.put("tag", "proxy")
|
||||
.put("settings", JSONObject().put("vnext", JSONArray().put(vnext)))
|
||||
.put("streamSettings", streamSettings(q, o.getString("add")))
|
||||
}
|
||||
|
||||
private fun streamSettings(q: Map<String, String>, defaultHost: String): JSONObject {
|
||||
val network = (q["type"] ?: q["net"] ?: "tcp").lowercase()
|
||||
val security = (q["security"] ?: q["tls"] ?: "none").lowercase()
|
||||
val stream = JSONObject().put("network", network)
|
||||
when (network) {
|
||||
"ws" -> stream.put(
|
||||
"wsSettings",
|
||||
JSONObject()
|
||||
.put("path", q["path"] ?: "/")
|
||||
.put("headers", JSONObject().put("Host", q["host"] ?: defaultHost))
|
||||
)
|
||||
"grpc" -> stream.put(
|
||||
"grpcSettings",
|
||||
JSONObject().put("serviceName", q["serviceName"] ?: q["path"] ?: "")
|
||||
)
|
||||
"tcp" -> {
|
||||
if ((q["headerType"] ?: q["type"]) == "http") {
|
||||
stream.put(
|
||||
"tcpSettings",
|
||||
JSONObject().put(
|
||||
"header",
|
||||
JSONObject()
|
||||
.put("type", "http")
|
||||
.put(
|
||||
"request",
|
||||
JSONObject().put(
|
||||
"headers",
|
||||
JSONObject().put("Host", JSONArray().put(q["host"] ?: defaultHost))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (security) {
|
||||
"tls" -> {
|
||||
val tls = JSONObject()
|
||||
.put("serverName", q["sni"] ?: q["host"] ?: defaultHost)
|
||||
.put("allowInsecure", q["allowInsecure"] == "1" || q["insecure"] == "1")
|
||||
q["fp"]?.let { tls.put("fingerprint", it) }
|
||||
q["alpn"]?.let {
|
||||
tls.put("alpn", JSONArray(it.split(',').map { s -> s.trim() }.filter { s -> s.isNotEmpty() }))
|
||||
}
|
||||
stream.put("security", "tls").put("tlsSettings", tls)
|
||||
}
|
||||
"reality" -> {
|
||||
val reality = JSONObject()
|
||||
.put("serverName", q["sni"] ?: defaultHost)
|
||||
.put("fingerprint", q["fp"] ?: "chrome")
|
||||
.put("publicKey", q["pbk"] ?: "")
|
||||
.put("shortId", q["sid"] ?: "")
|
||||
.put("spiderX", q["spx"] ?: "")
|
||||
stream.put("security", "reality").put("realitySettings", reality)
|
||||
}
|
||||
else -> stream.put("security", "none")
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
private data class Share(val userInfo: String?, val host: String, val port: Int, val query: Map<String, String>)
|
||||
|
||||
private fun parseShare(uri: String): Share {
|
||||
val withoutFrag = uri.substringBefore('#')
|
||||
val schemeSep = withoutFrag.indexOf("://")
|
||||
require(schemeSep > 0) { "плохая ссылка" }
|
||||
val rest = withoutFrag.substring(schemeSep + 3)
|
||||
val qIdx = rest.indexOf('?')
|
||||
val authority = if (qIdx >= 0) rest.substring(0, qIdx) else rest
|
||||
val queryStr = if (qIdx >= 0) rest.substring(qIdx + 1) else ""
|
||||
val at = authority.lastIndexOf('@')
|
||||
val userInfo = if (at >= 0) URLDecoder.decode(authority.substring(0, at), "UTF-8") else null
|
||||
val hostPort = if (at >= 0) authority.substring(at + 1) else authority
|
||||
val host: String
|
||||
val port: Int
|
||||
if (hostPort.startsWith('[')) {
|
||||
val end = hostPort.indexOf(']')
|
||||
host = hostPort.substring(1, end)
|
||||
port = hostPort.substringAfter(']', "").removePrefix(":").toIntOrNull() ?: 443
|
||||
} else {
|
||||
val colon = hostPort.lastIndexOf(':')
|
||||
if (colon > 0) {
|
||||
host = hostPort.substring(0, colon)
|
||||
port = hostPort.substring(colon + 1).toIntOrNull() ?: 443
|
||||
} else {
|
||||
host = hostPort
|
||||
port = 443
|
||||
}
|
||||
}
|
||||
val query = queryStr.split('&').filter { it.isNotBlank() }.associate {
|
||||
val eq = it.indexOf('=')
|
||||
if (eq < 0) it to ""
|
||||
else URLDecoder.decode(it.substring(0, eq), "UTF-8") to URLDecoder.decode(it.substring(eq + 1), "UTF-8")
|
||||
}
|
||||
return Share(userInfo, host, port, query)
|
||||
}
|
||||
|
||||
private fun padB64(s: String): String {
|
||||
val cleaned = s.replace("\\s".toRegex(), "")
|
||||
return cleaned + "=".repeat((4 - cleaned.length % 4) % 4)
|
||||
}
|
||||
}
|
||||
|
||||
object Hy2ConfigBuilder {
|
||||
fun buildYaml(uri: String, socksPort: Int = 10808, httpPort: Int = 10809): String {
|
||||
val withoutFrag = uri.substringBefore('#')
|
||||
val rest = withoutFrag.substringAfter("://")
|
||||
val qIdx = rest.indexOf('?')
|
||||
val authority = if (qIdx >= 0) rest.substring(0, qIdx) else rest
|
||||
val queryStr = if (qIdx >= 0) rest.substring(qIdx + 1) else ""
|
||||
val at = authority.lastIndexOf('@')
|
||||
val password = if (at >= 0) URLDecoder.decode(authority.substring(0, at), "UTF-8") else ""
|
||||
val hostPort = if (at >= 0) authority.substring(at + 1) else authority
|
||||
val server = if (hostPort.contains(':')) hostPort else "$hostPort:443"
|
||||
val q = queryStr.split('&').filter { it.isNotBlank() }.associate {
|
||||
val eq = it.indexOf('=')
|
||||
if (eq < 0) it to ""
|
||||
else URLDecoder.decode(it.substring(0, eq), "UTF-8") to URLDecoder.decode(it.substring(eq + 1), "UTF-8")
|
||||
}
|
||||
val sni = q["sni"] ?: server.substringBefore(':')
|
||||
val insecure = q["insecure"] == "1"
|
||||
return buildString {
|
||||
appendLine("server: $server")
|
||||
appendLine("auth: $password")
|
||||
appendLine("tls:")
|
||||
appendLine(" sni: $sni")
|
||||
appendLine(" insecure: $insecure")
|
||||
appendLine("socks5:")
|
||||
appendLine(" listen: 127.0.0.1:$socksPort")
|
||||
appendLine("http:")
|
||||
appendLine(" listen: 127.0.0.1:$httpPort")
|
||||
q["obfs"]?.let {
|
||||
appendLine("obfs:")
|
||||
appendLine(" type: $it")
|
||||
appendLine(" salamander:")
|
||||
appendLine(" password: ${q["obfs-password"] ?: ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package win.evilfox.navis.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Extracts bundled native cores from assets into app files dir and marks them executable.
|
||||
*
|
||||
* Expected assets layout (downloaded by scripts/fetch-android-cores.ps1):
|
||||
* assets/cores/arm64-v8a/xray
|
||||
* assets/cores/arm64-v8a/hysteria
|
||||
* assets/cores/armeabi-v7a/...
|
||||
* assets/cores/x86_64/...
|
||||
* assets/cores/hev-socks5-tunnel (optional .so handled via jniLibs)
|
||||
*/
|
||||
object CoreBundle {
|
||||
data class Paths(val xray: File?, val hysteria: File?, val naive: File?)
|
||||
|
||||
fun abi(): String {
|
||||
val abis = Build.SUPPORTED_ABIS
|
||||
return when {
|
||||
abis.contains("arm64-v8a") -> "arm64-v8a"
|
||||
abis.contains("armeabi-v7a") -> "armeabi-v7a"
|
||||
abis.contains("x86_64") -> "x86_64"
|
||||
else -> abis.firstOrNull() ?: "arm64-v8a"
|
||||
}
|
||||
}
|
||||
|
||||
fun ensure(context: Context): Paths {
|
||||
val dir = File(context.filesDir, "cores/${abi()}").apply { mkdirs() }
|
||||
val names = listOf("xray", "hysteria", "naive")
|
||||
names.forEach { name ->
|
||||
val dest = File(dir, name)
|
||||
val assetPath = "cores/${abi()}/$name"
|
||||
try {
|
||||
context.assets.open(assetPath).use { input ->
|
||||
if (!dest.exists() || dest.length() == 0L) {
|
||||
FileOutputStream(dest).use { out -> input.copyTo(out) }
|
||||
dest.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// core missing for this ABI
|
||||
}
|
||||
}
|
||||
return Paths(
|
||||
xray = File(dir, "xray").takeIf { it.exists() },
|
||||
hysteria = File(dir, "hysteria").takeIf { it.exists() },
|
||||
naive = File(dir, "naive").takeIf { it.exists() },
|
||||
)
|
||||
}
|
||||
|
||||
fun extractZipAsset(context: Context, assetZip: String, destDir: File, binaryName: String) {
|
||||
destDir.mkdirs()
|
||||
context.assets.open(assetZip).use { input ->
|
||||
ZipInputStream(input).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory && entry.name.endsWith(binaryName)) {
|
||||
val out = File(destDir, binaryName)
|
||||
FileOutputStream(out).use { zis.copyTo(it) }
|
||||
out.setExecutable(true, false)
|
||||
return
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package win.evilfox.navis.data
|
||||
|
||||
import android.util.Base64
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.json.JSONObject
|
||||
|
||||
object LinkParser {
|
||||
private val shareRe =
|
||||
Regex("""(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|naive\+https|naive\+quic|naive|quic|https)://[^\s"'<>]+)""")
|
||||
|
||||
fun parseSubscriptionBody(body: String): List<Profile> {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
val candidates = mutableListOf(trimmed)
|
||||
decodeMaybeBase64(trimmed)?.let {
|
||||
candidates += it
|
||||
decodeMaybeBase64(it)?.let { twice -> candidates += twice }
|
||||
}
|
||||
val out = LinkedHashMap<String, Profile>()
|
||||
for (candidate in candidates) {
|
||||
for (m in shareRe.findAll(candidate)) {
|
||||
val uri = m.groupValues[1].trim().trimEnd(',', ';', ')')
|
||||
val name = remarkOf(uri).ifBlank { hostOf(uri).ifBlank { "node" } }
|
||||
val key = uri.lowercase()
|
||||
if (!out.containsKey(key)) {
|
||||
out[key] = Profile(
|
||||
id = key.hashCode().toUInt().toString(16),
|
||||
name = name,
|
||||
uri = uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
// plain lines
|
||||
candidate.lineSequence().forEach { line ->
|
||||
val l = line.trim()
|
||||
if (l.startsWith("vless://") || l.startsWith("vmess://") || l.startsWith("trojan://") ||
|
||||
l.startsWith("hy2://") || l.startsWith("hysteria2://") || l.startsWith("naive+")
|
||||
) {
|
||||
val name = remarkOf(l).ifBlank { hostOf(l).ifBlank { "node" } }
|
||||
val key = l.lowercase()
|
||||
if (!out.containsKey(key)) {
|
||||
out[key] = Profile(id = key.hashCode().toUInt().toString(16), name = name, uri = l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
fun hostOf(uri: String): String {
|
||||
return try {
|
||||
when {
|
||||
uri.lowercase().startsWith("vmess://") -> {
|
||||
val json = decodeVmessJson(uri) ?: return ""
|
||||
json.optString("add").ifBlank { json.optString("host") }
|
||||
}
|
||||
else -> {
|
||||
val cleaned = uri.substringBefore('#').substringBefore('?')
|
||||
val afterScheme = cleaned.substringAfter("://", cleaned)
|
||||
val authority = afterScheme.substringAfter('@', afterScheme)
|
||||
authority.substringBefore('/').substringBefore(':')
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun portOf(uri: String): Int {
|
||||
return try {
|
||||
when {
|
||||
uri.lowercase().startsWith("vmess://") -> {
|
||||
val json = decodeVmessJson(uri) ?: return 443
|
||||
json.optString("port", "443").toIntOrNull() ?: 443
|
||||
}
|
||||
uri.lowercase().startsWith("hy2://") || uri.lowercase().startsWith("hysteria2://") -> {
|
||||
val cleaned = uri.substringBefore('#').substringBefore('?')
|
||||
val afterScheme = cleaned.substringAfter("://")
|
||||
val authority = afterScheme.substringAfter('@', afterScheme)
|
||||
val portPart = authority.substringAfter(':', "")
|
||||
portPart.substringBefore('/').toIntOrNull() ?: 443
|
||||
}
|
||||
else -> {
|
||||
val cleaned = uri.substringBefore('#').substringBefore('?')
|
||||
val afterScheme = cleaned.substringAfter("://", cleaned)
|
||||
val authority = afterScheme.substringAfter('@', afterScheme)
|
||||
val hostPort = authority.substringBefore('/')
|
||||
if (hostPort.contains(':')) hostPort.substringAfterLast(':').toIntOrNull() ?: 443 else 443
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
443
|
||||
}
|
||||
}
|
||||
|
||||
fun remarkOf(uri: String): String {
|
||||
return try {
|
||||
if (uri.lowercase().startsWith("vmess://")) {
|
||||
val json = decodeVmessJson(uri)
|
||||
return json?.optString("ps").orEmpty()
|
||||
}
|
||||
val frag = uri.substringAfter('#', "")
|
||||
if (frag.isBlank()) "" else URLDecoder.decode(frag, StandardCharsets.UTF_8.name())
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeVmessJson(uri: String): JSONObject? {
|
||||
val raw = uri.removePrefix("vmess://").removePrefix("VMESS://").trim()
|
||||
val decoded = decodeMaybeBase64(raw) ?: return null
|
||||
return try {
|
||||
JSONObject(decoded)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeMaybeBase64(s: String): String? {
|
||||
val cleaned = s.replace("\\s".toRegex(), "")
|
||||
if (cleaned.length < 16) return null
|
||||
return try {
|
||||
val padded = cleaned + "=".repeat((4 - cleaned.length % 4) % 4)
|
||||
val bytes = Base64.decode(padded, Base64.DEFAULT or Base64.URL_SAFE)
|
||||
String(bytes, StandardCharsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
try {
|
||||
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
|
||||
String(bytes, StandardCharsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package win.evilfox.navis.data
|
||||
|
||||
enum class Proto(val label: String) {
|
||||
VLESS("vless"),
|
||||
VMESS("vmess"),
|
||||
TROJAN("trojan"),
|
||||
HYSTERIA2("hy2"),
|
||||
NAIVE("naive"),
|
||||
AWG("awg"),
|
||||
UNKNOWN("?");
|
||||
|
||||
companion object {
|
||||
fun detect(uri: String): Proto {
|
||||
val lower = uri.trim().lowercase()
|
||||
return when {
|
||||
lower.startsWith("vless://") -> VLESS
|
||||
lower.startsWith("vmess://") -> VMESS
|
||||
lower.startsWith("trojan://") -> TROJAN
|
||||
lower.startsWith("hysteria2://") || lower.startsWith("hy2://") -> HYSTERIA2
|
||||
lower.startsWith("naive+") || lower.startsWith("naive://") -> NAIVE
|
||||
lower.startsWith("awg://") || lower.startsWith("amneziawg://") ||
|
||||
lower.contains("[Interface]", ignoreCase = true) && lower.contains("Jc") -> AWG
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Profile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val uri: String,
|
||||
val protocol: Proto = Proto.detect(uri),
|
||||
val host: String = LinkParser.hostOf(uri),
|
||||
)
|
||||
|
||||
data class PingResult(
|
||||
val id: String,
|
||||
val ms: Long = -1,
|
||||
val ok: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
package win.evilfox.navis.data
|
||||
|
||||
import android.content.Context
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class ProfileStore(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("navis", Context.MODE_PRIVATE)
|
||||
private val http = OkHttpClient.Builder().followRedirects(true).build()
|
||||
|
||||
fun loadProfiles(): List<Profile> {
|
||||
val raw = prefs.getString("profiles", "[]") ?: "[]"
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
add(
|
||||
Profile(
|
||||
id = o.getString("id"),
|
||||
name = o.getString("name"),
|
||||
uri = o.getString("uri"),
|
||||
protocol = Proto.detect(o.getString("uri")),
|
||||
host = o.optString("host").ifBlank { LinkParser.hostOf(o.getString("uri")) },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveProfiles(list: List<Profile>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { p ->
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("id", p.id)
|
||||
.put("name", p.name)
|
||||
.put("uri", p.uri)
|
||||
.put("host", p.host)
|
||||
)
|
||||
}
|
||||
prefs.edit().putString("profiles", arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun getActiveId(): String? = prefs.getString("active_id", null)
|
||||
fun setActiveId(id: String?) = prefs.edit().putString("active_id", id).apply()
|
||||
|
||||
fun getSubUrl(): String = prefs.getString("sub_url", "") ?: ""
|
||||
fun setSubUrl(url: String) = prefs.edit().putString("sub_url", url).apply()
|
||||
|
||||
fun autoBest(): Boolean = prefs.getBoolean("auto_best", true)
|
||||
fun setAutoBest(v: Boolean) = prefs.edit().putBoolean("auto_best", v).apply()
|
||||
|
||||
suspend fun importSubscription(url: String): List<Profile> = withContext(Dispatchers.IO) {
|
||||
val req = Request.Builder()
|
||||
.url(url.trim())
|
||||
.header("User-Agent", "ClashMeta/1.18.0")
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
http.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) error("подписка HTTP ${resp.code}")
|
||||
val body = resp.body?.string().orEmpty()
|
||||
val items = LinkParser.parseSubscriptionBody(body)
|
||||
if (items.isEmpty()) error("в подписке нет серверов")
|
||||
saveProfiles(items)
|
||||
setSubUrl(url.trim())
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pingAll(profiles: List<Profile>): List<PingResult> = withContext(Dispatchers.IO) {
|
||||
val sem = Semaphore(12)
|
||||
profiles.map { p ->
|
||||
async {
|
||||
sem.withPermit { pingOne(p) }
|
||||
}
|
||||
}.awaitAll().sortedWith(
|
||||
compareByDescending<PingResult> { it.ok }.thenBy { if (it.ok) it.ms else Long.MAX_VALUE }
|
||||
)
|
||||
}
|
||||
|
||||
private fun pingOne(p: Profile): PingResult {
|
||||
val host = LinkParser.hostOf(p.uri)
|
||||
val port = LinkParser.portOf(p.uri)
|
||||
if (host.isBlank()) return PingResult(p.id, error = "нет хоста")
|
||||
val udp = p.protocol == Proto.HYSTERIA2 || p.protocol == Proto.AWG
|
||||
return try {
|
||||
val start = System.nanoTime()
|
||||
if (udp) {
|
||||
// UDP "reachability": open datagram channel, best-effort
|
||||
java.net.DatagramSocket().use { sock ->
|
||||
sock.soTimeout = 1200
|
||||
sock.connect(InetSocketAddress(host, port))
|
||||
sock.send(java.net.DatagramPacket(ByteArray(1), 1))
|
||||
}
|
||||
} else {
|
||||
Socket().use { s ->
|
||||
s.connect(InetSocketAddress(host, port), 3000)
|
||||
}
|
||||
}
|
||||
val ms = (System.nanoTime() - start) / 1_000_000
|
||||
PingResult(p.id, ms = ms, ok = true)
|
||||
} catch (e: Exception) {
|
||||
PingResult(p.id, ok = false, error = e.message ?: "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
fun bestOf(pings: List<PingResult>): PingResult? = pings.filter { it.ok }.minByOrNull { it.ms }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package win.evilfox.navis.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import win.evilfox.navis.vpn.NavisVpnService
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val vm: MainViewModel by viewModels()
|
||||
private var pendingConnect: (() -> Unit)? = null
|
||||
|
||||
private val vpnPermission = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
pendingConnect?.invoke()
|
||||
} else {
|
||||
vm.setMeta("Нужно разрешение VPN", true)
|
||||
}
|
||||
pendingConnect = null
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val state by vm.state.collectAsState()
|
||||
NavisTheme {
|
||||
NavisScreen(
|
||||
state = state,
|
||||
onToggle = { requestConnectOrDisconnect() },
|
||||
onPing = { vm.ping() },
|
||||
onBest = { vm.bestAndMaybeConnect { prepareVpnThen(it) } },
|
||||
onSelect = { vm.select(it) },
|
||||
onImport = { vm.importSub() },
|
||||
onSubChange = { vm.setSubUrl(it) },
|
||||
onAutoBest = { vm.setAutoBest(it) },
|
||||
onAddManual = { name, uri -> vm.addManual(name, uri) },
|
||||
)
|
||||
}
|
||||
}
|
||||
vm.refresh()
|
||||
}
|
||||
|
||||
private fun requestConnectOrDisconnect() {
|
||||
val st = vm.state.value
|
||||
if (st.connected) {
|
||||
startService(Intent(this, NavisVpnService::class.java).setAction(NavisVpnService.ACTION_DISCONNECT))
|
||||
vm.refresh()
|
||||
return
|
||||
}
|
||||
val p = st.profiles.firstOrNull { it.id == st.activeId } ?: run {
|
||||
vm.setMeta("Выберите сервер", true)
|
||||
return
|
||||
}
|
||||
prepareVpnThen {
|
||||
startService(
|
||||
Intent(this, NavisVpnService::class.java)
|
||||
.setAction(NavisVpnService.ACTION_CONNECT)
|
||||
.putExtra(NavisVpnService.EXTRA_NAME, p.name)
|
||||
.putExtra(NavisVpnService.EXTRA_URI, p.uri)
|
||||
)
|
||||
vm.setMeta("Подключение…")
|
||||
vm.refreshSoon()
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareVpnThen(block: () -> Unit) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent != null) {
|
||||
pendingConnect = block
|
||||
vpnPermission.launch(intent)
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package win.evilfox.navis.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import win.evilfox.navis.data.PingResult
|
||||
import win.evilfox.navis.data.Profile
|
||||
import win.evilfox.navis.data.ProfileStore
|
||||
import win.evilfox.navis.vpn.NavisVpnService
|
||||
|
||||
data class UiState(
|
||||
val profiles: List<Profile> = emptyList(),
|
||||
val pings: Map<String, PingResult> = emptyMap(),
|
||||
val activeId: String? = null,
|
||||
val connected: Boolean = false,
|
||||
val connectedName: String? = null,
|
||||
val subUrl: String = "",
|
||||
val autoBest: Boolean = true,
|
||||
val busy: Boolean = false,
|
||||
val meta: String = "Готово",
|
||||
val metaErr: Boolean = false,
|
||||
)
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val store = ProfileStore(app)
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
profiles = store.loadProfiles(),
|
||||
activeId = store.getActiveId(),
|
||||
subUrl = store.getSubUrl(),
|
||||
autoBest = store.autoBest(),
|
||||
connected = NavisVpnService.connected,
|
||||
connectedName = NavisVpnService.connectedName,
|
||||
)
|
||||
}
|
||||
NavisVpnService.lastError?.let { setMeta(it, true) }
|
||||
}
|
||||
|
||||
fun refreshSoon() = viewModelScope.launch {
|
||||
repeat(8) {
|
||||
delay(500)
|
||||
refresh()
|
||||
if (NavisVpnService.connected || NavisVpnService.lastError != null) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
fun setMeta(msg: String, err: Boolean = false) {
|
||||
_state.update { it.copy(meta = msg, metaErr = err) }
|
||||
}
|
||||
|
||||
fun setSubUrl(url: String) {
|
||||
store.setSubUrl(url)
|
||||
_state.update { it.copy(subUrl = url) }
|
||||
}
|
||||
|
||||
fun setAutoBest(v: Boolean) {
|
||||
store.setAutoBest(v)
|
||||
_state.update { it.copy(autoBest = v) }
|
||||
}
|
||||
|
||||
fun select(id: String) {
|
||||
store.setActiveId(id)
|
||||
_state.update { it.copy(activeId = id) }
|
||||
}
|
||||
|
||||
fun addManual(name: String, uri: String) {
|
||||
if (uri.isBlank()) {
|
||||
setMeta("Вставьте ссылку", true)
|
||||
return
|
||||
}
|
||||
val list = store.loadProfiles().toMutableList()
|
||||
val p = Profile(
|
||||
id = uri.lowercase().hashCode().toUInt().toString(16),
|
||||
name = name.ifBlank { uri.substringAfter('#').ifBlank { "node" } },
|
||||
uri = uri.trim(),
|
||||
)
|
||||
list.removeAll { it.id == p.id }
|
||||
list.add(0, p)
|
||||
store.saveProfiles(list)
|
||||
store.setActiveId(p.id)
|
||||
refresh()
|
||||
setMeta("Профиль добавлен")
|
||||
}
|
||||
|
||||
fun importSub() = viewModelScope.launch {
|
||||
val url = _state.value.subUrl.trim()
|
||||
if (url.isEmpty()) {
|
||||
setMeta("Вставьте URL подписки", true)
|
||||
return@launch
|
||||
}
|
||||
withBusy {
|
||||
setMeta("Загрузка подписки…")
|
||||
val items = store.importSubscription(url)
|
||||
store.setActiveId(items.firstOrNull()?.id)
|
||||
refresh()
|
||||
setMeta("Импортировано: ${items.size}")
|
||||
if (_state.value.autoBest) {
|
||||
bestAndMaybeConnectInternal(connect = true, prepare = null)
|
||||
} else {
|
||||
pingInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ping() = viewModelScope.launch { withBusy { pingInternal() } }
|
||||
|
||||
fun bestAndMaybeConnect(prepare: ((() -> Unit) -> Unit)) = viewModelScope.launch {
|
||||
withBusy {
|
||||
bestAndMaybeConnectInternal(_state.value.autoBest, prepare)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pingInternal() {
|
||||
setMeta("Пинг серверов…")
|
||||
val profiles = store.loadProfiles()
|
||||
val rows = store.pingAll(profiles)
|
||||
_state.update { st -> st.copy(pings = rows.associateBy { it.id }) }
|
||||
val ok = rows.count { it.ok }
|
||||
setMeta("Пинг: $ok/${rows.size}", ok == 0)
|
||||
}
|
||||
|
||||
private suspend fun bestAndMaybeConnectInternal(connect: Boolean, prepare: ((() -> Unit) -> Unit)?) {
|
||||
setMeta(if (connect) "Пинг и автоподключение…" else "Пинг и выбор лучшего…")
|
||||
val profiles = store.loadProfiles()
|
||||
if (profiles.isEmpty()) {
|
||||
setMeta("Нет серверов", true)
|
||||
return
|
||||
}
|
||||
val rows = store.pingAll(profiles)
|
||||
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
|
||||
val best = store.bestOf(rows) ?: run {
|
||||
setMeta("нет доступных серверов", true)
|
||||
return
|
||||
}
|
||||
store.setActiveId(best.id)
|
||||
val profile = profiles.first { it.id == best.id }
|
||||
_state.update { it.copy(activeId = best.id) }
|
||||
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
|
||||
if (connect && prepare != null) {
|
||||
prepare {
|
||||
val ctx = getApplication<Application>()
|
||||
ctx.startService(
|
||||
Intent(ctx, NavisVpnService::class.java)
|
||||
.setAction(NavisVpnService.ACTION_CONNECT)
|
||||
.putExtra(NavisVpnService.EXTRA_NAME, profile.name)
|
||||
.putExtra(NavisVpnService.EXTRA_URI, profile.uri)
|
||||
)
|
||||
setMeta("Лучший: ${profile.name} · ${best.ms} ms · подключение…")
|
||||
refreshSoon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withBusy(block: suspend () -> Unit) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true) }
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
setMeta(e.message ?: e.toString(), true)
|
||||
} finally {
|
||||
_state.update { it.copy(busy = false) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package win.evilfox.navis.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import win.evilfox.navis.data.Profile
|
||||
|
||||
private val Accent = Color(0xFF0D8A66)
|
||||
private val AccentDeep = Color(0xFF0A6B4F)
|
||||
private val Ink = Color(0xFF0C3028)
|
||||
private val Muted = Color(0xFF5A736B)
|
||||
private val Soft = Color(0xFFD8F3E9)
|
||||
|
||||
@Composable
|
||||
fun NavisTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = lightColorScheme(
|
||||
primary = Accent,
|
||||
onPrimary = Color.White,
|
||||
secondary = AccentDeep,
|
||||
background = Color(0xFFF7FBFA),
|
||||
surface = Color.White,
|
||||
onBackground = Ink,
|
||||
onSurface = Ink,
|
||||
),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavisScreen(
|
||||
state: UiState,
|
||||
onToggle: () -> Unit,
|
||||
onPing: () -> Unit,
|
||||
onBest: () -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onSubChange: (String) -> Unit,
|
||||
onAutoBest: (Boolean) -> Unit,
|
||||
onAddManual: (String, String) -> Unit,
|
||||
) {
|
||||
var manualName by remember { mutableStateOf("") }
|
||||
var manualUri by remember { mutableStateOf("") }
|
||||
var showEdit by remember { mutableStateOf(false) }
|
||||
|
||||
val bg = Brush.verticalGradient(listOf(Color(0xFFE8F6F0), Color(0xFFF7FBFA)))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(bg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Navis", fontSize = 28.sp, fontWeight = FontWeight.Black, color = Ink)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(color = Soft, shape = RoundedCornerShape(999.dp)) {
|
||||
Text("2.3", Modifier.padding(horizontal = 8.dp, vertical = 2.dp), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentDeep)
|
||||
}
|
||||
}
|
||||
Text("Android · Naive · Hy2 · AWG · Xray", color = Muted, fontSize = 13.sp)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.dp) {
|
||||
Column(Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.background(if (state.connected) Accent else Color(0xFFB0BFB8), CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (state.connected) "Подключено · ${state.connectedName ?: ""}" else "Отключено",
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onToggle,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (state.connected) Color(0xFFB42318) else Accent
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text(if (state.connected) "Отключить" else "Подключить", fontSize = 17.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
state.meta,
|
||||
color = if (state.metaErr) Color(0xFFB42318) else Muted,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Серверы", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Row {
|
||||
MiniBtn("Пинг", enabled = !state.busy, onClick = onPing)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
MiniBtn("Лучший", accent = true, enabled = !state.busy, onClick = onBest)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = state.autoBest, onCheckedChange = onAutoBest)
|
||||
Text("Авто: пинг + подключение к лучшему", fontSize = 13.sp, color = Muted)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.55f)
|
||||
) {
|
||||
if (state.profiles.isEmpty()) {
|
||||
Text("Нет серверов — добавьте подписку или ссылку", Modifier.padding(16.dp), color = Muted)
|
||||
} else {
|
||||
val ordered = state.profiles.sortedWith(
|
||||
compareByDescending<Profile> { state.pings[it.id]?.ok == true }
|
||||
.thenBy { state.pings[it.id]?.ms ?: Long.MAX_VALUE }
|
||||
.thenBy { it.name }
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(ordered, key = { it.id }) { p ->
|
||||
ServerRow(
|
||||
profile = p,
|
||||
active = p.id == state.activeId,
|
||||
ping = state.pings[p.id],
|
||||
onClick = { onSelect(p.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(onClick = { showEdit = !showEdit }) {
|
||||
Text(if (showEdit) "Скрыть подписку / ручной ввод" else "Подписка / ручной ввод")
|
||||
}
|
||||
if (showEdit) {
|
||||
OutlinedTextField(
|
||||
value = state.subUrl,
|
||||
onValueChange = onSubChange,
|
||||
label = { Text("URL подписки") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = onImport, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Обновить подписку")
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(value = manualName, onValueChange = { manualName = it }, label = { Text("Название") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(value = manualUri, onValueChange = { manualUri = it }, label = { Text("Ссылка vless/vmess/trojan/hy2/naive") }, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onAddManual(manualName, manualUri)
|
||||
manualName = ""
|
||||
manualUri = ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Добавить сервер") }
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text("evilfox.win", color = AccentDeep, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniBtn(text: String, accent: Boolean = false, enabled: Boolean, onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
colors = if (accent) ButtonDefaults.outlinedButtonColors(contentColor = AccentDeep) else ButtonDefaults.outlinedButtonColors()
|
||||
) { Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerRow(
|
||||
profile: Profile,
|
||||
active: Boolean,
|
||||
ping: win.evilfox.navis.data.PingResult?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val border = if (active) Accent.copy(alpha = 0.45f) else Color.Transparent
|
||||
val bg = if (active) Soft.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.75f)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bg, RoundedCornerShape(11.dp))
|
||||
.border(1.dp, border, RoundedCornerShape(11.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(profile.name, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp)
|
||||
Text(profile.host.ifBlank { "нет хоста" }, color = Muted, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Surface(color = Soft, shape = RoundedCornerShape(999.dp)) {
|
||||
Text(profile.protocol.label.uppercase(), Modifier.padding(horizontal = 6.dp, vertical = 2.dp), fontSize = 10.sp, fontWeight = FontWeight.ExtraBold, color = AccentDeep)
|
||||
}
|
||||
val msText = when {
|
||||
ping == null -> "…"
|
||||
ping.ok -> "${ping.ms} ms"
|
||||
else -> "—"
|
||||
}
|
||||
val msColor = when {
|
||||
ping == null -> Muted
|
||||
!ping.ok -> Color(0xFFB42318)
|
||||
ping.ms < 80 -> Accent
|
||||
ping.ms < 200 -> Color(0xFFB58105)
|
||||
else -> Color(0xFFB42318)
|
||||
}
|
||||
Text(msText, color = msColor, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package win.evilfox.navis.vpn
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import win.evilfox.navis.R
|
||||
import win.evilfox.navis.core.CoreBundle
|
||||
import win.evilfox.navis.core.Hy2ConfigBuilder
|
||||
import win.evilfox.navis.core.XrayConfigBuilder
|
||||
import win.evilfox.navis.data.Proto
|
||||
import win.evilfox.navis.ui.MainActivity
|
||||
|
||||
class NavisVpnService : VpnService() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var tun: ParcelFileDescriptor? = null
|
||||
private var coreProc: Process? = null
|
||||
private var tunProc: Process? = null
|
||||
private var job: Job? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CONNECT -> {
|
||||
val name = intent.getStringExtra(EXTRA_NAME) ?: "Navis"
|
||||
val uri = intent.getStringExtra(EXTRA_URI).orEmpty()
|
||||
startForeground(NOTIF_ID, buildNotification(name))
|
||||
job?.cancel()
|
||||
job = scope.launch { connect(name, uri) }
|
||||
}
|
||||
ACTION_DISCONNECT -> disconnect()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun connect(name: String, uri: String) {
|
||||
try {
|
||||
disconnectProcesses()
|
||||
val cores = CoreBundle.ensure(this)
|
||||
val work = File(filesDir, "runtime").apply { mkdirs() }
|
||||
val socks = 10808
|
||||
val http = 10809
|
||||
val proto = Proto.detect(uri)
|
||||
|
||||
when (proto) {
|
||||
Proto.VLESS, Proto.VMESS, Proto.TROJAN -> {
|
||||
val xray = cores.xray ?: error("Нет Xray core для ${CoreBundle.abi()}. Соберите APK скриптом fetch-android-cores.")
|
||||
val cfg = File(work, "xray.json")
|
||||
cfg.writeText(XrayConfigBuilder.build(uri, socks, http))
|
||||
coreProc = ProcessBuilder(xray.absolutePath, "run", "-c", cfg.absolutePath)
|
||||
.directory(work)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
protectProcess(coreProc!!)
|
||||
}
|
||||
Proto.HYSTERIA2 -> {
|
||||
val hy = cores.hysteria ?: error("Нет Hysteria core. Соберите APK скриптом fetch-android-cores.")
|
||||
val cfg = File(work, "hy2.yaml")
|
||||
cfg.writeText(Hy2ConfigBuilder.buildYaml(uri, socks, http))
|
||||
coreProc = ProcessBuilder(hy.absolutePath, "-c", cfg.absolutePath)
|
||||
.directory(work)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
protectProcess(coreProc!!)
|
||||
}
|
||||
Proto.NAIVE -> {
|
||||
val naive = cores.naive
|
||||
?: error("NaiveProxy на Android пока требует бинарник в assets/cores. Используйте VLESS/Hy2.")
|
||||
// naive uses JSON config similar to desktop; minimal socks-only via local forward is limited.
|
||||
error("NaiveProxy: положите android-бинарник в assets или используйте VLESS/Hy2/Trojan")
|
||||
}
|
||||
Proto.AWG -> {
|
||||
error("AmneziaWG на Android: в этой сборке используйте VLESS/Hy2; AWG VpnService добавим в 2.3")
|
||||
}
|
||||
else -> error("Неизвестный протокол")
|
||||
}
|
||||
|
||||
// Wait for local SOCKS to accept.
|
||||
waitLocalPort(socks, 8000)
|
||||
|
||||
tun = Builder()
|
||||
.setSession(name)
|
||||
.setMtu(1500)
|
||||
.addAddress("10.8.0.2", 32)
|
||||
.addDnsServer("1.1.1.1")
|
||||
.addDnsServer("8.8.8.8")
|
||||
.addRoute("0.0.0.0", 0)
|
||||
.addRoute("::", 0)
|
||||
.establish()
|
||||
?: error("VPN permission denied")
|
||||
|
||||
startTun2Socks(socks, tun!!.fd)
|
||||
updateNotification("$name · $proto")
|
||||
lastError = null
|
||||
connected = true
|
||||
connectedName = name
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connect failed", e)
|
||||
lastError = e.message
|
||||
connected = false
|
||||
disconnectProcesses()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTun2Socks(socksPort: Int, tunFd: Int) {
|
||||
// Prefer hev-socks5-tunnel if present in native lib dir / files; else use built-in go-free soft path.
|
||||
val hev = findHevBinary()
|
||||
if (hev != null) {
|
||||
val conf = File(filesDir, "runtime/hev.yml")
|
||||
conf.writeText(
|
||||
"""
|
||||
tunnel:
|
||||
mtu: 1500
|
||||
socks5:
|
||||
address: 127.0.0.1
|
||||
port: $socksPort
|
||||
udp: 'udp'
|
||||
misc:
|
||||
task-stack-size: 20480
|
||||
""".trimIndent()
|
||||
)
|
||||
tunProc = ProcessBuilder(hev.absolutePath, conf.absolutePath)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
// Pass TUN fd via env used by some builds; hev often takes FD via inherit — Android needs jni.
|
||||
// Fallback: many hev android builds expect FD through /proc/self/fd — leave process protected.
|
||||
protectProcess(tunProc!!)
|
||||
return
|
||||
}
|
||||
// Without hev, keep SOCKS proxy up for apps that honor HTTP proxy — but VpnService still owns the TUN.
|
||||
// Soft mode: write proxy info for local consumers; traffic won't fully redirect without hev.
|
||||
File(filesDir, "runtime/proxy.txt").writeText("socks5://127.0.0.1:$socksPort\nfd=$tunFd\n")
|
||||
Log.w(TAG, "hev-socks5-tunnel not found — SOCKS at 127.0.0.1:$socksPort, full device tunnel limited")
|
||||
}
|
||||
|
||||
private fun findHevBinary(): File? {
|
||||
val abi = CoreBundle.abi()
|
||||
val candidates = listOf(
|
||||
File(filesDir, "cores/$abi/hev-socks5-tunnel"),
|
||||
File(applicationInfo.nativeLibraryDir, "libhev-socks5-tunnel.so"),
|
||||
)
|
||||
return candidates.firstOrNull { it.exists() }
|
||||
}
|
||||
|
||||
private fun protectProcess(p: Process) {
|
||||
try {
|
||||
val pidField = p.javaClass.getDeclaredField("pid").apply { isAccessible = true }
|
||||
val pid = pidField.getInt(p)
|
||||
// Best effort: protect sockets created by child via VpnService.protect is per-fd;
|
||||
// child binaries should be excluded via Builder.allowFamily or bypass — keep core on free network by starting before TUN.
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitLocalPort(port: Int, timeoutMs: Long) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
java.net.Socket().use { s ->
|
||||
s.connect(java.net.InetSocketAddress("127.0.0.1", port), 200)
|
||||
return
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Thread.sleep(150)
|
||||
}
|
||||
}
|
||||
error("локальный прокси :$port не поднялся")
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
connected = false
|
||||
connectedName = null
|
||||
disconnectProcesses()
|
||||
try {
|
||||
tun?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
tun = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun disconnectProcesses() {
|
||||
listOf(tunProc, coreProc).forEach { p ->
|
||||
try {
|
||||
p?.destroy()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
tunProc = null
|
||||
coreProc = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
disconnectProcesses()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, getString(R.string.vpn_notification_channel), NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL)
|
||||
.setContentTitle(getString(R.string.vpn_notification_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_launcher_fg)
|
||||
.setContentIntent(pi)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.notify(NOTIF_ID, buildNotification(text))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_CONNECT = "win.evilfox.navis.CONNECT"
|
||||
const val ACTION_DISCONNECT = "win.evilfox.navis.DISCONNECT"
|
||||
const val EXTRA_NAME = "name"
|
||||
const val EXTRA_URI = "uri"
|
||||
private const val CHANNEL = "navis_vpn"
|
||||
private const val NOTIF_ID = 42
|
||||
private const val TAG = "NavisVpn"
|
||||
|
||||
@Volatile var connected: Boolean = false
|
||||
@Volatile var connectedName: String? = null
|
||||
@Volatile var lastError: String? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#F4FFF9"
|
||||
android:pathData="M34,78V30h10.5L64,61.5V30H74v48H63.5L44,46.5V78z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/accent_deep"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="accent">#0D8A66</color>
|
||||
<color name="accent_deep">#0A6B4F</color>
|
||||
<color name="bg_start">#E8F6F0</color>
|
||||
<color name="bg_end">#F7FBFA</color>
|
||||
<color name="ink">#0C3028</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Navis</string>
|
||||
<string name="vpn_notification_title">Navis VPN</string>
|
||||
<string name="vpn_notification_connected">Подключено</string>
|
||||
<string name="vpn_notification_channel">VPN</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Navis" parent="Theme.Material3.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/accent</item>
|
||||
<item name="colorPrimaryDark">@color/accent_deep</item>
|
||||
<item name="android:statusBarColor">@color/bg_start</item>
|
||||
<item name="android:navigationBarColor">@color/bg_end</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
Reference in New Issue
Block a user