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