1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 08:57:44 +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

@@ -110,6 +110,12 @@ android {
buildConfig true
}
packaging {
resources {
excludes += '/META-INF/versions/9/OSGI-INF/MANIFEST.MF'
}
}
testOptions {
unitTests {
includeAndroidResources = true
@@ -133,7 +139,5 @@ dependencies {
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"?>
<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.REQUEST_INSTALL_PACKAGES" />
@@ -133,9 +134,16 @@
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<!-- VPN stack: isolated process com.foxx.androidcast:vpn (AIDL via AndroidCastVpnService). -->
<service
android:name=".remoteaccess.RemoteAccessVpnService"
android:name=".vpn.AndroidCastVpnService"
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:foregroundServiceType="connectedDevice">
<intent-filter>
@@ -143,6 +151,17 @@
</intent-filter>
</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
android:name=".TrayNotificationReceiver"
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>
</ul>
<h2>WireGuard tunnel module (optional Gradle module)</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>
<h2>WireGuard tunnel module (<code>third-party/wireguard-android</code>)</h2>
<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
public void onCreate() {
super.onCreate();
CrashReporter.install(this);
if (!CrashReporter.isMainProcess(this, CrashReporter.currentProcessName())) {
return;
}
CodecPriorityCatalog.init(this);
CastLocaleHelper.applyStoredLocale(this);
CastThemeHelper.applyNightMode(this);
CrashReporter.install(this);
ReceiverAvRuntime.reload(this);
CastTransportFactory.init(this);
WiredDisplayMonitor.getInstance(this).ensureRegistered();

View File

@@ -1,16 +1,5 @@
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.content.Context;
import android.util.Log;
@@ -21,19 +10,29 @@ import org.json.JSONObject;
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 {
private static final String TAG = "CrashReporter";
private CrashReporter() {}
/** Install handlers appropriate for the current process. */
public static void install(Context context) {
Context app = context.getApplicationContext();
if (!isMainProcess(app)) {
return;
String proc = currentProcessName();
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);
final Thread.UncaughtExceptionHandler previous =
@@ -55,6 +54,25 @@ public final class CrashReporter {
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) {
if (!CrashSettingsStore.load(context).enabled) {
return;
@@ -95,7 +113,22 @@ public final class CrashReporter {
CrashWatcherService.requestImmediateSweep(context, true);
}
private static boolean isMainProcess(Context context) {
return context.getPackageName().equals(Application.getProcessName());
public static boolean isMainProcess(Context context, String procName) {
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() {
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()) {
return "cast_session";
}

View File

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

View File

@@ -19,6 +19,7 @@ import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.R;
import com.foxx.androidcast.vpn.VpnServiceClient;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -185,20 +186,29 @@ public final class RemoteAccessService extends Service {
if (creds == null) {
return;
}
boolean ok = false;
if ("wireguard".equals(tunnel)) {
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
if (!wgConfig.isEmpty()) {
ok = WireGuardTunnelBridge.applyConfig(this, wgConfig);
if (wgConfig.isEmpty()) {
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)) {
ok = ReverseSshTunnelBridge.applyConfig(this, creds);
if (ReverseSshTunnelBridge.applyConfig(this, creds)) {
updateNotificationConnected(sessionId, tunnel);
}
} else {
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 com.foxx.androidcast.vpn.VpnServiceClient;
/** Tear down all remote-access transports (WireGuard + RSSH). */
public final class RemoteAccessTunnelHelper {
private RemoteAccessTunnelHelper() {}
public static void tearDownAll(Context context) {
WireGuardTunnelBridge.tearDown(context);
VpnServiceClient.get(context).tearDown();
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.NotificationChannel;
@@ -18,14 +18,15 @@ import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.R;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.regex.Matcher;
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 {
private static final String TAG = "RemoteAccessVPN";
public static final String ACTION_UP = "com.foxx.androidcast.remoteaccess.VPN_UP";
public static final String ACTION_DOWN = "com.foxx.androidcast.remoteaccess.VPN_DOWN";
public static final String ACTION_UP = "com.foxx.androidcast.vpn.VPN_UP";
public static final String ACTION_DOWN = "com.foxx.androidcast.vpn.VPN_DOWN";
public static final String EXTRA_WG_CONFIG = "wg_config";
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;
}
/** Routes from AllowedIPs= lines (split tunnel — typically server wg /32). */
private static String[] parseAllowedRoutes(String wgConfig) {
if (wgConfig == null) {
return new String[0];
@@ -118,7 +118,7 @@ public final class RemoteAccessVpnService extends VpnService {
return new String[0];
}
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) {
if (!p.isEmpty()) {
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);
}
}