1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:38:52 +03:00

vpn service sync

This commit is contained in:
Anton Afanasyeu
2026-06-11 13:43:15 +02:00
parent 6ed53e80ab
commit 8735fe4177
43 changed files with 1185 additions and 181 deletions

View File

@@ -0,0 +1,14 @@
---
description: Re-read bottomline_reminder.txt when owner requests implement/create/document work
alwaysApply: true
---
# Bottomline reminder (owner tasks)
When the **project owner** asks you to **implement**, **create**, or **document** something:
1. **Re-read** [bottomline_reminder.txt](bottomline_reminder.txt) at the start of that work (docs templates, alpha must-haves, UI themes/fonts, licenses, tests, buildability, task graph, submodules, atomic commits on `next`, infra safety, help_request PDFs).
2. Apply its bullets for the session unless the owner explicitly overrides.
3. Keep [third-party/](third-party/) submodules consistent (`.gitmodules`, mobile + BE licenses, Gitea mirrors, init scripts).
Do not skip this read step when switching from Q&A to implementation.

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.iml *.iml
.gradle/ .gradle/
gradle/
/local.properties /local.properties
/.idea/ /.idea/
/build/ /build/

View File

@@ -110,6 +110,12 @@ android {
buildConfig true buildConfig true
} }
packaging {
resources {
excludes += '/META-INF/versions/9/OSGI-INF/MANIFEST.MF'
}
}
testOptions { testOptions {
unitTests { unitTests {
includeAndroidResources = true includeAndroidResources = true
@@ -133,7 +139,5 @@ dependencies {
implementation 'com.github.mwiede:jsch:0.2.21' implementation 'com.github.mwiede:jsch:0.2.21'
if (project.findProject(':tunnel') != null) { implementation project(':tunnel')
implementation project(':tunnel')
}
} }

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -133,9 +134,16 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="connectedDevice" /> android:foregroundServiceType="connectedDevice" />
<!-- VPN stack: isolated process com.foxx.androidcast:vpn (AIDL via AndroidCastVpnService). -->
<service <service
android:name=".remoteaccess.RemoteAccessVpnService" android:name=".vpn.AndroidCastVpnService"
android:exported="false" android:exported="false"
android:process=":vpn" />
<service
android:name=".vpn.RemoteAccessVpnService"
android:exported="false"
android:process=":vpn"
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="connectedDevice"> android:foregroundServiceType="connectedDevice">
<intent-filter> <intent-filter>
@@ -143,6 +151,17 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name="com.wireguard.android.backend.GoBackend$VpnService"
android:exported="false"
android:process=":vpn"
android:permission="android.permission.BIND_VPN_SERVICE"
tools:node="merge">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<receiver <receiver
android:name=".TrayNotificationReceiver" android:name=".TrayNotificationReceiver"
android:exported="false" /> android:exported="false" />

View File

@@ -0,0 +1,7 @@
package com.foxx.androidcast;
/** One-shot result for apply / tearDown. */
interface IVpnOperationCallback {
oneway void onSuccess();
oneway void onFailure(String reason);
}

View File

@@ -0,0 +1,19 @@
package com.foxx.androidcast;
import com.foxx.androidcast.IVpnOperationCallback;
import com.foxx.androidcast.IVpnStateCallback;
/**
* Standalone VPN control plane (process com.foxx.androidcast:vpn).
* WireGuard userspace + TUN fallback run only in the VPN process.
*/
interface IVpnService {
void registerStateCallback(IVpnStateCallback callback);
void unregisterStateCallback(IVpnStateCallback callback);
int getState();
boolean isVpnPermissionGranted();
void applyWireGuardConfig(String wgQuickConfig, IVpnOperationCallback callback);
void tearDown(IVpnOperationCallback callback);
/** Debug/lab only — records a synthetic crash from the VPN process (no process kill). */
void simulateCrashForTest(int sequence, IVpnOperationCallback callback);
}

View File

@@ -0,0 +1,6 @@
package com.foxx.androidcast;
/** Async VPN state updates from the :vpn process. */
interface IVpnStateCallback {
oneway void onStateChanged(int state, String detail);
}

View File

@@ -32,5 +32,5 @@
<li><b>Speex</b> — Revised BSD license (Xiph.org Foundation). See <code>third-party/speex/COPYING</code>.</li> <li><b>Speex</b> — Revised BSD license (Xiph.org Foundation). See <code>third-party/speex/COPYING</code>.</li>
</ul> </ul>
<h2>WireGuard tunnel module (optional Gradle module)</h2> <h2>WireGuard tunnel module (<code>third-party/wireguard-android</code>)</h2>
<p>When <code>third-party/wireguard-android</code> is initialized and the <code>:tunnel</code> module is included, the app may link the WireGuard Android tunnel library (Apache License 2.0; see upstream <code>wireguard-android</code>). Remote access still uses Android <code>VpnService</code> (system VPN permission).</p> <p>The app links the WireGuard Android tunnel library from git submodule <code>third-party/wireguard-android</code> (Apache License 2.0; see upstream <code>COPYING</code> and <code>https://git.zx2c4.com/wireguard-android</code>). WireGuard lab mode uses Android <code>VpnService</code> (system VPN permission) via <code>GoBackend</code>.</p>

View File

@@ -32,10 +32,13 @@ public class AndroidCastApplication extends Application {
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
CrashReporter.install(this);
if (!CrashReporter.isMainProcess(this, CrashReporter.currentProcessName())) {
return;
}
CodecPriorityCatalog.init(this); CodecPriorityCatalog.init(this);
CastLocaleHelper.applyStoredLocale(this); CastLocaleHelper.applyStoredLocale(this);
CastThemeHelper.applyNightMode(this); CastThemeHelper.applyNightMode(this);
CrashReporter.install(this);
ReceiverAvRuntime.reload(this); ReceiverAvRuntime.reload(this);
CastTransportFactory.init(this); CastTransportFactory.init(this);
WiredDisplayMonitor.getInstance(this).ensureRegistered(); WiredDisplayMonitor.getInstance(this).ensureRegistered();

View File

@@ -1,16 +1,5 @@
package com.foxx.androidcast.crash; package com.foxx.androidcast.crash;
/*********************************************************************
* CrashReporter.java
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* class CrashReporter
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 89 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 5254fee83476d4a80db718169cbc13b61b18697148a8625baab8df0833b1d78f
**********************************************************************/
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.util.Log; import android.util.Log;
@@ -21,19 +10,29 @@ import org.json.JSONObject;
import java.io.File; import java.io.File;
/** Main-process entry: write pending crash JSON and wake the watcher process. */ /**
* Crash capture for main and monitored isolated processes ({@code :vpn}, {@code :netselftest}).
* Persists JSON to shared app storage and wakes {@link CrashWatcherService}.
*/
public final class CrashReporter { public final class CrashReporter {
private static final String TAG = "CrashReporter"; private static final String TAG = "CrashReporter";
private CrashReporter() {} private CrashReporter() {}
/** Install handlers appropriate for the current process. */
public static void install(Context context) { public static void install(Context context) {
Context app = context.getApplicationContext(); Context app = context.getApplicationContext();
if (!isMainProcess(app)) { String proc = currentProcessName();
return; if (isMainProcess(app, proc)) {
installMainProcess(app);
} else if (isMonitoredIsolatedProcess(proc)) {
installIsolatedProcess(app);
} }
if (app instanceof android.app.Application) { }
CrashRuntimeContext.init((android.app.Application) app);
private static void installMainProcess(Context app) {
if (app instanceof Application) {
CrashRuntimeContext.init((Application) app);
} }
CrashNativeBridge.init(app); CrashNativeBridge.init(app);
final Thread.UncaughtExceptionHandler previous = final Thread.UncaughtExceptionHandler previous =
@@ -55,6 +54,25 @@ public final class CrashReporter {
CrashWatcherService.scheduleUploadSweep(app); CrashWatcherService.scheduleUploadSweep(app);
} }
private static void installIsolatedProcess(Context app) {
CrashNativeBridge.init(app);
final Thread.UncaughtExceptionHandler previous =
Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
try {
recordJavaCrash(app, thread, throwable);
} catch (Throwable t) {
Log.e(TAG, "isolated recordJavaCrash failed", t);
}
if (previous != null) {
previous.uncaughtException(thread, throwable);
} else {
System.exit(10);
}
});
Log.i(TAG, "crash capture enabled in " + currentProcessName());
}
public static void recordJavaCrash(Context context, Thread thread, Throwable throwable) { public static void recordJavaCrash(Context context, Thread thread, Throwable throwable) {
if (!CrashSettingsStore.load(context).enabled) { if (!CrashSettingsStore.load(context).enabled) {
return; return;
@@ -95,7 +113,22 @@ public final class CrashReporter {
CrashWatcherService.requestImmediateSweep(context, true); CrashWatcherService.requestImmediateSweep(context, true);
} }
private static boolean isMainProcess(Context context) { public static boolean isMainProcess(Context context, String procName) {
return context.getPackageName().equals(Application.getProcessName()); return context.getPackageName().equals(procName);
}
public static boolean isMonitoredIsolatedProcess(String procName) {
if (procName == null || procName.isEmpty()) {
return false;
}
return procName.endsWith(":vpn") || procName.endsWith(":netselftest");
}
public static String currentProcessName() {
try {
return Application.getProcessName();
} catch (Exception e) {
return "";
}
} }
} }

View File

@@ -88,6 +88,13 @@ public final class CrashRuntimeContext {
} }
public static String scenarioLabel() { public static String scenarioLabel() {
String proc = CrashReporter.currentProcessName();
if (proc != null && proc.endsWith(":vpn")) {
return "vpn_service";
}
if (proc != null && proc.endsWith(":netselftest")) {
return "net_selftest";
}
if (SessionStatsContext.get() != null || isCastSessionActive()) { if (SessionStatsContext.get() != null || isCastSessionActive()) {
return "cast_session"; return "cast_session";
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.Log; import android.util.Log;
import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.vpn.VpnServiceClient;
/** Start/stop remote access foreground polling + VPN based on developer mode. */ /** Start/stop remote access foreground polling + VPN based on developer mode. */
public final class RemoteAccessCoordinator { public final class RemoteAccessCoordinator {
@@ -17,7 +18,7 @@ public final class RemoteAccessCoordinator {
Log.i(TAG, "disabling remote access"); Log.i(TAG, "disabling remote access");
RemoteAccessService.sendDisableOnce(app); RemoteAccessService.sendDisableOnce(app);
RemoteAccessService.stop(app); RemoteAccessService.stop(app);
RemoteAccessVpnService.stop(app); VpnServiceClient.get(app).tearDown();
AppPreferences.clearDevRemoteAccessSession(app); AppPreferences.clearDevRemoteAccessSession(app);
return; return;
} }

View File

@@ -19,6 +19,7 @@ import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.DeveloperSettingsActivity; import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.DeviceInfo; import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.vpn.VpnServiceClient;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -185,20 +186,29 @@ public final class RemoteAccessService extends Service {
if (creds == null) { if (creds == null) {
return; return;
} }
boolean ok = false;
if ("wireguard".equals(tunnel)) { if ("wireguard".equals(tunnel)) {
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds); String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
if (!wgConfig.isEmpty()) { if (wgConfig.isEmpty()) {
ok = WireGuardTunnelBridge.applyConfig(this, wgConfig); return;
} }
final String sid = sessionId;
VpnServiceClient.get(this).applyWireGuardConfig(wgConfig, new VpnServiceClient.OperationListener() {
@Override
public void onSuccess() {
updateNotificationConnected(sid, tunnel);
}
@Override
public void onFailure(String reason) {
Log.w(TAG, "wireguard connect failed: " + reason);
}
});
} else if ("ssh_reverse".equals(tunnel)) { } else if ("ssh_reverse".equals(tunnel)) {
ok = ReverseSshTunnelBridge.applyConfig(this, creds); if (ReverseSshTunnelBridge.applyConfig(this, creds)) {
updateNotificationConnected(sessionId, tunnel);
}
} else { } else {
Log.w(TAG, "unsupported tunnel in connect: " + tunnel); Log.w(TAG, "unsupported tunnel in connect: " + tunnel);
return;
}
if (ok) {
updateNotificationConnected(sessionId, tunnel);
} }
} }

View File

@@ -2,12 +2,14 @@ package com.foxx.androidcast.remoteaccess;
import android.content.Context; import android.content.Context;
import com.foxx.androidcast.vpn.VpnServiceClient;
/** Tear down all remote-access transports (WireGuard + RSSH). */ /** Tear down all remote-access transports (WireGuard + RSSH). */
public final class RemoteAccessTunnelHelper { public final class RemoteAccessTunnelHelper {
private RemoteAccessTunnelHelper() {} private RemoteAccessTunnelHelper() {}
public static void tearDownAll(Context context) { public static void tearDownAll(Context context) {
WireGuardTunnelBridge.tearDown(context); VpnServiceClient.get(context).tearDown();
ReverseSshTunnelBridge.tearDown(context); ReverseSshTunnelBridge.tearDown(context);
} }
} }

View File

@@ -1,64 +0,0 @@
package com.foxx.androidcast.remoteaccess;
import android.content.Context;
import android.util.Log;
/**
* Optional integration with wireguard-android tunnel module (third-party/wireguard-android).
* Falls back to {@link RemoteAccessVpnService} TUN setup when module is absent.
*/
public final class WireGuardTunnelBridge {
private static final String TAG = "WGTunnelBridge";
private WireGuardTunnelBridge() {}
public static boolean applyConfig(Context context, String wgQuickConfig) {
if (wgQuickConfig == null || wgQuickConfig.trim().isEmpty()) {
return false;
}
try {
Class<?> configClass = Class.forName("com.wireguard.config.Config");
Object config = configClass.getMethod("parse", String.class).invoke(null, wgQuickConfig);
Class<?> goBackendClass = Class.forName("com.wireguard.android.backend.GoBackend");
Object backend = goBackendClass.getConstructor(Context.class).newInstance(context);
Class<?> tunnelClass = Class.forName("com.wireguard.android.backend.Tunnel");
Object tunnel = proxyTunnel(tunnelClass);
Class<?> stateClass = Class.forName("com.wireguard.android.backend.Tunnel$State");
Object up = enumConstant(stateClass, "UP");
goBackendClass.getMethod("setState", tunnelClass, stateClass, configClass)
.invoke(backend, tunnel, up, config);
Log.i(TAG, "wireguard tunnel UP via GoBackend");
return true;
} catch (Throwable t) {
Log.i(TAG, "GoBackend unavailable, using VpnService fallback: " + t.getClass().getSimpleName());
return RemoteAccessVpnService.startWithConfig(context, wgQuickConfig);
}
}
public static void tearDown(Context context) {
RemoteAccessVpnService.stop(context);
try {
Class<?> goBackendClass = Class.forName("com.wireguard.android.backend.GoBackend");
Object backend = goBackendClass.getConstructor(Context.class).newInstance(context);
Class<?> tunnelClass = Class.forName("com.wireguard.android.backend.Tunnel");
Object tunnel = proxyTunnel(tunnelClass);
Class<?> stateClass = Class.forName("com.wireguard.android.backend.Tunnel$State");
Object down = enumConstant(stateClass, "DOWN");
goBackendClass.getMethod("setState", tunnelClass, stateClass, Class.forName("com.wireguard.config.Config"))
.invoke(backend, tunnel, down, null);
} catch (Throwable ignored) {
}
}
private static Object proxyTunnel(Class<?> tunnelClass) {
return java.lang.reflect.Proxy.newProxyInstance(
tunnelClass.getClassLoader(),
new Class<?>[]{tunnelClass},
(proxy, method, args) -> "getName".equals(method.getName()) ? "androidcast-ra" : null);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Object enumConstant(Class<?> stateClass, String name) {
return Enum.valueOf((Class<? extends Enum>) stateClass, name);
}
}

View File

@@ -0,0 +1,180 @@
package com.foxx.androidcast.vpn;
import android.app.Service;
import android.content.Intent;
import android.net.VpnService;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.util.Log;
import com.foxx.androidcast.IVpnOperationCallback;
import com.foxx.androidcast.IVpnService;
import com.foxx.androidcast.IVpnStateCallback;
/**
* Standalone VPN process ({@code com.foxx.androidcast:vpn}) — AIDL control plane for WireGuard.
*/
public final class AndroidCastVpnService extends Service {
private static final String TAG = "AndroidCastVpn";
/** Debug/lab: record synthetic VPN-process crash (see scripts/simulate-vpn-crashes.sh). */
public static final String ACTION_SIMULATE_CRASH = "com.foxx.androidcast.vpn.SIMULATE_CRASH";
public static final String EXTRA_SIM_SEQ = "sim_seq";
private final RemoteCallbackList<IVpnStateCallback> stateCallbacks = new RemoteCallbackList<>();
private final Object stateLock = new Object();
private volatile int state = VpnState.DOWN;
private volatile String stateDetail = "";
private HandlerThread workerThread;
private Handler worker;
private final IVpnService.Stub binder = new IVpnService.Stub() {
@Override
public void registerStateCallback(IVpnStateCallback callback) {
if (callback != null) {
stateCallbacks.register(callback);
}
}
@Override
public void unregisterStateCallback(IVpnStateCallback callback) {
if (callback != null) {
stateCallbacks.unregister(callback);
}
}
@Override
public int getState() {
return state;
}
@Override
public boolean isVpnPermissionGranted() {
return VpnService.prepare(AndroidCastVpnService.this) == null;
}
@Override
public void applyWireGuardConfig(String wgQuickConfig, IVpnOperationCallback callback) {
ensureWorker();
setState(VpnState.CONNECTING, "applying wireguard config");
worker.post(() -> {
boolean ok = WireGuardVpnEngine.apply(getApplicationContext(), wgQuickConfig);
if (ok) {
setState(VpnState.UP, "wireguard tunnel up");
notifyOperation(callback, true, null);
} else {
setState(VpnState.ERROR, "wireguard apply failed");
notifyOperation(callback, false, "apply failed or VPN permission missing");
}
});
}
@Override
public void tearDown(IVpnOperationCallback callback) {
ensureWorker();
worker.post(() -> {
WireGuardVpnEngine.tearDown(getApplicationContext());
setState(VpnState.DOWN, "tunnel down");
notifyOperation(callback, true, null);
});
}
@Override
public void simulateCrashForTest(int sequence, IVpnOperationCallback callback) {
ensureWorker();
worker.post(() -> {
boolean ok = VpnCrashSimulator.recordSimulatedCrash(getApplicationContext(), sequence);
if (ok) {
notifyOperation(callback, true, null);
} else {
notifyOperation(callback, false, "simulate only in debug builds");
}
});
}
};
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "VPN process service created");
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_SIMULATE_CRASH.equals(intent.getAction())) {
int seq = intent.getIntExtra(EXTRA_SIM_SEQ, 0);
ensureWorker();
worker.post(() -> {
VpnCrashSimulator.recordSimulatedCrash(getApplicationContext(), seq);
stopSelf(startId);
});
return START_NOT_STICKY;
}
return START_STICKY;
}
@Override
public void onDestroy() {
if (workerThread != null) {
workerThread.quitSafely();
workerThread = null;
worker = null;
}
WireGuardVpnEngine.tearDown(getApplicationContext());
super.onDestroy();
}
private void ensureWorker() {
if (worker != null) {
return;
}
workerThread = new HandlerThread("androidcast-vpn-worker");
workerThread.start();
worker = new Handler(workerThread.getLooper());
}
private void setState(int newState, String detail) {
synchronized (stateLock) {
state = newState;
stateDetail = detail != null ? detail : "";
}
broadcastState(newState, stateDetail);
}
private void broadcastState(int newState, String detail) {
int n = stateCallbacks.beginBroadcast();
try {
for (int i = 0; i < n; i++) {
try {
stateCallbacks.getBroadcastItem(i).onStateChanged(newState, detail);
} catch (RemoteException ignored) {
}
}
} finally {
stateCallbacks.finishBroadcast();
}
}
private static void notifyOperation(IVpnOperationCallback callback, boolean ok, String reason) {
if (callback == null) {
return;
}
try {
if (ok) {
callback.onSuccess();
} else {
callback.onFailure(reason != null ? reason : "error");
}
} catch (RemoteException e) {
Log.w(TAG, "operation callback died: " + e.getMessage());
}
}
}

View File

@@ -1,4 +1,4 @@
package com.foxx.androidcast.remoteaccess; package com.foxx.androidcast.vpn;
import android.app.Notification; import android.app.Notification;
import android.app.NotificationChannel; import android.app.NotificationChannel;
@@ -18,14 +18,15 @@ import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import java.net.InetAddress; import java.net.InetAddress;
import java.util.ArrayList;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** Holds WireGuard TUN when tunnel module is not linked; also prepares interface for userspace WG. */ /** TUN fallback when GoBackend cannot start — runs in :vpn process only. */
public final class RemoteAccessVpnService extends VpnService { public final class RemoteAccessVpnService extends VpnService {
private static final String TAG = "RemoteAccessVPN"; private static final String TAG = "RemoteAccessVPN";
public static final String ACTION_UP = "com.foxx.androidcast.remoteaccess.VPN_UP"; public static final String ACTION_UP = "com.foxx.androidcast.vpn.VPN_UP";
public static final String ACTION_DOWN = "com.foxx.androidcast.remoteaccess.VPN_DOWN"; public static final String ACTION_DOWN = "com.foxx.androidcast.vpn.VPN_DOWN";
public static final String EXTRA_WG_CONFIG = "wg_config"; public static final String EXTRA_WG_CONFIG = "wg_config";
private static final int NOTIF_ID = 0x7a01; private static final int NOTIF_ID = 0x7a01;
@@ -108,7 +109,6 @@ public final class RemoteAccessVpnService extends VpnService {
return m.find() ? m.group(1).trim() : null; return m.find() ? m.group(1).trim() : null;
} }
/** Routes from AllowedIPs= lines (split tunnel — typically server wg /32). */
private static String[] parseAllowedRoutes(String wgConfig) { private static String[] parseAllowedRoutes(String wgConfig) {
if (wgConfig == null) { if (wgConfig == null) {
return new String[0]; return new String[0];
@@ -118,7 +118,7 @@ public final class RemoteAccessVpnService extends VpnService {
return new String[0]; return new String[0];
} }
String[] parts = m.group(1).trim().split("\\s*,\\s*"); String[] parts = m.group(1).trim().split("\\s*,\\s*");
java.util.ArrayList<String> out = new java.util.ArrayList<>(); ArrayList<String> out = new ArrayList<>();
for (String p : parts) { for (String p : parts) {
if (!p.isEmpty()) { if (!p.isEmpty()) {
out.add(p.trim()); out.add(p.trim());

View File

@@ -0,0 +1,40 @@
package com.foxx.androidcast.vpn;
import android.content.Context;
import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.crash.CrashWatcherService;
/**
* Records synthetic Java crashes from the VPN process for lab validation (debug builds only).
* Does not kill the process — exercises persist + watcher upload path.
*/
public final class VpnCrashSimulator {
private VpnCrashSimulator() {}
public static boolean recordSimulatedCrash(Context context, int sequence) {
if (!BuildConfig.DEBUG) {
return false;
}
RuntimeException ex = new RuntimeException(
"VpnCrashSimulation seq=" + sequence + " proc=" + CrashReporter.currentProcessName());
ex.setStackTrace(buildStack(sequence));
CrashReporter.recordJavaCrash(context, Thread.currentThread(), ex);
CrashWatcherService.requestImmediateSweep(context.getApplicationContext(), true);
return true;
}
private static StackTraceElement[] buildStack(int sequence) {
return new StackTraceElement[]{
new StackTraceElement("com.foxx.androidcast.vpn.WireGuardVpnEngine", "apply",
"WireGuardVpnEngine.java", 40 + (sequence % 5)),
new StackTraceElement("com.foxx.androidcast.vpn.AndroidCastVpnService$1", "applyWireGuardConfig",
"AndroidCastVpnService.java", 60),
new StackTraceElement("com.foxx.androidcast.vpn.AndroidCastVpnService", "onStartCommand",
"AndroidCastVpnService.java", 95 + (sequence % 3)),
new StackTraceElement("android.app.ActivityThread", "handleServiceArgs",
"ActivityThread.java", 5001),
};
}
}

View File

@@ -0,0 +1,241 @@
package com.foxx.androidcast.vpn;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;
import android.util.Log;
import com.foxx.androidcast.IVpnOperationCallback;
import com.foxx.androidcast.IVpnService;
import com.foxx.androidcast.IVpnStateCallback;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Main-process client for {@link AndroidCastVpnService} ({@code com.foxx.androidcast:vpn}).
*/
public final class VpnServiceClient {
private static final String TAG = "VpnServiceClient";
private static final long BIND_TIMEOUT_MS = 8_000L;
private static volatile VpnServiceClient instance;
private final Context appContext;
private final Handler main = new Handler(Looper.getMainLooper());
private final Object bindLock = new Object();
private final List<Runnable> pending = new ArrayList<>();
private IVpnService service;
private boolean binding;
private CountDownLatch bindLatch;
public interface StateListener {
void onStateChanged(int state, String detail);
}
public interface OperationListener {
void onSuccess();
void onFailure(String reason);
}
private VpnServiceClient(Context context) {
appContext = context.getApplicationContext();
}
public static VpnServiceClient get(Context context) {
if (instance == null) {
synchronized (VpnServiceClient.class) {
if (instance == null) {
instance = new VpnServiceClient(context);
}
}
}
return instance;
}
private final ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder binder) {
synchronized (bindLock) {
service = IVpnService.Stub.asInterface(binder);
binding = false;
if (bindLatch != null) {
bindLatch.countDown();
}
}
flushPending();
}
@Override
public void onServiceDisconnected(ComponentName name) {
synchronized (bindLock) {
service = null;
binding = false;
}
}
};
public boolean isVpnPermissionGranted() {
if (!ensureBoundSync()) {
return false;
}
try {
return service.isVpnPermissionGranted();
} catch (RemoteException e) {
Log.w(TAG, "isVpnPermissionGranted: " + e.getMessage());
return false;
}
}
public int getState() {
if (!ensureBoundSync()) {
return VpnState.DOWN;
}
try {
return service.getState();
} catch (RemoteException e) {
return VpnState.DOWN;
}
}
public void registerStateListener(StateListener listener) {
runWithService(s -> {
IVpnStateCallback cb = new IVpnStateCallback.Stub() {
@Override
public void onStateChanged(int state, String detail) {
main.post(() -> listener.onStateChanged(state, detail));
}
};
s.registerStateCallback(cb);
});
}
public void applyWireGuardConfig(String wgQuickConfig, OperationListener listener) {
runWithService(s -> {
try {
s.applyWireGuardConfig(wgQuickConfig, wrapOperation(listener));
} catch (RemoteException e) {
deliverOperation(listener, false, e.getMessage());
}
});
}
public void tearDown(OperationListener listener) {
runWithService(s -> {
try {
s.tearDown(wrapOperation(listener));
} catch (RemoteException e) {
deliverOperation(listener, false, e.getMessage());
}
});
}
public void tearDown() {
tearDown(null);
}
private IVpnOperationCallback wrapOperation(OperationListener listener) {
return new IVpnOperationCallback.Stub() {
@Override
public void onSuccess() {
deliverOperation(listener, true, null);
}
@Override
public void onFailure(String reason) {
deliverOperation(listener, false, reason);
}
};
}
private void deliverOperation(OperationListener listener, boolean ok, String reason) {
if (listener == null) {
return;
}
main.post(() -> {
if (ok) {
listener.onSuccess();
} else {
listener.onFailure(reason != null ? reason : "error");
}
});
}
private interface ServiceTask {
void run(IVpnService s) throws RemoteException;
}
private void runWithService(ServiceTask task) {
IVpnService s = service;
if (s != null) {
try {
task.run(s);
} catch (RemoteException e) {
Log.w(TAG, "remote call failed: " + e.getMessage());
}
return;
}
synchronized (bindLock) {
pending.add(() -> runWithService(task));
}
ensureBoundAsync();
}
private void flushPending() {
List<Runnable> copy;
synchronized (bindLock) {
copy = new ArrayList<>(pending);
pending.clear();
}
for (Runnable r : copy) {
r.run();
}
}
private void ensureBoundAsync() {
synchronized (bindLock) {
if (service != null || binding) {
return;
}
binding = true;
Intent i = new Intent(appContext, AndroidCastVpnService.class);
appContext.startService(i);
appContext.bindService(i, connection, Context.BIND_AUTO_CREATE);
}
}
private boolean ensureBoundSync() {
if (service != null) {
return true;
}
synchronized (bindLock) {
if (service != null) {
return true;
}
bindLatch = new CountDownLatch(1);
ensureBoundAsync();
try {
if (!bindLatch.await(BIND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
Log.w(TAG, "VPN service bind timeout");
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
bindLatch = null;
}
}
return service != null;
}
}

View File

@@ -0,0 +1,11 @@
package com.foxx.androidcast.vpn;
/** VPN state codes shared across AIDL ({@link com.foxx.androidcast.IVpnService}). */
public final class VpnState {
public static final int DOWN = 0;
public static final int CONNECTING = 1;
public static final int UP = 2;
public static final int ERROR = 3;
private VpnState() {}
}

View File

@@ -0,0 +1,58 @@
package com.foxx.androidcast.vpn;
import android.content.Context;
import android.util.Log;
import com.wireguard.android.backend.GoBackend;
import com.wireguard.android.backend.Tunnel;
import com.wireguard.config.Config;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
/** WireGuard tunnel control inside the :vpn process only. */
final class WireGuardVpnEngine {
private static final String TAG = "WireGuardVpnEngine";
private static final Tunnel ANDROID_CAST_TUNNEL = new Tunnel() {
@Override
public String getName() {
return "androidcast-ra";
}
@Override
public void onStateChange(State newState) {
Log.d(TAG, "tunnel state " + newState);
}
};
private WireGuardVpnEngine() {}
static boolean apply(Context context, String wgQuickConfig) {
if (wgQuickConfig == null || wgQuickConfig.trim().isEmpty()) {
return false;
}
try {
Config config;
try (ByteArrayInputStream in = new ByteArrayInputStream(
wgQuickConfig.getBytes(StandardCharsets.UTF_8))) {
config = Config.parse(in);
}
GoBackend backend = new GoBackend(context);
backend.setState(ANDROID_CAST_TUNNEL, Tunnel.State.UP, config);
Log.i(TAG, "wireguard UP via GoBackend");
return true;
} catch (Throwable t) {
Log.w(TAG, "GoBackend failed, TUN fallback: " + t.getMessage());
return RemoteAccessVpnService.startWithConfig(context, wgQuickConfig);
}
}
static void tearDown(Context context) {
RemoteAccessVpnService.stop(context);
try {
GoBackend backend = new GoBackend(context);
backend.setState(ANDROID_CAST_TUNNEL, Tunnel.State.DOWN, null);
} catch (Throwable ignored) {
}
}
}

View File

@@ -0,0 +1,17 @@
package com.foxx.androidcast.crash;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class CrashReporterProcessTest {
@Test
public void isMonitoredIsolatedProcess_vpnAndNetselftest() {
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:vpn"));
assertTrue(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:netselftest"));
assertFalse(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast"));
assertFalse(CrashReporter.isMonitoredIsolatedProcess("com.foxx.androidcast:crashwatcher"));
}
}

View File

@@ -0,0 +1,34 @@
package com.foxx.androidcast.remoteaccess;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import com.wireguard.config.Config;
import org.json.JSONObject;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
/** Ensures BE credential → wg-quick text parses with wireguard-android Config. */
public class WireGuardConfigParseTest {
@Test
public void builderOutput_parsesWithWireGuardConfig() throws Exception {
JSONObject creds = new JSONObject();
creds.put("interface_private_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
creds.put("interface_address", "10.66.66.2/32");
creds.put("peer_public_key", "YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
creds.put("peer_endpoint", "ra.apps.f0xx.org:51820");
creds.put("peer_allowed_ips", "10.66.66.1/32");
String quick = WireGuardConfigBuilder.fromConnectCredentials(creds);
assertFalse(quick.isEmpty());
try (ByteArrayInputStream in = new ByteArrayInputStream(quick.getBytes(StandardCharsets.UTF_8))) {
Config config = Config.parse(in);
assertEquals("10.66.66.2/32", config.getInterface().getAddresses().iterator().next().toString());
}
}
}

View File

@@ -0,0 +1,15 @@
package com.foxx.androidcast.vpn;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class VpnStateTest {
@Test
public void stateCodes_areDistinct() {
assertTrue(VpnState.DOWN < VpnState.CONNECTING);
assertTrue(VpnState.CONNECTING < VpnState.UP);
assertTrue(VpnState.UP < VpnState.ERROR);
}
}

View File

@@ -55,6 +55,17 @@ cd orchestration
Direct debug ports: crashes `:8082`, builder `:8083`. Direct debug ports: crashes `:8082`, builder `:8083`.
## Local developer rebuild
`./rebuild.sh` (repo root):
1. `scripts/init-third-party-submodules.sh` — libvpx, opus, speex, **wireguard-android** (+ nested wireguard-tools / elf-cleaner)
2. `scripts/build-native-codecs.sh` per ABI
3. `./gradlew :tunnel:assembleDebug :app:assembleDebug` — WireGuard userspace (`libwg-go.so`) from `third-party/wireguard-android`
4. Unit tests, adb install (when devices configured)
No runtime fetch from zx2c4/GitHub during Gradle: sources come from checked-out `third-party/` submodules only.
## Trigger a build ## Trigger a build
1. Open Builder → sign in (default dev user from crash schema seed). 1. Open Builder → sign in (default dev user from crash schema seed).

View File

@@ -25,7 +25,7 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE
- **WireGuard:** foreground service; jittered poll **17 min**`heartbeat.php` with `type: ra` - **WireGuard:** foreground service; jittered poll **17 min**`heartbeat.php` with `type: ra`
- Heartbeat URL derived from crash upload URL (`…/api/heartbeat.php`) - Heartbeat URL derived from crash upload URL (`…/api/heartbeat.php`)
- Session credentials persisted until `expires_at`; expiry tears down tunnel locally - Session credentials persisted until `expires_at`; expiry tears down tunnel locally
- Optional full userspace WG via `third-party/wireguard-android` (`:tunnel`); otherwise `RemoteAccessVpnService` TUN fallback - Userspace WG via `third-party/wireguard-android` (`:tunnel` / `GoBackend`) in isolated process **`com.foxx.androidcast:vpn`**; main app controls via AIDL (`IVpnService` / `VpnServiceClient`); TUN fallback only if GoBackend fails
### Android VPN consent (WireGuard) ### Android VPN consent (WireGuard)
@@ -148,14 +148,28 @@ Internet UDP :51820 → FE DNAT → BE wg0 :51820
Hostname `ra.apps.f0xx.org` (or similar) should resolve to the FE public IP. Devices use `wg_endpoint` from config/connect payload. Hostname `ra.apps.f0xx.org` (or similar) should resolve to the FE public IP. Devices use `wg_endpoint` from config/connect payload.
## VPN process (AIDL)
| Component | Process | Role |
|-----------|---------|------|
| `VpnServiceClient` | main (`com.foxx.androidcast`) | Binds to `IVpnService`; async apply/tearDown + state callbacks |
| `AndroidCastVpnService` | `:vpn` | AIDL stub; worker thread for tunnel ops |
| `WireGuardVpnEngine` | `:vpn` | `GoBackend` + TUN fallback |
| `RemoteAccessVpnService` | `:vpn` | `VpnService` TUN when GoBackend fails |
| `GoBackend$VpnService` | `:vpn` | WireGuard tunnel library VPN entry (manifest merge) |
Developer settings still uses `VpnService.prepare(Activity)` for the one-time consent dialog (same app UID).
**Crash capture:** `CrashReporter.install()` registers uncaught handlers in `:vpn` (scenario `vpn_service`, process `com.foxx.androidcast:vpn`). Reports persist to shared app storage and upload via `:crashwatcher`. Lab: `scripts/simulate-vpn-crashes.sh` (debug APK + `MODE=device`, or `MODE=upload` for BE ingest validation).
## WireGuard third-party (Android) ## WireGuard third-party (Android)
```bash ```bash
bash scripts/init-wireguard-submodule.sh bash scripts/init-third-party-submodules.sh # or ./rebuild.sh (includes init + :tunnel build)
./gradlew :app:assembleDebug # includes :tunnel when submodule present ./gradlew :tunnel:assembleDebug :app:assembleDebug
``` ```
Without the submodule, `WireGuardTunnelBridge` uses `RemoteAccessVpnService` (TUN only — sufficient for lab; prefer `:tunnel` for production tunnels). Gradle fails fast if the submodule is missing. VPN runs in `:vpn` (`AndroidCastVpnService` + AIDL); `WireGuardVpnEngine` uses `GoBackend` with TUN fallback in the same process.
## Tests ## Tests

View File

@@ -0,0 +1,6 @@
Network traffic obfuscation
Definitions:
- E2E - E2E encryption
- P2P - P2P encryption
- POLY - Polymorphic ciphers, persistent and temporary keys

View File

@@ -0,0 +1,12 @@
network topology glossary (to be used as a code names within the product):
1. SIRENE - P2P-alike topology
2. SPIDER - MCU-alike topology
3. SPIDERWEB - SFU-alike topology
4. MINE - mesh-alike topology
5. SLY - E2E/VPN/hidden/any
6. BLUEMOON - Bluetooth-transported any available (mesh, P2P)
7. SLOTH - passive
and the combinations of them: SIRENE SLOTH - slow-to-respond passive P2P networking; SPIDER SLY - hidden non-transparent network activity using MCU-alike topology; MINE BLUEMOON, etc.

View File

@@ -0,0 +1,17 @@
Audio/Video RT and non-RT
linked drafts:
- Network obfuscation
- Transport layers
- Network topology
- Paid services
- End-user services
The service (standalone or the current one, depends on max. capacity, bandwidth, storage trade-offs, CPU load) should provide:
1. RT AV
- RT streaming and casting using well-known codecs and transports, including store recordings services for offline / non-RT usage
2. non-RT AV
- non-RT on-demand conversion routines with additional services, like:
** reproducible (playable) streams
** ability to download/delete streams

View File

@@ -26,7 +26,7 @@ These are **server-side**; not bundled in the mobile APK.
| **wireguard-tools** (`wg`, `wg-quick`) | GPLv2 (userspace) | BE dynamic peer provisioning (`WireGuardPeerProvisioner.php`) | | **wireguard-tools** (`wg`, `wg-quick`) | GPLv2 (userspace) | BE dynamic peer provisioning (`WireGuardPeerProvisioner.php`) |
| Linux **WireGuard** kernel module | GPLv2 | UDP tunnel termination on BE | | Linux **WireGuard** kernel module | GPLv2 | UDP tunnel termination on BE |
Mobile WireGuard mode uses outbound tunnel from device; see mobile app licenses for optional `wireguard-android` (Apache-2.0). Mobile WireGuard mode uses outbound tunnel from device; app embeds `wireguard-android` tunnel (`third-party/wireguard-android`, Apache-2.0) — see app `licenses/combined.html`.
## Planned: reverse SSH (RSSH) ## Planned: reverse SSH (RSSH)

View File

@@ -1,4 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# wireguard-android tunnel (third-party/wireguard-android); consumed by gradle/wireguard-tunnel/
wireguardPackageName=com.wireguard.android
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# Gradle 8.9 must run on JDK 1721 (Gentoo default JDK 25 fails with class file version 69). # Gradle 8.9 must run on JDK 1721 (Gentoo default JDK 25 fails with class file version 69).

Binary file not shown.

View File

@@ -1,7 +0,0 @@
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

View File

@@ -13,13 +13,10 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft
## Enable native codecs (developer) ## Enable native codecs (developer)
1. Initialize submodules as needed: 1. Initialize all third-party submodules:
```bash ```bash
git submodule update --init third-party/libvpx bash scripts/init-third-party-submodules.sh
git submodule update --init third-party/opus
git submodule update --init third-party/speex
git submodule update --init third-party/wireguard-android
``` ```
2. Build static libs per ABI (NDK auto-detected via `scripts/android-ndk.sh`): 2. Build static libs per ABI (NDK auto-detected via `scripts/android-ndk.sh`):

View File

@@ -142,6 +142,9 @@ export ANDROIDCAST_ROOT="$ROOT"
export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")" export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")"
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
echo "==> Using NDK: $ANDROID_NDK_HOME" echo "==> Using NDK: $ANDROID_NDK_HOME"
echo "==> Initializing third-party git submodules (libvpx, opus, speex, wireguard-android)"
bash "$ROOT/scripts/init-third-party-submodules.sh"
if androidcast_ccache_wanted 2>/dev/null; then if androidcast_ccache_wanted 2>/dev/null; then
echo "==> Native ccache: enabled (ANDROIDCAST_CCACHE=${ANDROIDCAST_CCACHE:-auto})" echo "==> Native ccache: enabled (ANDROIDCAST_CCACHE=${ANDROIDCAST_CCACHE:-auto})"
else else
@@ -158,15 +161,18 @@ for ARCH_ABI in ${ARCH_ABIS}; do
LC_ALL=C ./scripts/build-native-codecs.sh "${ARCH_ABI}" LC_ALL=C ./scripts/build-native-codecs.sh "${ARCH_ABI}"
done done
WG_HEAD="$(git -C "$ROOT/third-party/wireguard-android" rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "==> Building wireguard-android (:tunnel) from third-party/wireguard-android @ $WG_HEAD"
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)" echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
# Docker CI on a mounted repo leaves app/.cxx with /opt/android-sdk paths; that breaks local clean/build. # Docker CI on a mounted repo leaves app/.cxx with /opt/android-sdk paths; that breaks local clean/build.
rm -rf "$ROOT/app/.cxx" rm -rf "$ROOT/app/.cxx"
rm -rf "$ROOT/gradle/wireguard-tunnel/.cxx" "$ROOT/gradle/wireguard-tunnel/build" 2>/dev/null || true
if [[ -d "$ROOT/app/build" ]] && ! rm -rf "$ROOT/app/build" 2>/dev/null; then if [[ -d "$ROOT/app/build" ]] && ! rm -rf "$ROOT/app/build" 2>/dev/null; then
echo "WARN: app/build is not writable (often root-owned after Docker). Run: sudo rm -rf app/build" >&2 echo "WARN: app/build is not writable (often root-owned after Docker). Run: sudo rm -rf app/build" >&2
fi fi
./gradlew --stop ./gradlew --stop
./gradlew clean ./gradlew clean
./gradlew --build-cache assembleDebug ./gradlew --build-cache :tunnel:assembleDebug :app:assembleDebug
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
echo "==> Running unit tests (./gradlew testDebugUnitTest)" echo "==> Running unit tests (./gradlew testDebugUnitTest)"

View File

@@ -3,7 +3,7 @@
# CMake in ndk/ autolinks when those archives exist (ANDROIDCAST_HAVE_* = 1). # CMake in ndk/ autolinks when those archives exist (ANDROIDCAST_HAVE_* = 1).
# #
# Prerequisites: # Prerequisites:
# git submodule update --init third-party/libvpx # bash scripts/init-third-party-submodules.sh
# Android NDK (auto-detected via scripts/android-ndk.sh) # Android NDK (auto-detected via scripts/android-ndk.sh)
# #
# Usage: # Usage:
@@ -22,7 +22,7 @@ BUILD_DIR="$OUT/libvpx-build"
if [[ ! -d "$VPX_SRC_REAL" ]]; then if [[ ! -d "$VPX_SRC_REAL" ]]; then
echo "ERROR: $VPX_SRC_REAL missing. Run:" echo "ERROR: $VPX_SRC_REAL missing. Run:"
echo " git submodule update --init third-party/libvpx" echo " bash scripts/init-third-party-submodules.sh"
exit 1 exit 1
fi fi
@@ -253,7 +253,7 @@ androidcast_ndk_configure_host() {
build_opus_for_abi() { build_opus_for_abi() {
local opus_src="$ROOT/third-party/opus" local opus_src="$ROOT/third-party/opus"
if [[ ! -f "$opus_src/CMakeLists.txt" ]]; then if [[ ! -f "$opus_src/CMakeLists.txt" ]]; then
echo "Skip libopus: $opus_src missing (git submodule update --init third-party/opus)" echo "Skip libopus: $opus_src missing (bash scripts/init-third-party-submodules.sh)"
return 0 return 0
fi fi
local opus_build="$BUILD_DIR/opus-cmake" local opus_build="$BUILD_DIR/opus-cmake"
@@ -284,7 +284,7 @@ build_opus_for_abi() {
build_speex_for_abi() { build_speex_for_abi() {
local speex_src_real="$ROOT/third-party/speex" local speex_src_real="$ROOT/third-party/speex"
if [[ ! -d "$speex_src_real" ]]; then if [[ ! -d "$speex_src_real" ]]; then
echo "Skip libspeex: $speex_src_real missing (git submodule update --init third-party/speex)" echo "Skip libspeex: $speex_src_real missing (bash scripts/init-third-party-submodules.sh)"
return 0 return 0
fi fi
local speex_src="$speex_src_real" local speex_src="$speex_src_real"

View File

@@ -61,6 +61,9 @@ if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
printf 'sdk.dir=%s\n' "${ANDROID_SDK_ROOT//\\/\\\\}" > "$LOCAL_PROPS" printf 'sdk.dir=%s\n' "${ANDROID_SDK_ROOT//\\/\\\\}" > "$LOCAL_PROPS"
fi fi
echo "==> Phase: third-party submodules (libvpx, opus, speex, wireguard-android)"
bash "$ROOT/scripts/init-third-party-submodules.sh"
if [[ "$RUN_NATIVE" == "1" && "$RUN_APK" == "1" ]]; then if [[ "$RUN_NATIVE" == "1" && "$RUN_APK" == "1" ]]; then
echo "==> Phase: native codecs" echo "==> Phase: native codecs"
ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}" ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}"
@@ -75,17 +78,21 @@ fi
# Avoid host-specific CMake cache paths when running in Docker. # Avoid host-specific CMake cache paths when running in Docker.
rm -rf "$ROOT/app/.cxx" "$ROOT/app/build/.cxx" "$ROOT/app/build/intermediates/cxx" rm -rf "$ROOT/app/.cxx" "$ROOT/app/build/.cxx" "$ROOT/app/build/intermediates/cxx"
rm -rf "$ROOT/gradle/wireguard-tunnel/.cxx" "$ROOT/gradle/wireguard-tunnel/build" 2>/dev/null || true
WG_HEAD="$(git -C "$ROOT/third-party/wireguard-android" rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "==> Phase: wireguard-android (:tunnel) third-party/wireguard-android @ ${WG_HEAD}"
if [[ "$RUN_APK" == "1" ]]; then if [[ "$RUN_APK" == "1" ]]; then
echo "==> Phase: gradle ${GRADLE_TASK}" echo "==> Phase: gradle :tunnel + ${GRADLE_TASK}"
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean "${GRADLE_TASK}" testDebugUnitTest ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}" testDebugUnitTest
else else
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean "${GRADLE_TASK}" ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}"
fi fi
else else
echo "==> Phase: gradle tests-only" echo "==> Phase: gradle tests-only (:tunnel + testDebugUnitTest)"
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" testDebugUnitTest ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" :tunnel:assembleDebug testDebugUnitTest
fi fi
APK="" APK=""

View File

@@ -14,9 +14,8 @@ source "$ROOT/scripts/android-ndk.sh"
export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")" export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")"
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
if [[ -f .gitmodules ]]; then echo "==> Initializing third-party git submodules"
git submodule update --init --recursive bash "$ROOT/scripts/init-third-party-submodules.sh"
fi
ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}" ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}"
echo "==> Building libvpx for: $ABIS" echo "==> Building libvpx for: $ABIS"
@@ -26,7 +25,7 @@ for abi in $ABIS; do
./scripts/build-native-codecs.sh "$abi" ./scripts/build-native-codecs.sh "$abi"
done done
echo "==> Gradle assembleDebug + unit tests" echo "==> Gradle :tunnel + assembleDebug + unit tests"
./gradlew --no-daemon clean assembleDebug testDebugUnitTest ./gradlew --no-daemon clean :tunnel:assembleDebug assembleDebug testDebugUnitTest
echo "==> Done: $ROOT/app/build/outputs/apk/debug/app-debug.apk" echo "==> Done: $ROOT/app/build/outputs/apk/debug/app-debug.apk"

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Sync and initialize all third-party/ git submodules from .gitmodules (recursive nested).
# Used by rebuild.sh, ci-build.sh, and deploy paths — no network except submodule fetch/update.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ONLY_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_PATH="${2:-}"
shift 2
;;
-h|--help)
echo "Usage: $0 [--only third-party/wireguard-android]"
echo " Initializes every path in .gitmodules under third-party/ (recursive)."
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ ! -f .gitmodules ]]; then
echo "error: .gitmodules not found in $ROOT" >&2
exit 1
fi
mapfile -t SUB_PATHS < <(git config -f .gitmodules --get-regexp '^submodule\..*\.path$' \
| awk '{ print $2 }' | grep '^third-party/' | sort -u)
if [[ ${#SUB_PATHS[@]} -eq 0 ]]; then
echo "error: no third-party paths in .gitmodules" >&2
exit 1
fi
if [[ -n "$ONLY_PATH" ]]; then
SUB_PATHS=("$ONLY_PATH")
fi
ensure_gitlink() {
local path="$1"
local name="$path"
if git cat-file -e "HEAD:${path}" 2>/dev/null \
|| git ls-files --stage "$path" | grep -q '^160000'; then
return 0
fi
local url
url="$(git config -f .gitmodules --get "submodule.${name}.url" || true)"
if [[ -z "$url" ]]; then
echo "error: $path listed in .gitmodules but no gitlink in tree and no url" >&2
return 1
fi
local sha
sha="$(git ls-remote "$url" HEAD | awk 'NR==1 {print $1}')"
if [[ -z "$sha" ]]; then
echo "error: could not resolve HEAD for $url (needed to register missing gitlink)" >&2
return 1
fi
echo "WARN: registering missing gitlink for $path at $sha (commit this gitlink)" >&2
git update-index --add --cacheinfo 160000,"$sha","$path"
}
validate_path() {
local path="$1"
case "$path" in
third-party/wireguard-android)
[[ -d "$path/tunnel/src/main/java" ]] \
&& [[ -f "$path/tunnel/tools/wireguard-tools/src/config.c" ]] \
&& [[ -f "$path/tunnel/tools/elf-cleaner/elf-cleaner.cpp" || -f "$path/tunnel/tools/elf-cleaner/main.cpp" ]]
;;
third-party/libvpx)
[[ -d "$path" ]]
;;
third-party/opus|third-party/speex)
[[ -f "$path/configure" || -f "$path/CMakeLists.txt" || -d "$path/include" ]]
;;
*)
[[ -d "$path" ]]
;;
esac
}
echo "==> third-party submodules: sync + update --init --recursive"
for path in "${SUB_PATHS[@]}"; do
if ! grep -q "$path" .gitmodules 2>/dev/null; then
echo "error: $path not in .gitmodules" >&2
exit 1
fi
ensure_gitlink "$path"
git submodule sync "$path"
git submodule update --init --recursive "$path"
if ! validate_path "$path"; then
echo "error: $path incomplete after submodule update (nested submodules missing?)" >&2
exit 1
fi
short="$(git -C "$path" rev-parse --short HEAD 2>/dev/null || echo '?')"
echo " $path @ $short"
done
if [[ -d third-party/wireguard-android/tunnel ]]; then
wg_tools="$(git -C third-party/wireguard-android/tunnel/tools/wireguard-tools rev-parse --short HEAD 2>/dev/null || echo '?')"
echo " third-party/wireguard-android/tunnel/tools/wireguard-tools @ $wg_tools"
fi
echo "==> third-party submodules ready"

View File

@@ -1,36 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Initialize wireguard-android submodule (third-party/wireguard-android in .gitmodules). # Back-compat wrapper — wireguard-android is one of the third-party submodules.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" exec "$ROOT/scripts/init-third-party-submodules.sh" --only third-party/wireguard-android
SUB_PATH="third-party/wireguard-android"
SUB_NAME="third-party/wireguard-android"
if ! grep -q "$SUB_PATH" .gitmodules 2>/dev/null; then
echo "error: .gitmodules missing $SUB_PATH" >&2
exit 1
fi
# .gitmodules without a gitlink in the tree breaks plain `git submodule update`.
if ! git cat-file -e "HEAD:$SUB_PATH" 2>/dev/null \
&& ! git ls-files --stage "$SUB_PATH" | grep -q '^160000'; then
url="$(git config -f .gitmodules --get "submodule.$SUB_NAME.url" || true)"
if [ -z "$url" ]; then
echo "error: no url for submodule.$SUB_NAME in .gitmodules" >&2
exit 1
fi
sha="$(git ls-remote "$url" HEAD | awk 'NR==1 {print $1}')"
if [ -z "$sha" ]; then
echo "error: could not resolve HEAD for $url" >&2
exit 1
fi
echo "registering missing gitlink for $SUB_PATH at $sha"
git update-index --add --cacheinfo 160000,"$sha","$SUB_PATH"
echo "warning: gitlink was missing from the tree — commit third-party/wireguard-android after init" >&2
fi
git submodule sync "$SUB_PATH"
git submodule update --init --recursive "$SUB_PATH"
echo "wireguard-android ready under $SUB_PATH"
echo "Gradle includes :tunnel when $SUB_PATH/tunnel exists."

196
scripts/simulate-vpn-crashes.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# VPN crash simulation lab — device (adb) and/or BE upload verification.
#
# Modes:
# MODE=device — trigger VpnCrashSimulator in :vpn via adb (debug APK)
# MODE=upload — POST synthetic vpn-process crash JSON to BE upload API
# MODE=both — device then verify BE (default when adb device present)
#
# Env:
# COUNT=70
# BASE=https://apps.f0xx.org/app/androidcast_project/crashes
# RUN_ID=YYYYMMDD_HHMMSS (batch tag for Issues search)
# PACKAGE=com.foxx.androidcast
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COUNT="${COUNT:-70}"
BASE="${BASE:-https://apps.f0xx.org/app/androidcast_project/crashes}"
UPLOAD_URL="$BASE/api/upload.php"
PACKAGE="${PACKAGE:-com.foxx.androidcast}"
RUN_ID="${RUN_ID:-$(date -u +%Y%m%d_%H%M%S)}"
MODE="${MODE:-auto}"
JAR="${JAR:-/tmp/ac_vpn_crash_cookies.txt}"
DELAY_MS="${DELAY_MS:-80}"
VERIFY_LOGIN="${VERIFY_LOGIN:-1}"
log() { printf '[vpn-crash-sim] %s\n' "$*"; }
has_device() {
adb devices 2>/dev/null | awk 'NR>1 && $2=="device" { found=1 } END { exit !found }'
}
simulate_device() {
local n="$1"
local i seq
log "device: triggering $n VPN-process crash recordings via $PACKAGE"
for ((i = 1; i <= n; i++)); do
seq="$i"
adb shell am start-service \
-n "$PACKAGE/com.foxx.androidcast.vpn.AndroidCastVpnService" \
-a com.foxx.androidcast.vpn.SIMULATE_CRASH \
--ei sim_seq "$seq" >/dev/null 2>&1 \
|| adb shell am startservice \
-n "$PACKAGE/com.foxx.androidcast.vpn.AndroidCastVpnService" \
-a com.foxx.androidcast.vpn.SIMULATE_CRASH \
--ei sim_seq "$seq" >/dev/null 2>&1 \
|| true
if [[ "$DELAY_MS" -gt 0 ]]; then
sleep "$(awk "BEGIN { print $DELAY_MS / 1000 }")"
fi
if (( i % 10 == 0 )); then
log " device progress $i/$n"
fi
done
log "device: done — waiting for crash watcher upload sweep"
sleep 15
}
upload_one() {
local seq="$1"
local report_id="vpn_sim_${RUN_ID}_$(printf '%03d' "$seq")"
local ms
ms="$(date +%s)000"
local fp
fp="$(printf 'vpn_sim_%s_%03d' "$RUN_ID" "$seq" | sha256sum | awk '{print $1}')"
local body
body="$(cat <<EOF
{
"schema_version": 1,
"report_id": "$report_id",
"generated_at_epoch_ms": $ms,
"crash_type": "java",
"scenario": "vpn_service",
"process": "com.foxx.androidcast:vpn",
"fingerprint": "$fp",
"tags": ["vpn_sim", "$RUN_ID"],
"device": {
"manufacturer": "SimLab",
"brand": "AndroidCast",
"model": "VpnCrashSim",
"device": "vpn_sim",
"product": "vpn_sim",
"sdk_int": 34,
"release": "14",
"abis": ["arm64-v8a"]
},
"app": {
"package": "$PACKAGE",
"version_name": "0.1.0",
"version_code": 100
},
"build": { "debug": true, "git_commit": "vpn_sim" },
"runtime_context": { "vpn_sim_seq": $seq, "run_id": "$RUN_ID" },
"java": {
"thread": "androidcast-vpn-worker",
"exception": "java.lang.RuntimeException",
"message": "VpnCrashSimulation seq=$seq proc=com.foxx.androidcast:vpn",
"stack_frames": [
"com.foxx.androidcast.vpn.WireGuardVpnEngine.apply(WireGuardVpnEngine.java:40)",
"com.foxx.androidcast.vpn.AndroidCastVpnService\$1.applyWireGuardConfig(AndroidCastVpnService.java:60)",
"com.foxx.androidcast.vpn.AndroidCastVpnService.onStartCommand(AndroidCastVpnService.java:95)",
"android.app.ActivityThread.handleServiceArgs(ActivityThread.java:5001)"
]
}
}
EOF
)"
curl -sk -w '\n%{http_code}' -H 'Content-Type: application/json' --data-binary "$body" "$UPLOAD_URL"
}
simulate_upload() {
local n="$1"
local ok=0 fail=0 dup=0
log "upload: posting $n vpn-process crash reports to $UPLOAD_URL (run_id=$RUN_ID)"
local i resp code
for ((i = 1; i <= n; i++)); do
resp="$(upload_one "$i")"
code="$(echo "$resp" | tail -n1)"
case "$code" in
200) ((ok++)) || true ;;
409) ((dup++)) || true ;;
*) ((fail++)) || true; log " FAIL seq=$i HTTP $code" ;;
esac
if (( i % 10 == 0 )); then
log " upload progress $i/$n (ok=$ok dup=$dup fail=$fail)"
fi
if [[ "$DELAY_MS" -gt 0 ]]; then
sleep "$(awk "BEGIN { print $DELAY_MS / 1000 }")"
fi
done
log "upload: ok=$ok duplicate=$dup failed=$fail / $n"
[[ "$fail" -eq 0 ]]
}
verify_be() {
if [[ "$VERIFY_LOGIN" != "1" ]]; then
log "verify: skipped (VERIFY_LOGIN=$VERIFY_LOGIN)"
return 0
fi
log "verify: login + search Issues for run_id=$RUN_ID"
curl -sk -c "$JAR" -b "$JAR" -L -X POST "$BASE/login" \
-d 'username=admin&password=admin' -o /dev/null || true
local json
local q="${QUERY:-$RUN_ID}"
json="$(curl -sk -b "$JAR" "$BASE/api/reports.php?q=${q}&per_page=200")"
local cnt
cnt="$(echo "$json" | python3 -c "
import json,sys
d=json.load(sys.stdin)
items=d.get('items') or []
print(len(items))
" 2>/dev/null || echo 0)"
log "verify: BE reports matching q=$q$cnt (expected up to $COUNT)"
if [[ "$cnt" -lt 1 ]]; then
log "verify: WARN — Issues API returned 0 (login cookie may be invalid in automation)"
log "verify: open $BASE/?view=reports&q=$q in browser when logged in"
return 1
fi
echo "$json" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for it in (d.get('items') or [])[:3]:
p=it.get('payload') or {}
j=p.get('java') or {}
print(' sample:', it.get('report_id'), 'process=', p.get('process'), 'frames=', len(j.get('stack_frames') or []))
" 2>/dev/null || true
}
if [[ "$MODE" == "auto" ]]; then
if has_device; then
MODE=both
else
MODE=upload
log "no adb device — using upload-only mode"
fi
fi
case "$MODE" in
device)
simulate_device "$COUNT"
;;
upload)
simulate_upload "$COUNT"
verify_be || true
;;
both)
simulate_device "$COUNT"
QUERY="${QUERY:-VpnCrashSimulation}" verify_be || true
;;
*)
echo "unknown MODE=$MODE (device|upload|both)" >&2
exit 1
;;
esac
log "complete run_id=$RUN_ID — Issues: $BASE/?view=reports&q=$RUN_ID"

View File

@@ -21,8 +21,13 @@ include ':app'
include ':session-studio' include ':session-studio'
project(':session-studio').projectDir = file('desktop/session-studio') project(':session-studio').projectDir = file('desktop/session-studio')
def wgTunnelDir = file('third-party/wireguard-android/tunnel') def wgTunnelSrc = file('third-party/wireguard-android/tunnel')
if (wgTunnelDir.exists()) { if (!wgTunnelSrc.isDirectory()) {
include ':tunnel' throw new GradleException(
project(':tunnel').projectDir = wgTunnelDir 'Missing third-party/wireguard-android/tunnel — run:\n' +
' git submodule update --init --recursive third-party/wireguard-android\n' +
' bash scripts/init-wireguard-submodule.sh'
)
} }
include ':tunnel'
project(':tunnel').projectDir = file('gradle/wireguard-tunnel')

14
third-party/README.md vendored
View File

@@ -1,19 +1,21 @@
# Third-party vendored dependencies # Third-party vendored dependencies
## wireguard-android (git submodule) ## wireguard-android (required git submodule)
Listed in `.gitmodules` like libvpx/opus/speex. Initialize after clone: Listed in `.gitmodules` like libvpx/opus/speex. **Required for Gradle**`:tunnel` is always included. Initialize after clone:
```bash ```bash
git submodule update --init third-party/wireguard-android bash scripts/init-third-party-submodules.sh
# or: # wireguard only:
bash scripts/init-wireguard-submodule.sh bash scripts/init-wireguard-submodule.sh
``` ```
Build with the `:tunnel` module (auto-included when present): `rebuild.sh` and `scripts/ci-build.sh` run the init script automatically.
Build links the `:tunnel` module via `gradle/wireguard-tunnel/` (sources from this submodule):
- Submodule path: `third-party/wireguard-android/` - Submodule path: `third-party/wireguard-android/`
- Gradle module: `third-party/wireguard-android/tunnel` - Gradle module: `third-party/wireguard-android/tunnel`
- License: MIT / Apache-2.0 (see upstream `COPYING`) - License: MIT / Apache-2.0 (see upstream `COPYING`)
`WireGuardTunnelBridge` loads `com.wireguard.android.backend.GoBackend` via reflection when the module is on the classpath. `WireGuardVpnEngine` (in `:vpn` process) links `GoBackend` from `:tunnel` (native `libwg-go.so` built from this tree); main app uses `VpnServiceClient` AIDL.