mirror of
git://f0xx.org/android_cast
synced 2026-07-29 08:57:44 +03:00
vpn service sync
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
/** One-shot result for apply / tearDown. */
|
||||
interface IVpnOperationCallback {
|
||||
oneway void onSuccess();
|
||||
oneway void onFailure(String reason);
|
||||
}
|
||||
19
app/src/main/aidl/com/foxx/androidcast/IVpnService.aidl
Normal file
19
app/src/main/aidl/com/foxx/androidcast/IVpnService.aidl
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
241
app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java
Normal file
241
app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/com/foxx/androidcast/vpn/VpnState.java
Normal file
11
app/src/main/java/com/foxx/androidcast/vpn/VpnState.java
Normal 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() {}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/src/test/java/com/foxx/androidcast/vpn/VpnStateTest.java
Normal file
15
app/src/test/java/com/foxx/androidcast/vpn/VpnStateTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user