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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 1–7 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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user