diff --git a/.gitignore b/.gitignore
index 43ce481..59e96fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,15 @@ Navis-pending.exe
*.bak
Navis.exe.bak
+# Android
+android/.gradle/
+android/local.properties
+android/**/build/
+android/app/src/main/assets/cores/**/xray
+android/app/src/main/assets/cores/**/hysteria
+android/app/src/main/assets/cores/**/naive
+android/app/src/main/assets/cores/**/hev-socks5-tunnel
+*.apk
+!dist/navis-release/android/*.apk
+
+
diff --git a/README.md b/README.md
index 5b5003f..cb0a5d7 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
| Windows amd64 | `Navis.exe` | GUI (WebView2) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | CLI / .app — **рекомендуется** |
| macOS arm64 | `Navis` · `Navis.dmg` · `Navis.app.zip` | CLI / .app |
-| macOS amd64 | `Navis` · `Navis.dmg` · `Navis.app.zip` | CLI / .app |
+| Android | `Navis.apk` | GUI (VpnService) |
## Скачать macOS прямо с git
@@ -180,6 +180,16 @@ cd ~/Navis
| amd64 | [Navis](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis) | [Navis.dmg](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg) | [Navis.app.zip](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip) |
| манифест | [update.json](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json) | | |
+## Android APK
+
+Сборка: `build-android.bat` → `dist/navis-release/android/Navis.apk`
+
+Установка: скопируйте APK на телефон → разрешите установку из неизвестных источников.
+
+В APK: подписка, компактный список нод, пинг, «Лучший», автоподключение. Протоколы VLESS/VMess/Trojan (Xray) и Hysteria 2. Naive/AWG — импорт в UI, полный туннель дорабатывается.
+
+Подробнее: [android/README.md](android/README.md)
+
## Сборка Windows
```bat
@@ -233,6 +243,9 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
+В 2.3.0:
+- Android APK: подписка, список нод, пинг, автолучший; VLESS/VMess/Trojan (Xray) и Hysteria 2 через VpnService.
+
В 2.2.1:
- универсальный macOS DMG (Intel + Apple Silicon), скрипт автоустановки, предупреждения при неверной архитектуре.
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..e701c95
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,28 @@
+# Navis Android
+
+Клиент с UI как на Windows 2.2: список нод, пинг, «лучший», автоподключение, подписка.
+
+Версия APK: **2.3.0**
+
+## Протоколы
+
+| Протокол | Статус в APK |
+|----------|--------------|
+| VLESS / VMess / Trojan (Xray) | SOCKS core + VpnService |
+| Hysteria 2 | SOCKS core + VpnService |
+| NaiveProxy | импорт в UI; Android Chromium-бинарник не бандлится |
+| AmneziaWG | импорт в UI; полный Tun — следующий релиз |
+
+## Сборка
+
+Нужны JDK 17+ и Android SDK (platform 35).
+
+```bat
+build-android.bat
+```
+
+Скрипт скачает Xray/Hysteria в `app/src/main/assets/cores/` и соберёт:
+
+`dist/navis-release/android/Navis.apk`
+
+Или Android Studio → Open `android/` → Run.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..4ecaead
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -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")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0633306
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/assets/cores/README.txt b/android/app/src/main/assets/cores/README.txt
new file mode 100644
index 0000000..abe45f6
--- /dev/null
+++ b/android/app/src/main/assets/cores/README.txt
@@ -0,0 +1,4 @@
+Navis Android cores
+- xray: VLESS / VMess / Trojan
+- hysteria: Hysteria 2
+NaiveProxy / full AmneziaWG tunnel: WIP (import UI already accepts links)
diff --git a/android/app/src/main/java/win/evilfox/navis/NavisApp.kt b/android/app/src/main/java/win/evilfox/navis/NavisApp.kt
new file mode 100644
index 0000000..d1e8823
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/NavisApp.kt
@@ -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
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/core/ConfigBuilders.kt b/android/app/src/main/java/win/evilfox/navis/core/ConfigBuilders.kt
new file mode 100644
index 0000000..82d2e90
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/core/ConfigBuilders.kt
@@ -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, 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)
+
+ 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"] ?: ""}")
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/core/CoreBundle.kt b/android/app/src/main/java/win/evilfox/navis/core/CoreBundle.kt
new file mode 100644
index 0000000..d446762
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/core/CoreBundle.kt
@@ -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
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/data/LinkParser.kt b/android/app/src/main/java/win/evilfox/navis/data/LinkParser.kt
new file mode 100644
index 0000000..3d8bce4
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/data/LinkParser.kt
@@ -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 {
+ 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()
+ 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
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/data/Models.kt b/android/app/src/main/java/win/evilfox/navis/data/Models.kt
new file mode 100644
index 0000000..50cc61d
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/data/Models.kt
@@ -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,
+)
diff --git a/android/app/src/main/java/win/evilfox/navis/data/ProfileStore.kt b/android/app/src/main/java/win/evilfox/navis/data/ProfileStore.kt
new file mode 100644
index 0000000..d821a68
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/data/ProfileStore.kt
@@ -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 {
+ 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) {
+ 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 = 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): List = withContext(Dispatchers.IO) {
+ val sem = Semaphore(12)
+ profiles.map { p ->
+ async {
+ sem.withPermit { pingOne(p) }
+ }
+ }.awaitAll().sortedWith(
+ compareByDescending { 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? = pings.filter { it.ok }.minByOrNull { it.ms }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt b/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt
new file mode 100644
index 0000000..990d6dd
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt
@@ -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()
+ }
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt b/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt
new file mode 100644
index 0000000..6d11ab5
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt
@@ -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 = emptyList(),
+ val pings: Map = 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 = _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()
+ 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()
+ }
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt b/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt
new file mode 100644
index 0000000..4cc8dcb
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt
@@ -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 { 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)
+ }
+ }
+}
diff --git a/android/app/src/main/java/win/evilfox/navis/vpn/NavisVpnService.kt b/android/app/src/main/java/win/evilfox/navis/vpn/NavisVpnService.kt
new file mode 100644
index 0000000..39b6bbb
--- /dev/null
+++ b/android/app/src/main/java/win/evilfox/navis/vpn/NavisVpnService.kt
@@ -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
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher_fg.xml b/android/app/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..30f9518
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..d132537
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..698013c
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+ #0D8A66
+ #0A6B4F
+ #E8F6F0
+ #F7FBFA
+ #0C3028
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..9ad5410
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ Navis
+ Navis VPN
+ Подключено
+ VPN
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f8d9e36
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..2439f15
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..1b4481a
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100644
index 0000000..f5feea6
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..94792fe
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "Navis"
+include(":app")
diff --git a/build-android.bat b/build-android.bat
new file mode 100644
index 0000000..99f4ce5
--- /dev/null
+++ b/build-android.bat
@@ -0,0 +1,41 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+
+set "JAVA_HOME=C:\Program Files\Microsoft\jdk-17.0.20.8-hotspot"
+set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
+set "ANDROID_SDK_ROOT=%ANDROID_HOME%"
+set "PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\cmdline-tools\latest\bin;%ANDROID_HOME%\platform-tools;%PATH%"
+
+if not exist "%ANDROID_HOME%\platforms\android-35" (
+ echo Android SDK platform android-35 missing. Run sdkmanager first.
+ exit /b 1
+)
+
+echo sdk.dir=%ANDROID_HOME:\=\\%> android\local.properties
+
+echo Fetching protocol cores into assets...
+powershell -ExecutionPolicy Bypass -File scripts\fetch-android-cores.ps1
+if errorlevel 1 exit /b 1
+
+cd android
+if not exist gradlew.bat (
+ echo Generating Gradle wrapper...
+ where gradle >nul 2>&1
+ if errorlevel 1 (
+ echo Download gradle wrapper via powershell...
+ powershell -ExecutionPolicy Bypass -File ..\scripts\bootstrap-gradle.ps1
+ ) else (
+ gradle wrapper --gradle-version 8.9
+ )
+)
+
+call gradlew.bat assembleDebug
+if errorlevel 1 exit /b 1
+
+cd ..
+mkdir dist\navis-release\android 2>nul
+copy /Y android\app\build\outputs\apk\debug\app-debug.apk dist\navis-release\android\Navis.apk
+echo.
+echo APK: dist\navis-release\android\Navis.apk
+endlocal
diff --git a/dist/navis-release/android/Navis.apk b/dist/navis-release/android/Navis.apk
new file mode 100644
index 0000000..4961a89
Binary files /dev/null and b/dist/navis-release/android/Navis.apk differ
diff --git a/scripts/bootstrap-gradle.ps1 b/scripts/bootstrap-gradle.ps1
new file mode 100644
index 0000000..9a28eb4
--- /dev/null
+++ b/scripts/bootstrap-gradle.ps1
@@ -0,0 +1,11 @@
+$ErrorActionPreference = "Stop"
+$android = Join-Path (Split-Path -Parent $PSScriptRoot) "android"
+Set-Location $android
+$ver = "8.9"
+$zip = Join-Path $env:TEMP "gradle-$ver-bin.zip"
+$dest = Join-Path $env:TEMP "gradle-$ver"
+if (-not (Test-Path "$dest\bin\gradle.bat")) {
+ Invoke-WebRequest "https://services.gradle.org/distributions/gradle-$ver-bin.zip" -OutFile $zip
+ Expand-Archive -Force $zip $env:TEMP
+}
+& "$dest\bin\gradle.bat" wrapper --gradle-version $ver
diff --git a/scripts/fetch-android-cores.ps1 b/scripts/fetch-android-cores.ps1
new file mode 100644
index 0000000..e0a4f41
--- /dev/null
+++ b/scripts/fetch-android-cores.ps1
@@ -0,0 +1,69 @@
+# Download Xray + Hysteria Android binaries into app assets (required for VPN cores).
+$ErrorActionPreference = "Stop"
+$Root = Split-Path -Parent $PSScriptRoot
+$Assets = Join-Path $Root "android\app\src\main\assets\cores"
+$Tmp = Join-Path $env:TEMP "navis-android-cores"
+New-Item -ItemType Directory -Force -Path $Tmp, $Assets | Out-Null
+
+function Get-AbiDir([string]$abi) {
+ $d = Join-Path $Assets $abi
+ New-Item -ItemType Directory -Force -Path $d | Out-Null
+ return $d
+}
+
+$xrayVer = "25.3.6"
+$xrayAssets = @(
+ @{ abi = "arm64-v8a"; file = "Xray-android-arm64-v8a.zip" },
+ @{ abi = "armeabi-v7a"; file = "Xray-android-arm32-v7a.zip" },
+ @{ abi = "x86_64"; file = "Xray-android-x86_64.zip?"; }
+)
+# Correct zip names from XTLS releases (may vary — try common ones)
+$xrayAssets = @(
+ @{ abi = "arm64-v8a"; file = "Xray-android-arm64-v8a.zip" },
+ @{ abi = "armeabi-v7a"; file = "Xray-android-arm32-v7a.zip" },
+ @{ abi = "x86_64"; file = "Xray-android-amd64.zip" }
+)
+
+foreach ($e in $xrayAssets) {
+ try {
+ $u = "https://github.com/XTLS/Xray-core/releases/download/v$xrayVer/$($e.file)"
+ $z = Join-Path $Tmp $e.file
+ Write-Host "Downloading Xray $($e.abi) from $u ..."
+ Invoke-WebRequest -Uri $u -OutFile $z
+ $out = Join-Path $Tmp ("xray-" + $e.abi)
+ if (Test-Path $out) { Remove-Item -Recurse -Force $out }
+ Expand-Archive -Force $z $out
+ $bin = Get-ChildItem $out -Recurse | Where-Object { $_.Name -eq "xray" } | Select-Object -First 1
+ if (-not $bin) { throw "xray binary not found in zip" }
+ Copy-Item -Force $bin.FullName (Join-Path (Get-AbiDir $e.abi) "xray")
+ } catch {
+ Write-Warning "Skip Xray $($e.abi): $($_.Exception.Message)"
+ }
+}
+
+$hyVer = "app/v2.6.1"
+$hyMap = @(
+ @{ abi = "arm64-v8a"; file = "hysteria-android-arm64" },
+ @{ abi = "armeabi-v7a"; file = "hysteria-android-armv7" },
+ @{ abi = "x86_64"; file = "hysteria-android-amd64" }
+)
+foreach ($h in $hyMap) {
+ try {
+ $u = "https://github.com/apernet/hysteria/releases/download/$hyVer/$($h.file)"
+ $dest = Join-Path (Get-AbiDir $h.abi) "hysteria"
+ Write-Host "Downloading Hysteria $($h.abi)..."
+ Invoke-WebRequest -Uri $u -OutFile $dest
+ } catch {
+ Write-Warning "Skip Hy2 $($h.abi): $($_.Exception.Message)"
+ }
+}
+
+@"
+Navis Android cores
+- xray: VLESS / VMess / Trojan
+- hysteria: Hysteria 2
+NaiveProxy / full AmneziaWG tunnel: WIP (import UI already accepts links)
+"@ | Set-Content -Encoding UTF8 (Join-Path $Assets "README.txt")
+
+Write-Host "Cores ready under $Assets"
+Get-ChildItem $Assets -Recurse | Select-Object FullName, Length