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

initial web rssh stage 1

This commit is contained in:
Anton Afanasyeu
2026-06-03 06:50:57 +02:00
parent bd339ee90c
commit f818ec4f0c
52 changed files with 2173 additions and 10 deletions

View File

@@ -129,4 +129,9 @@ dependencies {
implementation 'com.google.android.play:review:2.0.2'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
if (project.findProject(':tunnel') != null) {
implementation project(':tunnel')
}
}

View File

@@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application
android:name=".AndroidCastApplication"
@@ -127,6 +128,21 @@
android:value="Persistent status bar icon for Android Cast" />
</service>
<service
android:name=".remoteaccess.RemoteAccessService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<service
android:name=".remoteaccess.RemoteAccessVpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="connectedDevice">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<receiver
android:name=".TrayNotificationReceiver"
android:exported="false" />

View File

@@ -20,6 +20,7 @@ import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator;
/** Applies saved theme and locale at process start. */
public class AndroidCastApplication extends Application {
@@ -39,5 +40,6 @@ public class AndroidCastApplication extends Application {
CastTransportFactory.init(this);
WiredDisplayMonitor.getInstance(this).ensureRegistered();
CommercialFeatures.refreshFromPreferences(this);
RemoteAccessCoordinator.onBootIfNeeded(this);
}
}

View File

@@ -61,6 +61,7 @@ public final class AppPreferences {
private static final String KEY_DEV_DIAG_PING_PONG = "dev_diag_ping_pong";
private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode";
private static final String KEY_DEV_BACKEND_NTP_SYNC = "dev_backend_ntp_sync";
private static final String KEY_DEV_REMOTE_ACCESS_MODE = "dev_remote_access_mode";
/** Minimum interval between automatic OTA checks on app launch. */
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
@@ -428,6 +429,18 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_BACKEND_NTP_SYNC, enabled).apply();
}
public static com.foxx.androidcast.remoteaccess.RemoteAccessMode getDevRemoteAccessMode(Context context) {
return com.foxx.androidcast.remoteaccess.RemoteAccessMode.fromStored(
prefs(context).getString(KEY_DEV_REMOTE_ACCESS_MODE, com.foxx.androidcast.remoteaccess.RemoteAccessMode.DISABLED.name()));
}
public static void setDevRemoteAccessMode(Context context, com.foxx.androidcast.remoteaccess.RemoteAccessMode mode) {
if (mode == null) {
mode = com.foxx.androidcast.remoteaccess.RemoteAccessMode.DISABLED;
}
prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_MODE, mode.name()).apply();
}
/**
* Reset user-facing settings shown in Android Cast settings.
* Developer-only settings are intentionally preserved.

View File

@@ -38,12 +38,16 @@ import com.foxx.androidcast.sender.SenderResolutionUiMode;
import com.foxx.androidcast.dev.DevDiagnosticsController;
import com.foxx.androidcast.dev.DevNetworkSelfTestController;
import com.foxx.androidcast.network.diag.CastDiagPing;
import com.foxx.androidcast.remoteaccess.RemoteAccessCoordinator;
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.textfield.TextInputEditText;
/** Hidden developer settings (opened via app-info area easter egg). */
public class DeveloperSettingsActivity extends AppCompatActivity {
private static final int REQUEST_VPN_PREPARE = 9107;
private DevDiagnosticsController diagnosticsController;
private DevNetworkSelfTestController networkSelfTestController;
private android.view.View settingsPanel;
@@ -51,6 +55,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
private android.view.View networkSelfTestPanel;
private Spinner videoPresetSpinner;
private Spinner audioPresetSpinner;
private Spinner remoteAccessSpinner;
private RemoteAccessMode pendingRemoteAccessMode;
private boolean suppressRemoteAccessSpinnerEvents;
private SeekBar intensitySeek;
private TextView intensityValue;
@@ -106,6 +113,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
});
bindAvPresets();
bindRemoteAccessSection();
bindDiagPingSection();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
@@ -144,11 +152,87 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
findViewById(R.id.edit_ota_manifest_url),
findViewById(R.id.check_ota_on_launch));
bindAvPresets();
bindRemoteAccessSection();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
bindCommercialSection();
}
private void bindRemoteAccessSection() {
remoteAccessSpinner = findViewById(R.id.spinner_dev_remote_access_mode);
if (remoteAccessSpinner == null) {
return;
}
RemoteAccessMode[] modes = RemoteAccessMode.values();
String[] labels = new String[]{
getString(R.string.dev_remote_access_disabled),
getString(R.string.dev_remote_access_wireguard),
getString(R.string.dev_remote_access_rssh_unavailable),
};
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, labels);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
remoteAccessSpinner.setAdapter(adapter);
RemoteAccessMode current = AppPreferences.getDevRemoteAccessMode(this);
suppressRemoteAccessSpinnerEvents = true;
remoteAccessSpinner.setOnItemSelectedListener(null);
remoteAccessSpinner.setSelection(current.ordinal(), false);
suppressRemoteAccessSpinnerEvents = false;
remoteAccessSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, android.view.View view,
int position, long id) {
if (suppressRemoteAccessSpinnerEvents) {
return;
}
RemoteAccessMode picked = modes[position];
if (picked == RemoteAccessMode.RSSH) {
Toast.makeText(DeveloperSettingsActivity.this,
R.string.dev_remote_access_rssh_toast, Toast.LENGTH_SHORT).show();
suppressRemoteAccessSpinnerEvents = true;
remoteAccessSpinner.setSelection(
AppPreferences.getDevRemoteAccessMode(DeveloperSettingsActivity.this).ordinal(), false);
suppressRemoteAccessSpinnerEvents = false;
return;
}
applyRemoteAccessMode(picked);
}
@Override
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
});
}
private void applyRemoteAccessMode(RemoteAccessMode mode) {
if (mode == RemoteAccessMode.WIREGUARD) {
android.content.Intent prep = android.net.VpnService.prepare(this);
if (prep != null) {
pendingRemoteAccessMode = mode;
startActivityForResult(prep, REQUEST_VPN_PREPARE);
return;
}
}
AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode);
Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, android.content.Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_VPN_PREPARE && pendingRemoteAccessMode != null) {
RemoteAccessMode mode = pendingRemoteAccessMode;
pendingRemoteAccessMode = null;
if (resultCode == RESULT_OK) {
AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode);
} else {
bindRemoteAccessSection();
Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show();
}
}
}
private void bindDiagPingSection() {
CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong);
bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this),

View File

@@ -0,0 +1,40 @@
package com.foxx.androidcast.remoteaccess;
import android.content.Context;
import android.util.Log;
import com.foxx.androidcast.AppPreferences;
/** Start/stop remote access foreground polling + VPN based on developer mode. */
public final class RemoteAccessCoordinator {
private static final String TAG = "RemoteAccessCoord";
private RemoteAccessCoordinator() {}
public static void applyMode(Context context, RemoteAccessMode mode) {
Context app = context.getApplicationContext();
if (mode == null || mode == RemoteAccessMode.DISABLED) {
Log.i(TAG, "disabling remote access");
RemoteAccessService.stop(app);
RemoteAccessVpnService.stop(app);
RemoteAccessService.sendDisableOnce(app);
return;
}
if (mode == RemoteAccessMode.WIREGUARD) {
RemoteAccessService.start(app, mode);
return;
}
Log.w(TAG, "unsupported mode " + mode);
}
public static void syncFromPreferences(Context context) {
applyMode(context, AppPreferences.getDevRemoteAccessMode(context));
}
public static void onBootIfNeeded(Context context) {
RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(context);
if (mode.isActive()) {
RemoteAccessService.start(context.getApplicationContext(), mode);
}
}
}

View File

@@ -0,0 +1,106 @@
package com.foxx.androidcast.remoteaccess;
import android.content.Context;
import android.util.Log;
import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.crash.CrashSettingsStore;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/** POST heartbeat type:ra to BE (shared by RemoteAccessService). */
public final class RemoteAccessHeartbeatClient {
private static final String TAG = "RemoteAccessHB";
private static final int HTTP_TIMEOUT_MS = 15_000;
private RemoteAccessHeartbeatClient() {}
public static String resolveHeartbeatUrl(Context context) {
String crash = CrashSettingsStore.load(context).uploadUrl;
if (crash != null && !crash.trim().isEmpty()) {
return toHeartbeatUrl(crash.trim());
}
String ota = com.foxx.androidcast.AppPreferences.getOtaChannelUrl(context);
if (ota != null && !ota.trim().isEmpty()) {
return toHeartbeatUrl(ota.trim());
}
if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null && !BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim().isEmpty()) {
return toHeartbeatUrl(BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim());
}
return "";
}
public static String toHeartbeatUrl(String backend) {
if (backend.contains("/api/upload.php")) {
return backend.replace("/api/upload.php", "/api/heartbeat.php");
}
if (backend.endsWith("/")) {
return backend + "api/heartbeat.php";
}
return backend + "/api/heartbeat.php";
}
/** @return parsed heartbeat object from response, or null on failure */
public static JSONObject postRa(Context context, JSONObject heartbeatBody) throws Exception {
String url = resolveHeartbeatUrl(context);
if (url.isEmpty()) {
throw new IllegalStateException("no_backend_url");
}
JSONObject req = new JSONObject();
req.put("heartbeat", heartbeatBody);
String text = httpPostJson(url, req.toString());
JSONObject resp = new JSONObject(text);
return resp.optJSONObject("heartbeat");
}
private static String httpPostJson(String url, String body) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
c.setRequestMethod("POST");
c.setConnectTimeout(HTTP_TIMEOUT_MS);
c.setReadTimeout(HTTP_TIMEOUT_MS);
c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json; charset=utf-8");
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
try (OutputStream os = c.getOutputStream()) {
os.write(bytes);
}
int code = c.getResponseCode();
String resp = readBody(c);
c.disconnect();
if (code < 200 || code >= 300) {
Log.w(TAG, "heartbeat HTTP " + code + " body=" + resp);
throw new IllegalStateException("http_" + code);
}
return resp;
}
private static String readBody(HttpURLConnection c) {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
if (sb.length() > 8192) {
break;
}
}
return sb.toString();
} catch (Exception e) {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(c.getErrorStream(), StandardCharsets.UTF_8))) {
String line = br.readLine();
return line != null ? line : "";
} catch (Exception ignored) {
return "";
}
}
}
}

View File

@@ -0,0 +1,44 @@
package com.foxx.androidcast.remoteaccess;
import java.util.Locale;
/** Developer setting: on-demand remote debug transport. */
public enum RemoteAccessMode {
/** Remote access off; tear down tunnels and notify BE. */
DISABLED,
/** WireGuard outbound tunnel (v1). */
WIREGUARD,
/** Reverse SSH — reserved; not selectable in UI until implemented. */
RSSH;
public static RemoteAccessMode fromStored(String name) {
if (name == null || name.isEmpty()) {
return DISABLED;
}
try {
return valueOf(name.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
if ("NONE".equalsIgnoreCase(name)) {
return DISABLED;
}
return DISABLED;
}
}
/** BE heartbeat opt_in_mode / capabilities value. */
public String toApiValue() {
switch (this) {
case WIREGUARD:
return "wireguard";
case RSSH:
return "rssh";
case DISABLED:
default:
return "none";
}
}
public boolean isActive() {
return this == WIREGUARD;
}
}

View File

@@ -0,0 +1,226 @@
package com.foxx.androidcast.remoteaccess;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import android.os.SystemClock;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.R;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** Jittered heartbeat type:ra polling while remote access mode is active. */
public final class RemoteAccessService extends Service {
private static final String TAG = "RemoteAccessSvc";
public static final String ACTION_START = "com.foxx.androidcast.remoteaccess.START";
public static final String ACTION_STOP = "com.foxx.androidcast.remoteaccess.STOP";
public static final String ACTION_POLL = "com.foxx.androidcast.remoteaccess.POLL";
public static final String ACTION_DISABLE_ONCE = "com.foxx.androidcast.remoteaccess.DISABLE_ONCE";
public static final String EXTRA_MODE = "mode";
private static final int NOTIF_ID = 0x7a00;
private static final String CHANNEL_ID = "remote_access";
private static final int MIN_POLL_MS = 60_000;
private static final int MAX_POLL_MS = 420_000;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Random random = new Random();
private String sessionRandom = UUID.randomUUID().toString();
public static void start(Context context, RemoteAccessMode mode) {
Intent i = new Intent(context, RemoteAccessService.class);
i.setAction(ACTION_START);
i.putExtra(EXTRA_MODE, mode.name());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(i);
} else {
context.startService(i);
}
}
public static void stop(Context context) {
Intent i = new Intent(context, RemoteAccessService.class);
i.setAction(ACTION_STOP);
context.startService(i);
}
public static void sendDisableOnce(Context context) {
Intent i = new Intent(context, RemoteAccessService.class);
i.setAction(ACTION_DISABLE_ONCE);
context.startService(i);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : ACTION_POLL;
if (ACTION_STOP.equals(action)) {
cancelPoll(this);
WireGuardTunnelBridge.tearDown(this);
stopForeground(true);
stopSelf();
return START_NOT_STICKY;
}
if (ACTION_DISABLE_ONCE.equals(action)) {
executor.execute(() -> postDisableHeartbeat());
return START_NOT_STICKY;
}
RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(this);
if (intent != null && intent.hasExtra(EXTRA_MODE)) {
mode = RemoteAccessMode.fromStored(intent.getStringExtra(EXTRA_MODE));
}
if (!mode.isActive()) {
stop(this);
return START_NOT_STICKY;
}
ensureChannel();
startForeground(NOTIF_ID, buildNotification(mode));
final RemoteAccessMode pollMode = mode;
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
executor.execute(() -> runPoll(pollMode));
}
scheduleNextPoll();
return START_STICKY;
}
private void runPoll(RemoteAccessMode mode) {
try {
JSONObject hb = new JSONObject();
hb.put("type", "ra");
hb.put("status", "enable");
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
hb.put("random", sessionRandom);
hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME);
hb.put("tunnel_mode", mode.toApiValue());
JSONArray caps = new JSONArray();
caps.put("wireguard");
caps.put("files_app_home");
hb.put("capabilities", caps);
JSONObject resp = RemoteAccessHeartbeatClient.postRa(this, hb);
if (resp == null) {
return;
}
String raAction = resp.optString("action", "wait");
if ("connect".equals(raAction)) {
handleConnect(resp);
} else if ("disabled".equals(raAction) || "deny".equals(raAction)) {
WireGuardTunnelBridge.tearDown(this);
}
} catch (Exception e) {
Log.w(TAG, "poll failed: " + e.getMessage());
}
}
private void postDisableHeartbeat() {
try {
WireGuardTunnelBridge.tearDown(this);
JSONObject hb = new JSONObject();
hb.put("type", "ra");
hb.put("status", "disable");
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
hb.put("random", sessionRandom);
RemoteAccessHeartbeatClient.postRa(this, hb);
} catch (Exception e) {
Log.w(TAG, "disable notify failed: " + e.getMessage());
}
}
private void handleConnect(JSONObject resp) {
if (!"wireguard".equals(resp.optString("tunnel", ""))) {
Log.w(TAG, "unsupported tunnel in connect");
return;
}
JSONObject creds = resp.optJSONObject("credentials");
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
if (wgConfig.isEmpty()) {
return;
}
WireGuardTunnelBridge.applyConfig(this, wgConfig);
}
private void scheduleNextPoll() {
int delay = MIN_POLL_MS + random.nextInt(MAX_POLL_MS - MIN_POLL_MS + 1);
AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
if (am == null) {
return;
}
PendingIntent pi = PendingIntent.getService(this, 0,
new Intent(this, RemoteAccessService.class).setAction(ACTION_POLL),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
long trigger = SystemClock.elapsedRealtime() + delay;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, pi);
} else {
am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, pi);
}
}
private static void cancelPoll(Context context) {
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (am == null) {
return;
}
PendingIntent pi = PendingIntent.getService(context, 0,
new Intent(context, RemoteAccessService.class).setAction(ACTION_POLL),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
am.cancel(pi);
}
private Notification buildNotification(RemoteAccessMode mode) {
Intent open = new Intent(this, DeveloperSettingsActivity.class);
PendingIntent pi = PendingIntent.getActivity(this, 0, open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.dev_remote_access_notification_title))
.setContentText(getString(R.string.dev_remote_access_notification_poll, mode.name()))
.setContentIntent(pi)
.setOngoing(true)
.build();
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm == null) {
return;
}
NotificationChannel ch = new NotificationChannel(CHANNEL_ID,
getString(R.string.dev_remote_access_notification_channel),
NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(ch);
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
}

View File

@@ -0,0 +1,150 @@
package com.foxx.androidcast.remoteaccess;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.net.VpnService;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.R;
import java.net.InetAddress;
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. */
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 EXTRA_WG_CONFIG = "wg_config";
private static final int NOTIF_ID = 0x7a01;
private static final String CHANNEL_ID = "remote_access_vpn";
private ParcelFileDescriptor tun;
public static boolean startWithConfig(Context context, String wgConfig) {
Intent prep = VpnService.prepare(context);
if (prep != null) {
Log.w(TAG, "VPN permission not granted");
return false;
}
Intent i = new Intent(context, RemoteAccessVpnService.class);
i.setAction(ACTION_UP);
i.putExtra(EXTRA_WG_CONFIG, wgConfig);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(i);
} else {
context.startService(i);
}
return true;
}
public static void stop(Context context) {
Intent i = new Intent(context, RemoteAccessVpnService.class);
i.setAction(ACTION_DOWN);
context.startService(i);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (ACTION_DOWN.equals(action)) {
tearDown();
stopForeground(true);
stopSelf();
return START_NOT_STICKY;
}
if (ACTION_UP.equals(action)) {
String cfg = intent.getStringExtra(EXTRA_WG_CONFIG);
ensureChannel();
startForeground(NOTIF_ID, buildNotification("WireGuard remote access"));
establishTun(cfg);
return START_STICKY;
}
return START_NOT_STICKY;
}
private void establishTun(String wgConfig) {
tearDown();
try {
String address = parseAddress(wgConfig);
Builder b = new Builder();
b.setSession("AndroidCast RA");
if (address != null) {
String[] parts = address.split("/");
b.addAddress(InetAddress.getByName(parts[0]),
parts.length > 1 ? Integer.parseInt(parts[1]) : 32);
} else {
b.addAddress("10.66.66.2", 32);
}
b.addRoute("10.66.66.0", 24);
b.setMtu(1280);
tun = b.establish();
Log.i(TAG, "VPN interface established");
} catch (Exception e) {
Log.e(TAG, "VPN establish failed", e);
}
}
private static String parseAddress(String wgConfig) {
if (wgConfig == null) {
return null;
}
Matcher m = Pattern.compile("(?m)^Address\\s*=\\s*(.+)$").matcher(wgConfig);
return m.find() ? m.group(1).trim() : null;
}
private void tearDown() {
if (tun != null) {
try {
tun.close();
} catch (Exception ignored) {
}
tun = null;
}
}
private Notification buildNotification(String text) {
Intent open = new Intent(this, DeveloperSettingsActivity.class);
PendingIntent pi = PendingIntent.getActivity(this, 0, open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.dev_remote_access_notification_title))
.setContentText(text)
.setContentIntent(pi)
.setOngoing(true)
.build();
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm == null) {
return;
}
NotificationChannel ch = new NotificationChannel(CHANNEL_ID,
getString(R.string.dev_remote_access_notification_channel),
NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(ch);
}
@Override
public void onDestroy() {
tearDown();
super.onDestroy();
}
}

View File

@@ -0,0 +1,40 @@
package com.foxx.androidcast.remoteaccess;
import android.util.Log;
import org.json.JSONObject;
/** Build wireguard-quick style config from BE connect credentials. */
public final class WireGuardConfigBuilder {
private static final String TAG = "WireGuardCfg";
private WireGuardConfigBuilder() {}
public static String fromConnectCredentials(JSONObject credentials) {
if (credentials == null) {
return "";
}
String priv = credentials.optString("interface_private_key", "");
String addr = credentials.optString("interface_address", "10.66.66.2/32");
String peerPub = credentials.optString("peer_public_key", "");
String endpoint = credentials.optString("peer_endpoint", "");
String allowed = credentials.optString("peer_allowed_ips", "0.0.0.0/0");
if (priv.isEmpty() || peerPub.isEmpty()) {
Log.w(TAG, "missing wg keys in credentials");
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("[Interface]\n");
sb.append("PrivateKey = ").append(priv).append('\n');
sb.append("Address = ").append(addr).append('\n');
sb.append('\n');
sb.append("[Peer]\n");
sb.append("PublicKey = ").append(peerPub).append('\n');
if (!endpoint.isEmpty()) {
sb.append("Endpoint = ").append(endpoint).append('\n');
}
sb.append("AllowedIPs = ").append(allowed).append('\n');
sb.append("PersistentKeepalive = 25\n");
return sb.toString();
}
}

View File

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

View File

@@ -51,6 +51,26 @@
android:text="@string/dev_backend_ntp_sync_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/dev_remote_access_mode"
android:textSize="14sp" />
<Spinner
android:id="@+id/spinner_dev_remote_access_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/dev_remote_access_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -261,6 +261,17 @@
<string name="dev_diag_ping_hint">Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.</string>
<string name="dev_backend_ntp_sync_enable">Enable backend NTP sync</string>
<string name="dev_backend_ntp_sync_hint">Disabled by default. Allows self-test/backend heartbeat to fetch server time correction without changing system clock.</string>
<string name="dev_remote_access_mode">Remote access (on-demand debug)</string>
<string name="dev_remote_access_disabled">Disabled</string>
<string name="dev_remote_access_wireguard">WireGuard</string>
<string name="dev_remote_access_rssh_unavailable">RSSH (reverse SSH — not available yet)</string>
<string name="dev_remote_access_hint">When enabled, device polls BE every 17 min (type: ra). Disabled sends teardown + BE notification. RSSH is reserved for a future release.</string>
<string name="dev_remote_access_rssh_toast">Reverse SSH (RSSH) is not implemented yet.</string>
<string name="dev_remote_access_applied">Remote access: %1$s</string>
<string name="dev_remote_access_vpn_denied">VPN permission required for WireGuard remote access.</string>
<string name="dev_remote_access_notification_channel">Remote access</string>
<string name="dev_remote_access_notification_title">Remote debug active</string>
<string name="dev_remote_access_notification_poll">Polling BE (%1$s)</string>
<string name="dev_tab_network_self_test">Network self-test</string>
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
<string name="dev_network_selftest_intro">Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, peer reachability, backend RTT/throughput, NAT and NTP correction.</string>

View File

@@ -0,0 +1,37 @@
package com.foxx.androidcast.remoteaccess;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class RemoteAccessModeTest {
@Test
public void fromStored_noneAlias() {
assertEquals(RemoteAccessMode.DISABLED, RemoteAccessMode.fromStored("none"));
}
@Test
public void fromStored_wireguard() {
assertEquals(RemoteAccessMode.WIREGUARD, RemoteAccessMode.fromStored("WIREGUARD"));
}
@Test
public void toApiValue_wireguard() {
assertEquals("wireguard", RemoteAccessMode.WIREGUARD.toApiValue());
}
@Test
public void toApiValue_disabled() {
assertEquals("none", RemoteAccessMode.DISABLED.toApiValue());
}
@Test
public void isActive_onlyWireGuard() {
assertFalse(RemoteAccessMode.DISABLED.isActive());
assertTrue(RemoteAccessMode.WIREGUARD.isActive());
assertFalse(RemoteAccessMode.RSSH.isActive());
}
}

View File

@@ -0,0 +1,32 @@
package com.foxx.androidcast.remoteaccess;
import org.json.JSONObject;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class WireGuardConfigBuilderTest {
@Test
public void buildsConfigFromCredentials() throws Exception {
JSONObject creds = new JSONObject();
creds.put("interface_private_key", "aPrivKey=");
creds.put("interface_address", "10.66.66.5/32");
creds.put("peer_public_key", "aPubKey=");
creds.put("peer_endpoint", "example.org:51820");
creds.put("peer_allowed_ips", "10.66.66.1/32");
String cfg = WireGuardConfigBuilder.fromConnectCredentials(creds);
assertTrue(cfg.contains("[Interface]"));
assertTrue(cfg.contains("PrivateKey = aPrivKey="));
assertTrue(cfg.contains("PublicKey = aPubKey="));
assertTrue(cfg.contains("Endpoint = example.org:51820"));
}
@Test
public void emptyWhenMissingKeys() throws Exception {
JSONObject creds = new JSONObject();
creds.put("interface_address", "10.66.66.5/32");
assertTrue(WireGuardConfigBuilder.fromConnectCredentials(creds).isEmpty());
}
}