Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 7 additions & 33 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Android

# Builds the Android APKs: the Rust server plus the EasyTier and frp tunnel
# clients, all cross-compiled from source with the NDK, packed into a small
# app that runs them as a foreground service.
# 构建安卓 APK(手机上运行的 VIVANT 服务器 + EasyTier / frp 内网穿透)。
# Builds the Android APKs: the Rust server plus the EasyTier tunnel client,
# both cross-compiled from source with the NDK, packed into a small app that
# runs them as a foreground service.
# 构建安卓 APK(手机上运行的 VIVANT 服务器 + EasyTier 内网穿透)。
#
# Stable signing (so updates install over each other): add the repository
# secrets ANDROID_KEYSTORE_BASE64 (base64 of a .jks), ANDROID_KEYSTORE_PASSWORD,
Expand Down Expand Up @@ -59,34 +59,8 @@ jobs:
path: android/app/src/main/jniLibs/${{ matrix.abi }}/libeasytier.so
if-no-files-found: error

frpc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Versions
run: grep -E "^[A-Z_]+=" android/tunnels.env >> "$GITHUB_ENV"
- name: Source
run: git clone -q --depth 1 --branch "$FRP_VERSION" https://github.com/fatedier/frp android/.tunnel-src/frp
- uses: actions/setup-go@v6
with:
go-version-file: android/.tunnel-src/frp/go.mod
cache-dependency-path: android/.tunnel-src/frp/go.sum
- name: Build
run: |
set -o pipefail
android/build-tunnels.sh frpc 2>&1 | tee build.log
file android/app/src/main/jniLibs/*/libfrpc.so
- name: Report failure
if: failure()
run: .github/report-failure.sh build.log
- uses: actions/upload-artifact@v4
with:
name: frpc
path: android/app/src/main/jniLibs/
if-no-files-found: error

apk:
needs: [easytier, frpc]
needs: [easytier]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
Expand Down Expand Up @@ -126,7 +100,6 @@ jobs:
J=android/app/src/main/jniLibs
for abi in arm64-v8a armeabi-v7a x86_64; do
cp tunnels/easytier-$abi/libeasytier.so $J/$abi/
cp tunnels/frpc/$abi/libfrpc.so $J/$abi/
done
ls -l $J/*

Expand Down Expand Up @@ -163,12 +136,13 @@ jobs:
cp $d/app-arm64-v8a-release.apk out/vivant-android-arm64.apk
cp $d/app-armeabi-v7a-release.apk out/vivant-android-armv7.apk
cp $d/app-x86_64-release.apk out/vivant-android-x86_64.apk
for f in libvivant.so libeasytier.so libfrpc.so; do
for f in libvivant.so libeasytier.so; do
for abi in arm64-v8a armeabi-v7a x86_64; do
unzip -l out/vivant-android.apk | grep -q "lib/$abi/$f" || { echo "missing $abi/$f"; exit 1; }
done
unzip -l out/vivant-android-arm64.apk | grep -q "lib/arm64-v8a/$f" || { echo "arm64 apk missing $f"; exit 1; }
done
if unzip -l out/vivant-android.apk | grep -q libfrpc.so; then echo "libfrpc.so should be gone"; exit 1; fi
ls -l out

- name: Report failure
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ jobs:

Windows / macOS:`vivant-windows-*.exe`、`vivant-macos-*` 只适合本机测试或局域网,没有安装器、沙箱和开机自启 · for local testing or a LAN only — no installer, sandbox or autostart.

安卓手机当服务器(含 EasyTier / FRP 外网访问)· Android phone as a server (with EasyTier / FRP internet access): 大多数手机下载 `vivant-android-arm64.apk`,不确定就下 `vivant-android.apk`(通用版)· most phones: `vivant-android-arm64.apk`; not sure: `vivant-android.apk` (universal)(Android 8+,见 [docs/android.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.md) · see [docs/android.en.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.en.md))
安卓手机当服务器(含 EasyTier 外网访问)· Android phone as a server (with EasyTier internet access): 大多数手机下载 `vivant-android-arm64.apk`,不确定就下 `vivant-android.apk`(通用版)· most phones: `vivant-android-arm64.apk`; not sure: `vivant-android.apk` (universal)(Android 8+,见 [docs/android.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.md) · see [docs/android.en.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.en.md))

程序 Binaries: `vivant-linux-amd64` (x86_64), `vivant-linux-arm64` (ARM64, e.g. Oracle Ampere), `vivant-linux-armv7`;用 `SHA256SUMS` 校验 · verify with `SHA256SUMS`. (`ptterm-linux-*` are the same files under the old names.)
EOF
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ public class MainActivity extends Activity {
private final Handler ui = new Handler(Looper.getMainLooper());
private TextView status, online, tunnelStatus, error, connect, netHint, netConnect, fingerprint, log, advToggle, logToggle;
private Button toggle, battery, etNew;
private EditText port, maxClients, rooms, extra, etPeer, etName, etSecret, frpServer, frpServerPort, frpToken, frpRemotePort;
private EditText port, maxClients, rooms, extra, etPeer, etName, etSecret;
private CheckBox autostart;
private RadioGroup tunnel;
private View etGroup, frpGroup, advGroup;
private View etGroup, advGroup;
private ScrollView logScroll;
private long shownLog = -1;
private int tick;
Expand Down Expand Up @@ -88,14 +88,9 @@ protected void onCreate(Bundle b) {
etPeer = findViewById(R.id.et_peer);
etName = findViewById(R.id.et_name);
etSecret = findViewById(R.id.et_secret);
frpServer = findViewById(R.id.frp_server);
frpServerPort = findViewById(R.id.frp_server_port);
frpToken = findViewById(R.id.frp_token);
frpRemotePort = findViewById(R.id.frp_remote_port);
autostart = findViewById(R.id.autostart);
tunnel = findViewById(R.id.tunnel);
etGroup = findViewById(R.id.et_group);
frpGroup = findViewById(R.id.frp_group);
try {
((TextView) findViewById(R.id.version)).setText("v" + getPackageManager().getPackageInfo(getPackageName(), 0).versionName);
} catch (PackageManager.NameNotFoundException ignored) {
Expand Down Expand Up @@ -155,26 +150,19 @@ private void loadFields() {
autostart.setChecked(Prefs.autostart(this));
etName.setText(Prefs.etName(this));
etSecret.setText(Prefs.etSecret(this));
frpServer.setText(Prefs.frpServer(this));
frpServerPort.setText(String.valueOf(Prefs.frpServerPort(this)));
frpToken.setText(Prefs.frpToken(this));
frpRemotePort.setText(String.valueOf(Prefs.frpRemotePort(this)));
String t = Prefs.tunnel(this);
tunnel.check(Prefs.TUNNEL_EASYTIER.equals(t) ? R.id.tunnel_et : Prefs.TUNNEL_FRP.equals(t) ? R.id.tunnel_frp : R.id.tunnel_off);
tunnel.check(Prefs.TUNNEL_EASYTIER.equals(t) ? R.id.tunnel_et : R.id.tunnel_off);
showTunnelGroups();
}

private String tunnelFromRadio() {
int id = tunnel.getCheckedRadioButtonId();
if (id == R.id.tunnel_et) return Prefs.TUNNEL_EASYTIER;
if (id == R.id.tunnel_frp) return Prefs.TUNNEL_FRP;
return Prefs.TUNNEL_OFF;
return id == R.id.tunnel_et ? Prefs.TUNNEL_EASYTIER : Prefs.TUNNEL_OFF;
}

private void showTunnelGroups() {
String t = tunnelFromRadio();
etGroup.setVisibility(Prefs.TUNNEL_EASYTIER.equals(t) ? View.VISIBLE : View.GONE);
frpGroup.setVisibility(Prefs.TUNNEL_FRP.equals(t) ? View.VISIBLE : View.GONE);
}

@Override
Expand Down Expand Up @@ -232,22 +220,6 @@ private boolean saveSettings() {
ok = false;
}
}
int fsp = parse(frpServerPort), frp = parse(frpRemotePort);
String server = frpServer.getText().toString().trim();
if (Prefs.TUNNEL_FRP.equals(t)) {
if (server.isEmpty()) {
frpServer.setError(getString(R.string.err_need));
ok = false;
}
if (fsp < 1 || fsp > 65535) {
frpServerPort.setError(getString(R.string.err_need));
ok = false;
}
if (frp < 1 || frp > 65535) {
frpRemotePort.setError(getString(R.string.err_need));
ok = false;
}
}
if (!ok) return false;
Prefs.sp(this).edit()
.putInt("port", p)
Expand All @@ -259,10 +231,6 @@ private boolean saveSettings() {
.putString("tunnel", t)
.putString("et_name", name)
.putString("et_secret", secret)
.putString("frp_server", server)
.putInt("frp_server_port", fsp > 0 ? fsp : 7000)
.putString("frp_token", frpToken.getText().toString())
.putInt("frp_remote_port", frp > 0 ? frp : p)
.apply();
return true;
}
Expand Down Expand Up @@ -310,8 +278,7 @@ private void render() {

boolean editable = !run && !wanted;
for (View v : new View[]{port, maxClients, rooms, extra, etPeer, etName, etSecret, etNew,
frpServer, frpServerPort, frpToken, frpRemotePort,
findViewById(R.id.tunnel_off), findViewById(R.id.tunnel_et), findViewById(R.id.tunnel_frp)}) {
findViewById(R.id.tunnel_off), findViewById(R.id.tunnel_et)}) {
v.setEnabled(editable);
}

Expand Down Expand Up @@ -349,13 +316,6 @@ private void renderInternet() {
+ " --port-forward tcp://127.0.0.1:2222/" + Prefs.ET_IP + ":" + relay
+ "\n\nssh -p 2222 [email protected]";
hint = getString(R.string.net_hint_et) + "\n" + getString(R.string.net_hint_et_app, relay, Prefs.ET_IP);
} else if (Prefs.TUNNEL_FRP.equals(t)) {
String server = frpServer.getText().toString().trim();
int remote = parse(frpRemotePort);
if (!server.isEmpty() && remote > 0) {
cmd = "ssh -p " + remote + " guest@" + server;
hint = getString(R.string.net_hint_frp);
}
}
netHint.setVisibility(cmd == null ? View.GONE : View.VISIBLE);
netConnect.setVisibility(cmd == null ? View.GONE : View.VISIBLE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ final class Prefs {

static final String TUNNEL_OFF = "off";
static final String TUNNEL_EASYTIER = "easytier";
static final String TUNNEL_FRP = "frp";

/** This phone's address inside the EasyTier network. */
static final String ET_IP = "10.144.144.1";
Expand All @@ -40,7 +39,10 @@ static SharedPreferences sp(Context c) {
static void setRunning(Context c, boolean on) { sp(c).edit().putBoolean("running", on).apply(); }

// internet access
static String tunnel(Context c) { return sp(c).getString("tunnel", TUNNEL_OFF); }
/** off or easytier. FRP was removed; a phone that had chosen it starts with internet access off. */
static String tunnel(Context c) {
return TUNNEL_EASYTIER.equals(sp(c).getString("tunnel", TUNNEL_OFF)) ? TUNNEL_EASYTIER : TUNNEL_OFF;
}
static String etName(Context c) { return generated(c, "et_name", "vivant-", 10); }
static String etSecret(Context c) { return generated(c, "et_secret", "", 16); }
static String etPeer(Context c) {
Expand All @@ -55,11 +57,6 @@ static String etMachineId(Context c) {
}
return id;
}
static String frpServer(Context c) { return sp(c).getString("frp_server", "").trim(); }
static int frpServerPort(Context c) { return sp(c).getInt("frp_server_port", 7000); }
static String frpToken(Context c) { return sp(c).getString("frp_token", ""); }
static int frpRemotePort(Context c) { return sp(c).getInt("frp_remote_port", DEFAULT_PORT); }
static String frpProxyName(Context c) { return generated(c, "frp_name", "vivant-", 6); }

/** A new random EasyTier network name and password. */
static void regenerateEasyTier(Context c) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
import android.content.Context;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
Expand All @@ -15,73 +12,38 @@
import java.util.regex.Pattern;

/**
* Internet access for the phone's chat room through a bundled tunnel:
* EasyTier (P2P mesh, no server of your own; visitors run EasyTier too) or
* frpc (your own frps server; visitors just ssh to it). Both forward to the
* server's local relay listener, so tunnelled users share generous limits
* Internet access for the phone's chat room through the bundled EasyTier
* (P2P mesh, no server of your own; visitors run EasyTier too). It forwards to
* the server's local relay listener, so tunnelled users share generous limits
* instead of all counting as one address (127.0.0.1).
*/
final class Tunnels {
private Tunnels() {}

static String binary(Context c, String kind) {
String lib = Prefs.TUNNEL_EASYTIER.equals(kind) ? "libeasytier.so" : "libfrpc.so";
return new File(c.getApplicationInfo().nativeLibraryDir, lib).getAbsolutePath();
static String binary(Context c) {
return new File(c.getApplicationInfo().nativeLibraryDir, "libeasytier.so").getAbsolutePath();
}

static List<String> command(Context c, String kind) throws IOException {
static List<String> command(Context c) {
List<String> cmd = new ArrayList<>();
cmd.add(binary(c, kind));
if (Prefs.TUNNEL_EASYTIER.equals(kind)) {
// no TUN device (that would need Android's VPN permission): other
// nodes reach this phone's loopback ports at ET_IP through EasyTier's
// own network stack
add(cmd, "--no-tun", "-i", Prefs.ET_IP,
"--network-name", Prefs.etName(c),
"--network-secret", Prefs.etSecret(c),
"--hostname", "vivant",
"--machine-id", Prefs.etMachineId(c),
"--bind-device", "false",
"--file-log-level", "off",
"--console-log-level", "info");
for (String p : Prefs.etPeer(c).split("[,\\s]+")) {
if (!p.isEmpty()) add(cmd, "-p", p);
}
} else {
File conf = new File(c.getFilesDir(), "frpc.toml");
Files.write(conf.toPath(), frpConfig(c).getBytes(StandardCharsets.UTF_8));
add(cmd, "-c", conf.getAbsolutePath());
cmd.add(binary(c));
// no TUN device (that would need Android's VPN permission): other
// nodes reach this phone's loopback ports at ET_IP through EasyTier's
// own network stack
add(cmd, "--no-tun", "-i", Prefs.ET_IP,
"--network-name", Prefs.etName(c),
"--network-secret", Prefs.etSecret(c),
"--hostname", "vivant",
"--machine-id", Prefs.etMachineId(c),
"--bind-device", "false",
"--file-log-level", "off",
"--console-log-level", "info");
for (String p : Prefs.etPeer(c).split("[,\\s]+")) {
if (!p.isEmpty()) add(cmd, "-p", p);
}
return cmd;
}

static String frpConfig(Context c) {
StringBuilder t = new StringBuilder();
t.append("serverAddr = ").append(q(Prefs.frpServer(c))).append('\n');
t.append("serverPort = ").append(Prefs.frpServerPort(c)).append('\n');
if (!Prefs.frpToken(c).isEmpty()) t.append("auth.token = ").append(q(Prefs.frpToken(c))).append('\n');
t.append("loginFailExit = false\n");
t.append("log.to = \"console\"\nlog.level = \"info\"\nlog.disablePrintColor = true\n");
t.append("transport.tls.enable = true\n\n");
t.append("[[proxies]]\n");
t.append("name = ").append(q(Prefs.frpProxyName(c))).append('\n');
t.append("type = \"tcp\"\nlocalIP = \"127.0.0.1\"\n");
t.append("localPort = ").append(Prefs.relayPort(c)).append('\n');
t.append("remotePort = ").append(Prefs.frpRemotePort(c)).append('\n');
return t.toString();
}

/** TOML basic string. */
static String q(String s) {
StringBuilder b = new StringBuilder("\"");
for (char ch : s.toCharArray()) {
if (ch == '"' || ch == '\\') b.append('\\').append(ch);
else if (ch < 0x20 || ch == 0x7f) b.append(String.format(Locale.ROOT, "\\u%04x", (int) ch));
else b.append(ch);
}
return b.append('"').toString();
}

private static void add(List<String> l, String... a) {
for (String s : a) l.add(s);
}
Expand All @@ -90,42 +52,19 @@ private static void add(List<String> l, String... a) {

// 2026-11-08T08:34:14.514Z INFO CORE::INSTANCE::CONNECTION: [uuid] new peer added peer_id=1
private static final Pattern ET_LINE = Pattern.compile("^\\d{4}-\\d\\d-\\d\\dT\\S+\\s+(\\w+)\\s+\\S+?:\\s+(?:\\[[0-9a-f-]{36}\\]\\s+)?(.*)$");
// 2026-11-08 03:39:39.016 [I] [client/control.go:174] [816ebbb0ac97ff72] [vivant-ab] start proxy success
private static final Pattern FRP_LINE = Pattern.compile("^\\S+ \\S+ \\[(\\w)\\] \\[[^\\]]+\\] (?:\\[[0-9a-f]+\\] )?(.*)$");

/** Returns the line to show, or null to hide it (EasyTier's startup config dump). Updates ServerState. */
static String onLine(String kind, String raw) {
String level, msg;
if (Prefs.TUNNEL_EASYTIER.equals(kind)) {
Matcher m = ET_LINE.matcher(raw);
if (!m.matches()) return null;
level = m.group(1);
msg = m.group(2);
if (msg.contains("new peer added") || msg.contains("new peer connection added")) {
ServerState.tunnel = ServerState.TUNNEL_UP;
ServerState.tunnelError = null;
}
} else {
Matcher m = FRP_LINE.matcher(raw);
if (!m.matches()) return null;
level = m.group(1);
msg = m.group(2);
if (msg.contains("start proxy success")) {
ServerState.tunnel = ServerState.TUNNEL_UP;
ServerState.tunnelError = null;
} else if (msg.contains("port already used") || msg.contains("port not allowed") || msg.contains("port unavailable")) {
ServerState.tunnel = ServerState.TUNNEL_FAILED;
ServerState.tunnelError = "frp: 外网端口不可用(被占用或服务器不允许)· remote port unavailable (in use or not allowed by the server)";
} else if (msg.contains("token") || msg.contains("authorization failed")) {
ServerState.tunnel = ServerState.TUNNEL_FAILED;
ServerState.tunnelError = "frp: 令牌不对 · wrong token";
} else if (msg.contains("login to the server failed") || msg.contains("connect to server error")) {
ServerState.tunnel = ServerState.TUNNEL_CONNECTING;
ServerState.tunnelError = "frp: 连不上服务器,正在重试 · can't reach the server, retrying";
}
static String onLine(String raw) {
Matcher m = ET_LINE.matcher(raw);
if (!m.matches()) return null;
String level = m.group(1);
String msg = m.group(2);
if (msg.contains("new peer added") || msg.contains("new peer connection added")) {
ServerState.tunnel = ServerState.TUNNEL_UP;
ServerState.tunnelError = null;
}
if (msg.length() > 180) msg = msg.substring(0, 180) + "…";
String time = new SimpleDateFormat("HH:mm:ss", Locale.ROOT).format(new Date());
return time + " [" + kind + "] " + level + " " + msg;
return time + " [easytier] " + level + " " + msg;
}
}
Loading
Loading