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