diff --git a/.cursor/rules/bottomline-reminder.mdc b/.cursor/rules/bottomline-reminder.mdc new file mode 100644 index 0000000..7b31976 --- /dev/null +++ b/.cursor/rules/bottomline-reminder.mdc @@ -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. diff --git a/.gitignore b/.gitignore index 2abaf1e..59396d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle/ +gradle/ /local.properties /.idea/ /build/ diff --git a/app/build.gradle b/app/build.gradle index b689be9..62faccc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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') } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 80d94ad..6fd2132 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -133,9 +134,16 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> + + + @@ -143,6 +151,17 @@ + + + + + + diff --git a/app/src/main/aidl/com/foxx/androidcast/IVpnOperationCallback.aidl b/app/src/main/aidl/com/foxx/androidcast/IVpnOperationCallback.aidl new file mode 100644 index 0000000..f386ade --- /dev/null +++ b/app/src/main/aidl/com/foxx/androidcast/IVpnOperationCallback.aidl @@ -0,0 +1,7 @@ +package com.foxx.androidcast; + +/** One-shot result for apply / tearDown. */ +interface IVpnOperationCallback { + oneway void onSuccess(); + oneway void onFailure(String reason); +} diff --git a/app/src/main/aidl/com/foxx/androidcast/IVpnService.aidl b/app/src/main/aidl/com/foxx/androidcast/IVpnService.aidl new file mode 100644 index 0000000..fa2e1b4 --- /dev/null +++ b/app/src/main/aidl/com/foxx/androidcast/IVpnService.aidl @@ -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); +} diff --git a/app/src/main/aidl/com/foxx/androidcast/IVpnStateCallback.aidl b/app/src/main/aidl/com/foxx/androidcast/IVpnStateCallback.aidl new file mode 100644 index 0000000..5db1bdf --- /dev/null +++ b/app/src/main/aidl/com/foxx/androidcast/IVpnStateCallback.aidl @@ -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); +} diff --git a/app/src/main/assets/licenses/combined.html b/app/src/main/assets/licenses/combined.html index 86b2271..f9d8c6e 100644 --- a/app/src/main/assets/licenses/combined.html +++ b/app/src/main/assets/licenses/combined.html @@ -32,5 +32,5 @@
  • Speex — Revised BSD license (Xiph.org Foundation). See third-party/speex/COPYING.
  • -

    WireGuard tunnel module (optional Gradle module)

    -

    When third-party/wireguard-android is initialized and the :tunnel module is included, the app may link the WireGuard Android tunnel library (Apache License 2.0; see upstream wireguard-android). Remote access still uses Android VpnService (system VPN permission).

    +

    WireGuard tunnel module (third-party/wireguard-android)

    +

    The app links the WireGuard Android tunnel library from git submodule third-party/wireguard-android (Apache License 2.0; see upstream COPYING and https://git.zx2c4.com/wireguard-android). WireGuard lab mode uses Android VpnService (system VPN permission) via GoBackend.

    diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java index 91e4045..f6c03eb 100644 --- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java +++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java @@ -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(); diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java index afc8087..02f0114 100644 --- a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java @@ -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 - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 - * class CrashReporter - * Contributors: - * - Anton Afanasyeu (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 ""; + } } } diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java b/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java index 1f23f83..f27f183 100644 --- a/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java @@ -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"; } diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java index acb4139..feb40cb 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java @@ -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; } diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java index 0d13f85..bc287e3 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -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); } } diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java index 220258e..1ff9d6c 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java @@ -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); } } diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java deleted file mode 100644 index fe07e77..0000000 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/WireGuardTunnelBridge.java +++ /dev/null @@ -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) stateClass, name); - } -} diff --git a/app/src/main/java/com/foxx/androidcast/vpn/AndroidCastVpnService.java b/app/src/main/java/com/foxx/androidcast/vpn/AndroidCastVpnService.java new file mode 100644 index 0000000..fe377dc --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/vpn/AndroidCastVpnService.java @@ -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 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()); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java b/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java similarity index 94% rename from app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java rename to app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java index 4192593..1e7af54 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessVpnService.java +++ b/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java @@ -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 out = new java.util.ArrayList<>(); + ArrayList out = new ArrayList<>(); for (String p : parts) { if (!p.isEmpty()) { out.add(p.trim()); diff --git a/app/src/main/java/com/foxx/androidcast/vpn/VpnCrashSimulator.java b/app/src/main/java/com/foxx/androidcast/vpn/VpnCrashSimulator.java new file mode 100644 index 0000000..529ca00 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/vpn/VpnCrashSimulator.java @@ -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), + }; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java b/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java new file mode 100644 index 0000000..b00802d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/vpn/VpnServiceClient.java @@ -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 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 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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/vpn/VpnState.java b/app/src/main/java/com/foxx/androidcast/vpn/VpnState.java new file mode 100644 index 0000000..e14cb10 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/vpn/VpnState.java @@ -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() {} +} diff --git a/app/src/main/java/com/foxx/androidcast/vpn/WireGuardVpnEngine.java b/app/src/main/java/com/foxx/androidcast/vpn/WireGuardVpnEngine.java new file mode 100644 index 0000000..4c2099e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/vpn/WireGuardVpnEngine.java @@ -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) { + } + } +} diff --git a/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java b/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java new file mode 100644 index 0000000..94ea63b --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/crash/CrashReporterProcessTest.java @@ -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")); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java new file mode 100644 index 0000000..d5b3d4a --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/WireGuardConfigParseTest.java @@ -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()); + } + } +} diff --git a/app/src/test/java/com/foxx/androidcast/vpn/VpnStateTest.java b/app/src/test/java/com/foxx/androidcast/vpn/VpnStateTest.java new file mode 100644 index 0000000..1111045 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/vpn/VpnStateTest.java @@ -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); + } +} diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md index 1536bcc..2fb2ffb 100644 --- a/docs/BUILD_DEPLOY.md +++ b/docs/BUILD_DEPLOY.md @@ -55,6 +55,17 @@ cd orchestration 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 1. Open Builder → sign in (default dev user from crash schema seed). diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md index 04630f5..61a2243 100644 --- a/docs/REMOTE_ACCESS_IMPL.md +++ b/docs/REMOTE_ACCESS_IMPL.md @@ -25,7 +25,7 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE - **WireGuard:** foreground service; jittered poll **1–7 min** → `heartbeat.php` with `type: ra` - Heartbeat URL derived from crash upload URL (`…/api/heartbeat.php`) - 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) @@ -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. +## 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) ```bash -bash scripts/init-wireguard-submodule.sh -./gradlew :app:assembleDebug # includes :tunnel when submodule present +bash scripts/init-third-party-submodules.sh # or ./rebuild.sh (includes init + :tunnel build) +./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 diff --git a/docs/drafts/20100611_1_net_obfuscation.txt b/docs/drafts/20100611_1_net_obfuscation.txt new file mode 100644 index 0000000..ab00fa0 --- /dev/null +++ b/docs/drafts/20100611_1_net_obfuscation.txt @@ -0,0 +1,6 @@ +Network traffic obfuscation + +Definitions: +- E2E - E2E encryption +- P2P - P2P encryption +- POLY - Polymorphic ciphers, persistent and temporary keys diff --git a/docs/drafts/20100611_1_net_topology_glossary.txt b/docs/drafts/20100611_1_net_topology_glossary.txt new file mode 100644 index 0000000..28d7f98 --- /dev/null +++ b/docs/drafts/20100611_1_net_topology_glossary.txt @@ -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. diff --git a/docs/drafts/20100611_2_av_conversion_and_non_rt.txt b/docs/drafts/20100611_2_av_conversion_and_non_rt.txt new file mode 100644 index 0000000..70d5311 --- /dev/null +++ b/docs/drafts/20100611_2_av_conversion_and_non_rt.txt @@ -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 diff --git a/examples/crash_reporter/backend/THIRD_PARTY_LICENSES.md b/examples/crash_reporter/backend/THIRD_PARTY_LICENSES.md index 660d175..9628cbe 100644 --- a/examples/crash_reporter/backend/THIRD_PARTY_LICENSES.md +++ b/examples/crash_reporter/backend/THIRD_PARTY_LICENSES.md @@ -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`) | | 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) diff --git a/gradle.properties b/gradle.properties index a99232b..96923bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,6 @@ 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.nonTransitiveRClass=true # Gradle 8.9 must run on JDK 17–21 (Gentoo default JDK 25 fails with class file version 69). diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 2c35211..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 09523c0..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -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 diff --git a/ndk/README.md b/ndk/README.md index 6af8bfa..fd6e86c 100644 --- a/ndk/README.md +++ b/ndk/README.md @@ -13,13 +13,10 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft ## Enable native codecs (developer) -1. Initialize submodules as needed: +1. Initialize all third-party submodules: ```bash - git submodule update --init third-party/libvpx - git submodule update --init third-party/opus - git submodule update --init third-party/speex - git submodule update --init third-party/wireguard-android + bash scripts/init-third-party-submodules.sh ``` 2. Build static libs per ABI (NDK auto-detected via `scripts/android-ndk.sh`): diff --git a/rebuild.sh b/rebuild.sh index d0da09c..7c8dce4 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -142,6 +142,9 @@ export ANDROIDCAST_ROOT="$ROOT" export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")" export ANDROID_NDK_ROOT="$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 echo "==> Native ccache: enabled (ANDROIDCAST_CCACHE=${ANDROIDCAST_CCACHE:-auto})" else @@ -158,15 +161,18 @@ for ARCH_ABI in ${ARCH_ABIS}; do LC_ALL=C ./scripts/build-native-codecs.sh "${ARCH_ABI}" 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)" # 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/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 echo "WARN: app/build is not writable (often root-owned after Docker). Run: sudo rm -rf app/build" >&2 fi ./gradlew --stop ./gradlew clean -./gradlew --build-cache assembleDebug +./gradlew --build-cache :tunnel:assembleDebug :app:assembleDebug if [[ "$RUN_UNIT_TESTS" == "1" ]]; then echo "==> Running unit tests (./gradlew testDebugUnitTest)" diff --git a/scripts/build-native-codecs.sh b/scripts/build-native-codecs.sh index de4edfb..3c099bb 100755 --- a/scripts/build-native-codecs.sh +++ b/scripts/build-native-codecs.sh @@ -3,7 +3,7 @@ # CMake in ndk/ autolinks when those archives exist (ANDROIDCAST_HAVE_* = 1). # # Prerequisites: -# git submodule update --init third-party/libvpx +# bash scripts/init-third-party-submodules.sh # Android NDK (auto-detected via scripts/android-ndk.sh) # # Usage: @@ -22,7 +22,7 @@ BUILD_DIR="$OUT/libvpx-build" if [[ ! -d "$VPX_SRC_REAL" ]]; then 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 fi @@ -253,7 +253,7 @@ androidcast_ndk_configure_host() { build_opus_for_abi() { local opus_src="$ROOT/third-party/opus" 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 fi local opus_build="$BUILD_DIR/opus-cmake" @@ -284,7 +284,7 @@ build_opus_for_abi() { build_speex_for_abi() { local speex_src_real="$ROOT/third-party/speex" 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 fi local speex_src="$speex_src_real" diff --git a/scripts/ci-build-and-publish-ota.sh b/scripts/ci-build-and-publish-ota.sh index bbfb1d7..9d764e6 100755 --- a/scripts/ci-build-and-publish-ota.sh +++ b/scripts/ci-build-and-publish-ota.sh @@ -61,6 +61,9 @@ if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then printf 'sdk.dir=%s\n' "${ANDROID_SDK_ROOT//\\/\\\\}" > "$LOCAL_PROPS" 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 echo "==> Phase: native codecs" 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. 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 - echo "==> Phase: gradle ${GRADLE_TASK}" + echo "==> Phase: gradle :tunnel + ${GRADLE_TASK}" 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 - ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean "${GRADLE_TASK}" + ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}" fi else - echo "==> Phase: gradle tests-only" - ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" testDebugUnitTest + echo "==> Phase: gradle tests-only (:tunnel + testDebugUnitTest)" + ./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" :tunnel:assembleDebug testDebugUnitTest fi APK="" diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh index 7981f13..4f262be 100755 --- a/scripts/ci-build.sh +++ b/scripts/ci-build.sh @@ -14,9 +14,8 @@ source "$ROOT/scripts/android-ndk.sh" export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")" export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" -if [[ -f .gitmodules ]]; then - git submodule update --init --recursive -fi +echo "==> Initializing third-party git submodules" +bash "$ROOT/scripts/init-third-party-submodules.sh" ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}" echo "==> Building libvpx for: $ABIS" @@ -26,7 +25,7 @@ for abi in $ABIS; do ./scripts/build-native-codecs.sh "$abi" done -echo "==> Gradle assembleDebug + unit tests" -./gradlew --no-daemon clean assembleDebug testDebugUnitTest +echo "==> Gradle :tunnel + assembleDebug + unit tests" +./gradlew --no-daemon clean :tunnel:assembleDebug assembleDebug testDebugUnitTest echo "==> Done: $ROOT/app/build/outputs/apk/debug/app-debug.apk" diff --git a/scripts/init-third-party-submodules.sh b/scripts/init-third-party-submodules.sh new file mode 100755 index 0000000..54647f6 --- /dev/null +++ b/scripts/init-third-party-submodules.sh @@ -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" diff --git a/scripts/init-wireguard-submodule.sh b/scripts/init-wireguard-submodule.sh index 734b83a..6acabbb 100755 --- a/scripts/init-wireguard-submodule.sh +++ b/scripts/init-wireguard-submodule.sh @@ -1,36 +1,5 @@ #!/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 ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -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." +exec "$ROOT/scripts/init-third-party-submodules.sh" --only third-party/wireguard-android diff --git a/scripts/simulate-vpn-crashes.sh b/scripts/simulate-vpn-crashes.sh new file mode 100755 index 0000000..742e29c --- /dev/null +++ b/scripts/simulate-vpn-crashes.sh @@ -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 </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" diff --git a/settings.gradle b/settings.gradle index 406f3e7..3b0b36f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,8 +21,13 @@ include ':app' include ':session-studio' project(':session-studio').projectDir = file('desktop/session-studio') -def wgTunnelDir = file('third-party/wireguard-android/tunnel') -if (wgTunnelDir.exists()) { - include ':tunnel' - project(':tunnel').projectDir = wgTunnelDir +def wgTunnelSrc = file('third-party/wireguard-android/tunnel') +if (!wgTunnelSrc.isDirectory()) { + throw new GradleException( + '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') diff --git a/third-party/README.md b/third-party/README.md index efe2202..05e15f2 100644 --- a/third-party/README.md +++ b/third-party/README.md @@ -1,19 +1,21 @@ # 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 -git submodule update --init third-party/wireguard-android -# or: +bash scripts/init-third-party-submodules.sh +# wireguard only: 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/` - Gradle module: `third-party/wireguard-android/tunnel` - 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.