release: 1.4.0 — one version for stable/beta/dev
Light/dark theme and compact mobile layout, per-client speed limit, real speed tests, panel domain (Let's Encrypt), client address + cascade relay, RKN scanner guard, WARP, backup/restore, router web terminal, Android VPN client with split tunnelling and Android admin app. Updating from 1.2.x keeps keys, server.env and clients; tested 1.2.8 -> 1.4.0 on live VMs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2
android-client/.gitignore
vendored
Normal file
2
android-client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
libs/x/
|
||||
46
android-client/AndroidManifest.xml
Normal file
46
android-client/AndroidManifest.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.pca.phobos.vpn"
|
||||
android:versionCode="140"
|
||||
android:versionName="1.4.0">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="PCA Phobos VPN"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/AppTheme"
|
||||
android:extractNativeLibs="true"
|
||||
android:allowBackup="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- panel "phobos://" links open straight into the app -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="phobos" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- from com.wireguard.android:tunnel (AAR manifest, merged by hand: no Gradle) -->
|
||||
<service
|
||||
android:name="com.wireguard.android.backend.GoBackend$VpnService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
20
android-client/README.md
Normal file
20
android-client/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# PCA Phobos VPN — Android-клиент
|
||||
|
||||
Своё приложение-клиент: импорт `phobos://` из панели, встроенный wg-obfuscator + WireGuard,
|
||||
**раздельный трафик** по приложениям (весь / только выбранные / все кроме выбранных).
|
||||
|
||||
- WireGuard: `com.wireguard.android:tunnel` (Apache-2.0), `libs/`.
|
||||
- Обфускатор: [ClusterM/wg-obfuscator](https://github.com/ClusterM/wg-obfuscator) v1.6 (GPL-3.0),
|
||||
собран NDK под bionic: `jni/<abi>/libwgobfuscator.so`. Статический glibc-бинарник с роутеров
|
||||
внутри приложения падает с SIGSYS (seccomp), поэтому только bionic-сборка.
|
||||
- Само приложение всегда вне туннеля — иначе обфускатор не достучится до сервера.
|
||||
|
||||
Сборка без Gradle: `KS_PASS=… ./build.sh` → `build/PCA-Phobos-VPN.apk`
|
||||
(ключ подписи — `~/.android/pca-phobos.jks`, пароль в ДОСТУПЫ.md; ключ не менять, иначе обновления не встанут).
|
||||
|
||||
Пересборка обфускатора:
|
||||
```bash
|
||||
NDK=~/Library/Android/sdk/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin
|
||||
$NDK/aarch64-linux-android24-clang -O2 -std=gnu11 -o jni/arm64-v8a/libwgobfuscator.so wg-obfuscator/*.c
|
||||
$NDK/armv7a-linux-androideabi24-clang -O2 -std=gnu11 -o jni/armeabi-v7a/libwgobfuscator.so wg-obfuscator/*.c
|
||||
```
|
||||
33
android-client/build.sh
Executable file
33
android-client/build.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build PCA Phobos VPN (client) without Gradle. Needs libs/: tunnel-*.aar (com.wireguard.android:tunnel),
|
||||
# collection-*.jar, annotation-*.jar; jni/<abi>/libwgobfuscator.so (static ClusterM wg-obfuscator).
|
||||
# KS_PASS=… ./build.sh -> build/PCA-Phobos-VPN.apk
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
|
||||
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
|
||||
KS="${KS:-$HOME/.android/pca-phobos.jks}"
|
||||
KS_PASS="${KS_PASS:?set KS_PASS}"
|
||||
AAR=$(ls libs/tunnel-*.aar | tail -1)
|
||||
rm -rf build && mkdir -p build/res build/classes build/dex build/aar build/apk/lib
|
||||
unzip -q "$AAR" -d build/aar
|
||||
LIBS="build/aar/classes.jar:$(ls libs/*.jar | tr '\n' ':')"
|
||||
"$BT/aapt2" compile --dir res -o build/res/
|
||||
"$BT/aapt2" link -o build/base.apk -I "$JAR" --manifest AndroidManifest.xml \
|
||||
--java build/gen build/res/*.flat --min-sdk-version 24 --target-sdk-version 35
|
||||
javac -source 11 -target 11 -nowarn -classpath "$JAR:$LIBS" -d build/classes \
|
||||
$(find src build/gen -name '*.java') 2>&1 | grep -v "bootstrap classpath\|^Note:" || true
|
||||
"$BT/d8" --release --lib "$JAR" --min-api 24 --output build/dex \
|
||||
$(find build/classes -name '*.class') build/aar/classes.jar libs/*.jar
|
||||
cp build/base.apk build/unsigned.apk
|
||||
(cd build/dex && zip -q ../unsigned.apk classes*.dex)
|
||||
for abi in arm64-v8a armeabi-v7a; do
|
||||
mkdir -p "build/apk/lib/$abi"
|
||||
cp build/aar/jni/$abi/*.so "build/apk/lib/$abi/"
|
||||
cp "jni/$abi/libwgobfuscator.so" "build/apk/lib/$abi/"
|
||||
done
|
||||
(cd build/apk && zip -qr ../unsigned.apk lib)
|
||||
"$BT/zipalign" -f 4 build/unsigned.apk build/aligned.apk
|
||||
"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$KS_PASS" --out build/PCA-Phobos-VPN.apk build/aligned.apk
|
||||
"$BT/apksigner" verify build/PCA-Phobos-VPN.apk && ls -la build/PCA-Phobos-VPN.apk
|
||||
BIN
android-client/jni/arm64-v8a/libwgobfuscator.so
Executable file
BIN
android-client/jni/arm64-v8a/libwgobfuscator.so
Executable file
Binary file not shown.
BIN
android-client/jni/armeabi-v7a/libwgobfuscator.so
Executable file
BIN
android-client/jni/armeabi-v7a/libwgobfuscator.so
Executable file
Binary file not shown.
BIN
android-client/libs/annotation-1.6.0.jar
Normal file
BIN
android-client/libs/annotation-1.6.0.jar
Normal file
Binary file not shown.
BIN
android-client/libs/collection-1.2.0.jar
Normal file
BIN
android-client/libs/collection-1.2.0.jar
Normal file
Binary file not shown.
BIN
android-client/libs/tunnel-1.0.20230706.aar
Normal file
BIN
android-client/libs/tunnel-1.0.20230706.aar
Normal file
Binary file not shown.
12
android-client/res/drawable/ic_launcher.xml
Normal file
12
android-client/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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="#1e1b4b" android:pathData="M0,0h108v108h-108z" />
|
||||
<path android:fillColor="#4f46e5"
|
||||
android:pathData="M54,18 L82,28 L82,52 C82,70 70,84 54,90 C38,84 26,70 26,52 L26,28 Z" />
|
||||
<path android:fillColor="#c7d2fe"
|
||||
android:pathData="M54,30 L72,36.5 L72,52 C72,64 64,73.5 54,78 C44,73.5 36,64 36,52 L36,36.5 Z" />
|
||||
<path android:fillColor="#1e1b4b"
|
||||
android:pathData="M47,46 h14 v5 h-4.5 v16 h-5 v-16 h-4.5 z" />
|
||||
</vector>
|
||||
10
android-client/res/values-night/styles.xml
Normal file
10
android-client/res/values-night/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:colorPrimary">#a5b4fc</item>
|
||||
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||
<item name="android:colorAccent">#818cf8</item>
|
||||
<item name="android:windowBackground">#0f172a</item>
|
||||
<item name="android:statusBarColor">#1e1b4b</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android-client/res/values/styles.xml
Normal file
10
android-client/res/values/styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:colorPrimary">#4338ca</item>
|
||||
<item name="android:colorPrimaryDark">#1e1b4b</item>
|
||||
<item name="android:colorAccent">#4f46e5</item>
|
||||
<item name="android:windowBackground">#f1f5f9</item>
|
||||
<item name="android:statusBarColor">#1e1b4b</item>
|
||||
</style>
|
||||
</resources>
|
||||
453
android-client/src/com/pca/phobos/vpn/MainActivity.java
Normal file
453
android-client/src/com/pca/phobos/vpn/MainActivity.java
Normal file
@@ -0,0 +1,453 @@
|
||||
package com.pca.phobos.vpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.wireguard.android.backend.GoBackend;
|
||||
import com.wireguard.android.backend.Statistics;
|
||||
import com.wireguard.android.backend.Tunnel;
|
||||
import com.wireguard.config.Config;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* PCA Phobos VPN — client: imports a phobos:// link from the panel, runs the
|
||||
* bundled wg-obfuscator + userspace WireGuard, split tunnelling by apps.
|
||||
* The app itself is always outside the tunnel so the obfuscator reaches the server directly.
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
static final int LPORT = 13255;
|
||||
static final String MODE_ALL = "all", MODE_ONLY = "only", MODE_EXCEPT = "except";
|
||||
|
||||
static GoBackend backend;
|
||||
static Process obfuscator;
|
||||
static final Tunnel TUNNEL = new Tunnel() {
|
||||
public String getName() { return "phobos"; }
|
||||
public void onStateChange(Tunnel.State s) { }
|
||||
};
|
||||
|
||||
private final ExecutorService bg = Executors.newSingleThreadExecutor();
|
||||
private final Handler ui = new Handler(Looper.getMainLooper());
|
||||
private SharedPreferences prefs;
|
||||
private boolean dark;
|
||||
private TextView status, traffic;
|
||||
private Button power;
|
||||
private long lastRx, lastTx, lastT;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
prefs = getSharedPreferences("vpn", MODE_PRIVATE);
|
||||
dark = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
|
||||
if (backend == null) backend = new GoBackend(getApplicationContext());
|
||||
handleIntent(getIntent());
|
||||
render();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent i) {
|
||||
super.onNewIntent(i);
|
||||
handleIntent(i);
|
||||
render();
|
||||
}
|
||||
|
||||
private void handleIntent(Intent i) {
|
||||
if (i != null && i.getData() != null && "phobos".equals(i.getData().getScheme())) {
|
||||
importLink(i.getDataString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── colors / widgets ──────────────────────────────────────────────────
|
||||
private int bg() { return dark ? 0xFF0F172A : 0xFFF1F5F9; }
|
||||
private int card() { return dark ? 0xFF1E293B : 0xFFFFFFFF; }
|
||||
private int text() { return dark ? 0xFFE2E8F0 : 0xFF0F172A; }
|
||||
private int muted() { return dark ? 0xFF94A3B8 : 0xFF64748B; }
|
||||
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
|
||||
|
||||
private GradientDrawable round(int color, int r) {
|
||||
GradientDrawable g = new GradientDrawable();
|
||||
g.setColor(color);
|
||||
g.setCornerRadius(dp(r));
|
||||
return g;
|
||||
}
|
||||
|
||||
private Button button(String label, int color, View.OnClickListener l) {
|
||||
Button b = new Button(this);
|
||||
b.setText(label);
|
||||
b.setAllCaps(false);
|
||||
b.setTextColor(Color.WHITE);
|
||||
b.setBackground(round(color, 10));
|
||||
b.setOnClickListener(l);
|
||||
return b;
|
||||
}
|
||||
|
||||
private TextView tv(String s, int color, int size, boolean bold) {
|
||||
TextView t = new TextView(this);
|
||||
t.setText(s);
|
||||
t.setTextColor(color);
|
||||
t.setTextSize(size);
|
||||
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
return t;
|
||||
}
|
||||
|
||||
private LinearLayout.LayoutParams mw(int top) {
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp.topMargin = dp(top);
|
||||
return lp;
|
||||
}
|
||||
|
||||
// ── profile ───────────────────────────────────────────────────────────
|
||||
/** phobos://<urlsafe-base64 of conf>#name (format produced by the PCA Phobos panel). */
|
||||
private void importLink(String link) {
|
||||
try {
|
||||
String s = link.trim();
|
||||
String name = "Phobos";
|
||||
int hash = s.indexOf('#');
|
||||
if (hash > 0) { name = Uri.decode(s.substring(hash + 1)); s = s.substring(0, hash); }
|
||||
s = s.replaceFirst("^phobos://", "").trim();
|
||||
String conf = new String(Base64.decode(s, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING), StandardCharsets.UTF_8);
|
||||
if (!conf.contains("[Interface]") || !conf.contains("[instance]")) throw new IllegalArgumentException("not a Phobos config");
|
||||
Config.parse(new BufferedReader(new StringReader(wgPart(conf, MODE_ALL, "")))); // validate
|
||||
prefs.edit().putString("conf", conf).putString("name", name).apply();
|
||||
toast("Профиль «" + name + "» импортирован");
|
||||
} catch (Exception e) {
|
||||
toast("Не удалось импортировать: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Split payload into sections: header -> lines (drop "= none" placeholders). */
|
||||
private static List<String> section(String conf, String header) {
|
||||
List<String> out = new ArrayList<>();
|
||||
boolean in = false;
|
||||
for (String raw : conf.split("\n")) {
|
||||
String ln = raw.trim();
|
||||
if (ln.startsWith("[") && ln.endsWith("]")) { in = ln.equalsIgnoreCase(header); continue; }
|
||||
if (!in || ln.isEmpty() || !ln.contains("=")) continue;
|
||||
if (ln.substring(ln.indexOf('=') + 1).trim().equalsIgnoreCase("none")) continue;
|
||||
out.add(ln);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String key(String line) { return line.substring(0, line.indexOf('=')).trim().toLowerCase(); }
|
||||
private static String val(String line) { return line.substring(line.indexOf('=') + 1).trim(); }
|
||||
|
||||
String wgPart(String conf, String mode, String apps) {
|
||||
StringBuilder sb = new StringBuilder("[Interface]\n");
|
||||
for (String ln : section(conf, "[Interface]")) {
|
||||
String k = key(ln);
|
||||
if (k.equals("excludedapplications") || k.equals("includedapplications")) continue;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
List<String> sel = new ArrayList<>();
|
||||
for (String a : apps.split(",")) if (!a.trim().isEmpty()) sel.add(a.trim());
|
||||
if (MODE_ONLY.equals(mode) && !sel.isEmpty()) {
|
||||
sel.remove(getPackageNameSafe());
|
||||
sb.append("IncludedApplications = ").append(String.join(", ", sel)).append('\n');
|
||||
} else {
|
||||
Set<String> ex = new LinkedHashSet<>();
|
||||
ex.add(getPackageNameSafe()); // obfuscator must bypass the tunnel
|
||||
if (MODE_EXCEPT.equals(mode)) ex.addAll(sel);
|
||||
sb.append("ExcludedApplications = ").append(String.join(", ", ex)).append('\n');
|
||||
}
|
||||
sb.append("\n[Peer]\n");
|
||||
for (String ln : section(conf, "[Peer]")) {
|
||||
if (key(ln).equals("endpoint")) ln = "Endpoint = 127.0.0.1:" + LPORT;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String getPackageNameSafe() { return "com.pca.phobos.vpn"; }
|
||||
|
||||
private String obfPart(String conf) {
|
||||
StringBuilder sb = new StringBuilder("[instance]\nsource-if = 127.0.0.1\nsource-lport = " + LPORT + "\n");
|
||||
for (String ln : section(conf, "[instance]")) {
|
||||
String k = key(ln);
|
||||
if (k.equals("source-if") || k.equals("source-lport") || k.equals("verbose")) continue;
|
||||
sb.append(ln).append('\n');
|
||||
}
|
||||
sb.append("verbose = INFO\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String target(String conf) {
|
||||
for (String ln : section(conf, "[instance]")) if (key(ln).equals("target")) return val(ln);
|
||||
return "?";
|
||||
}
|
||||
|
||||
// ── connect / disconnect ──────────────────────────────────────────────
|
||||
private boolean isUp() {
|
||||
try { return backend.getState(TUNNEL) == Tunnel.State.UP; } catch (Exception e) { return false; }
|
||||
}
|
||||
|
||||
private void toggle() {
|
||||
if (isUp()) { bg.execute(this::down); return; }
|
||||
if (prefs.getString("conf", "").isEmpty()) { toast("Сначала импортируйте ссылку phobos:// из панели"); return; }
|
||||
Intent prep = VpnService.prepare(this);
|
||||
if (prep != null) startActivityForResult(prep, 7);
|
||||
else bg.execute(this::up);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int req, int res, Intent data) {
|
||||
if (req == 7 && res == RESULT_OK) bg.execute(this::up);
|
||||
else if (req == 7) toast("Без разрешения на VPN подключиться нельзя");
|
||||
}
|
||||
|
||||
private void up() {
|
||||
try {
|
||||
String conf = prefs.getString("conf", "");
|
||||
File cfg = new File(getFilesDir(), "obf.conf");
|
||||
try (FileOutputStream o = new FileOutputStream(cfg)) { o.write(obfPart(conf).getBytes(StandardCharsets.UTF_8)); }
|
||||
stopObfuscator();
|
||||
String bin = getApplicationInfo().nativeLibraryDir + "/libwgobfuscator.so";
|
||||
obfuscator = new ProcessBuilder(bin, "-c", cfg.getAbsolutePath())
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(new File(getFilesDir(), "obf.log"))
|
||||
.start();
|
||||
Thread.sleep(600);
|
||||
if (!obfuscator.isAlive()) throw new IllegalStateException("обфускатор завершился, код " + obfuscator.exitValue() + " — см. «Журнал»");
|
||||
Config c = Config.parse(new BufferedReader(new StringReader(
|
||||
wgPart(conf, prefs.getString("mode", MODE_ALL), prefs.getString("apps", "")))));
|
||||
backend.setState(TUNNEL, Tunnel.State.UP, c);
|
||||
log("up ok, target " + target(conf) + ", mode " + prefs.getString("mode", MODE_ALL));
|
||||
post("Подключено");
|
||||
} catch (Throwable e) {
|
||||
stopObfuscator();
|
||||
log("up failed: " + e);
|
||||
post("Ошибка: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void down() {
|
||||
try { backend.setState(TUNNEL, Tunnel.State.DOWN, null); } catch (Throwable ignored) { }
|
||||
stopObfuscator();
|
||||
post("Отключено");
|
||||
}
|
||||
|
||||
private static void stopObfuscator() {
|
||||
if (obfuscator != null) { obfuscator.destroy(); obfuscator = null; }
|
||||
}
|
||||
|
||||
private void post(String msg) { ui.post(() -> { toast(msg); refresh(); }); }
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────
|
||||
private void render() {
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(bg());
|
||||
|
||||
LinearLayout h = new LinearLayout(this);
|
||||
h.setBackgroundColor(0xFF1E1B4B);
|
||||
h.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
h.addView(tv("🛡 PCA Phobos VPN", 0xFFC7D2FE, 18, true));
|
||||
root.addView(h);
|
||||
|
||||
ScrollView sv = new ScrollView(this);
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setPadding(dp(16), dp(16), dp(16), dp(24));
|
||||
sv.addView(box);
|
||||
|
||||
String conf = prefs.getString("conf", "");
|
||||
LinearLayout prof = new LinearLayout(this);
|
||||
prof.setOrientation(LinearLayout.VERTICAL);
|
||||
prof.setBackground(round(card(), 14));
|
||||
prof.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
if (conf.isEmpty()) {
|
||||
prof.addView(tv("Профиль не импортирован", text(), 16, true));
|
||||
prof.addView(tv("В панели PCA Phobos откройте клиента → 📱 Android → нажмите ссылку phobos:// "
|
||||
+ "на этом устройстве, или скопируйте её и нажмите «Импорт».", muted(), 14, false));
|
||||
} else {
|
||||
prof.addView(tv(prefs.getString("name", "Phobos"), text(), 18, true));
|
||||
prof.addView(tv("Сервер: " + target(conf), muted(), 14, false));
|
||||
}
|
||||
box.addView(prof, mw(0));
|
||||
|
||||
power = new Button(this);
|
||||
power.setAllCaps(false);
|
||||
power.setTextColor(Color.WHITE);
|
||||
power.setTextSize(20);
|
||||
power.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
power.setOnClickListener(v -> toggle());
|
||||
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(dp(180), dp(180));
|
||||
plp.gravity = Gravity.CENTER_HORIZONTAL;
|
||||
plp.topMargin = dp(24);
|
||||
box.addView(power, plp);
|
||||
|
||||
status = tv("", text(), 16, true);
|
||||
status.setGravity(Gravity.CENTER);
|
||||
box.addView(status, mw(12));
|
||||
traffic = tv("", muted(), 14, false);
|
||||
traffic.setGravity(Gravity.CENTER);
|
||||
box.addView(traffic, mw(2));
|
||||
|
||||
// split tunnelling
|
||||
LinearLayout split = new LinearLayout(this);
|
||||
split.setOrientation(LinearLayout.VERTICAL);
|
||||
split.setBackground(round(card(), 14));
|
||||
split.setPadding(dp(16), dp(14), dp(16), dp(14));
|
||||
split.addView(tv("Раздельный трафик", text(), 16, true));
|
||||
RadioGroup rg = new RadioGroup(this);
|
||||
String mode = prefs.getString("mode", MODE_ALL);
|
||||
String[][] modes = {{MODE_ALL, "Весь трафик через VPN"}, {MODE_ONLY, "Только выбранные приложения"},
|
||||
{MODE_EXCEPT, "Все, кроме выбранных приложений"}};
|
||||
for (String[] m : modes) {
|
||||
RadioButton r = new RadioButton(this);
|
||||
r.setText(m[1]);
|
||||
r.setTextColor(text());
|
||||
r.setId(View.generateViewId());
|
||||
r.setChecked(m[0].equals(mode));
|
||||
r.setOnClickListener(v -> { prefs.edit().putString("mode", m[0]).apply(); applyIfUp(); refresh(); });
|
||||
rg.addView(r);
|
||||
}
|
||||
split.addView(rg);
|
||||
int n = prefs.getString("apps", "").isEmpty() ? 0 : prefs.getString("apps", "").split(",").length;
|
||||
split.addView(button("Выбрать приложения (" + n + ")", 0xFF475569, v -> pickApps()), mw(8));
|
||||
split.addView(tv("Изменения применяются сразу. Само приложение всегда идёт напрямую — так обфускатор держит связь с сервером.",
|
||||
muted(), 12, false), mw(6));
|
||||
box.addView(split, mw(20));
|
||||
|
||||
box.addView(button("Импорт ссылки phobos://", 0xFF4F46E5, v -> importDialog()), mw(16));
|
||||
box.addView(button("Проверить скорость", 0xFF0EA5E9, v -> speedtest()), mw(8));
|
||||
box.addView(button("Журнал", 0xFF475569, v -> showLog()), mw(8));
|
||||
root.addView(sv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
|
||||
setContentView(root);
|
||||
refresh();
|
||||
ui.removeCallbacksAndMessages(null);
|
||||
ui.postDelayed(new Runnable() { public void run() { refresh(); ui.postDelayed(this, 2000); } }, 2000);
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
if (power == null) return;
|
||||
boolean up = isUp();
|
||||
power.setText(up ? "Отключить" : "Подключить");
|
||||
power.setBackground(round(up ? 0xFF16A34A : 0xFF4F46E5, 90));
|
||||
status.setText(up ? "🟢 VPN включён" : "⚪ VPN выключен");
|
||||
if (up) {
|
||||
try {
|
||||
Statistics st = backend.getStatistics(TUNNEL);
|
||||
long rx = st.totalRx(), tx = st.totalTx(), now = System.currentTimeMillis();
|
||||
String speed = "";
|
||||
if (lastT > 0 && now > lastT) {
|
||||
double s = (now - lastT) / 1000.0;
|
||||
speed = String.format(" · ↓ %.1f / ↑ %.1f Мбит/с", (rx - lastRx) * 8 / s / 1e6, (tx - lastTx) * 8 / s / 1e6);
|
||||
}
|
||||
lastRx = rx; lastTx = tx; lastT = now;
|
||||
traffic.setText(String.format("Получено %.1f МБ · Отправлено %.1f МБ", rx / 1048576.0, tx / 1048576.0) + speed);
|
||||
} catch (Exception e) { traffic.setText(""); }
|
||||
} else { traffic.setText(""); lastT = 0; }
|
||||
}
|
||||
|
||||
private void applyIfUp() { if (isUp()) bg.execute(() -> { down(); up(); }); }
|
||||
|
||||
private void importDialog() {
|
||||
final EditText e = new EditText(this);
|
||||
e.setHint("phobos://…");
|
||||
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
|
||||
if (cm != null && cm.getPrimaryClip() != null && cm.getPrimaryClip().getItemCount() > 0) {
|
||||
CharSequence c = cm.getPrimaryClip().getItemAt(0).getText();
|
||||
if (c != null && c.toString().trim().startsWith("phobos://")) e.setText(c);
|
||||
}
|
||||
new AlertDialog.Builder(this).setTitle("Ссылка из панели").setView(e)
|
||||
.setPositiveButton("Импорт", (d, w) -> { importLink(e.getText().toString()); applyIfUp(); render(); })
|
||||
.setNegativeButton("Отмена", null).show();
|
||||
}
|
||||
|
||||
private void pickApps() {
|
||||
PackageManager pm = getPackageManager();
|
||||
Intent main = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
List<ResolveInfo> ris = pm.queryIntentActivities(main, 0);
|
||||
List<String[]> apps = new ArrayList<>();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (ResolveInfo ri : ris) {
|
||||
String p = ri.activityInfo.packageName;
|
||||
if (p.equals(getPackageName()) || !seen.add(p)) continue;
|
||||
apps.add(new String[]{String.valueOf(ri.loadLabel(pm)), p});
|
||||
}
|
||||
Collections.sort(apps, (a, b) -> a[0].compareToIgnoreCase(b[0]));
|
||||
final Set<String> chosen = new LinkedHashSet<>(Arrays.asList(prefs.getString("apps", "").split(",")));
|
||||
chosen.remove("");
|
||||
String[] labels = new String[apps.size()];
|
||||
boolean[] checked = new boolean[apps.size()];
|
||||
for (int i = 0; i < apps.size(); i++) { labels[i] = apps.get(i)[0]; checked[i] = chosen.contains(apps.get(i)[1]); }
|
||||
new AlertDialog.Builder(this).setTitle("Приложения")
|
||||
.setMultiChoiceItems(labels, checked, (d, i, on) -> { if (on) chosen.add(apps.get(i)[1]); else chosen.remove(apps.get(i)[1]); })
|
||||
.setPositiveButton("Готово", (d, w) -> { prefs.edit().putString("apps", String.join(",", chosen)).apply(); applyIfUp(); render(); })
|
||||
.setNegativeButton("Отмена", null).show();
|
||||
}
|
||||
|
||||
/** Real speed through the tunnel: the browser is inside the VPN (unless excluded by the split). */
|
||||
private void speedtest() {
|
||||
if (!isUp()) { toast("Сначала подключитесь"); return; }
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://speed.cloudflare.com/")));
|
||||
}
|
||||
|
||||
private void log(String s) {
|
||||
try (FileOutputStream o = new FileOutputStream(new File(getFilesDir(), "app.log"), true)) {
|
||||
o.write((new java.util.Date() + " " + s + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
private static String tail(File f, int max) {
|
||||
try {
|
||||
byte[] b = java.nio.file.Files.readAllBytes(f.toPath());
|
||||
String t = new String(b, StandardCharsets.UTF_8);
|
||||
return t.length() > max ? t.substring(t.length() - max) : t;
|
||||
} catch (Exception e) { return ""; }
|
||||
}
|
||||
|
||||
private void showLog() {
|
||||
String t = "— приложение —\n" + tail(new File(getFilesDir(), "app.log"), 3000)
|
||||
+ "\n— обфускатор —\n" + tail(new File(getFilesDir(), "obf.log"), 3000)
|
||||
+ "\nобфускатор запущен: " + (obfuscator != null && obfuscator.isAlive());
|
||||
TextView v = tv(t, text(), 12, false);
|
||||
v.setTextIsSelectable(true);
|
||||
v.setPadding(dp(16), dp(8), dp(16), dp(8));
|
||||
ScrollView sv = new ScrollView(this);
|
||||
sv.addView(v);
|
||||
new AlertDialog.Builder(this).setTitle("Журнал").setView(sv).setPositiveButton("OK", null).show();
|
||||
}
|
||||
|
||||
private void toast(String s) { Toast.makeText(this, s, Toast.LENGTH_LONG).show(); }
|
||||
}
|
||||
Reference in New Issue
Block a user