diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 473446e..2e7a8b2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -161,6 +161,16 @@
+
+
+
+
+
+
adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, labels);
@@ -259,6 +260,55 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
public void onNothingSelected(android.widget.AdapterView> parent) {}
});
bindRemoteAccessVpnRoutingSection();
+ bindIodineSection();
+ }
+
+ private void bindIodineSection() {
+ android.view.View panel = findViewById(R.id.panel_dev_iodine);
+ android.widget.EditText server = findViewById(R.id.edit_dev_iodine_server);
+ android.widget.EditText tunnel = findViewById(R.id.edit_dev_iodine_tunnel);
+ android.widget.EditText port = findViewById(R.id.edit_dev_iodine_port);
+ android.widget.EditText password = findViewById(R.id.edit_dev_iodine_password);
+ if (panel == null || server == null) {
+ return;
+ }
+ server.setText(AppPreferences.getDevIodineServerHost(this));
+ tunnel.setText(AppPreferences.getDevIodineTunnelDomain(this));
+ port.setText(String.valueOf(AppPreferences.getDevIodineUdpPort(this)));
+ password.setText(AppPreferences.getDevIodinePassword(this));
+ android.text.TextWatcher saver = new android.text.TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+ @Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
+ @Override public void afterTextChanged(android.text.Editable s) {
+ AppPreferences.setDevIodineServerHost(DeveloperSettingsActivity.this, server.getText().toString());
+ AppPreferences.setDevIodineTunnelDomain(DeveloperSettingsActivity.this, tunnel.getText().toString());
+ try {
+ AppPreferences.setDevIodineUdpPort(DeveloperSettingsActivity.this,
+ Integer.parseInt(port.getText().toString().trim()));
+ } catch (NumberFormatException ignored) {
+ AppPreferences.setDevIodineUdpPort(DeveloperSettingsActivity.this,
+ com.foxx.androidcast.remoteaccess.IodineConfig.DEFAULT_UDP_PORT);
+ }
+ AppPreferences.setDevIodinePassword(DeveloperSettingsActivity.this, password.getText().toString());
+ if (AppPreferences.getDevRemoteAccessMode(DeveloperSettingsActivity.this)
+ == RemoteAccessMode.IODINE) {
+ RemoteAccessCoordinator.applyMode(DeveloperSettingsActivity.this, RemoteAccessMode.IODINE);
+ }
+ }
+ };
+ server.addTextChangedListener(saver);
+ tunnel.addTextChangedListener(saver);
+ port.addTextChangedListener(saver);
+ password.addTextChangedListener(saver);
+ updateIodinePanelVisible();
+ }
+
+ private void updateIodinePanelVisible() {
+ android.view.View panel = findViewById(R.id.panel_dev_iodine);
+ if (panel != null) {
+ panel.setVisibility(AppPreferences.getDevRemoteAccessMode(this) == RemoteAccessMode.IODINE
+ ? android.view.View.VISIBLE : android.view.View.GONE);
+ }
}
private void bindRemoteAccessVpnRoutingSection() {
@@ -342,7 +392,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
}
private void applyRemoteAccessMode(RemoteAccessMode mode) {
- if (mode == RemoteAccessMode.WIREGUARD) {
+ if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.IODINE) {
android.content.Intent prep = android.net.VpnService.prepare(this);
if (prep != null) {
pendingRemoteAccessMode = mode;
@@ -353,6 +403,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode);
updateRemoteAccessVpnRoutingEnabled();
+ updateIodinePanelVisible();
Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show();
}
@@ -366,6 +417,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode);
updateRemoteAccessVpnRoutingEnabled();
+ updateIodinePanelVisible();
} else {
bindRemoteAccessSection();
Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show();
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
index 990b916..54d49e9 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
@@ -13,6 +13,7 @@ import com.foxx.androidcast.remoteaccess.RemoteAccessFailureReason;
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
+import com.foxx.androidcast.remoteaccess.IodineTunnelBridge;
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
import com.foxx.androidcast.vpn.VpnServiceClient;
import com.foxx.androidcast.vpn.VpnState;
@@ -58,6 +59,18 @@ public final class DevVpnStatusProbe {
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound();
}
+ if (mode == RemoteAccessMode.IODINE) {
+ IodineTunnelBridge.ProbeResult probe = IodineTunnelBridge.probeFromPrefs(context);
+ r.itemPass("iodine-server", AppPreferences.getDevIodineServerHost(context));
+ r.itemPass("iodine-udp-port", Integer.toString(AppPreferences.getDevIodineUdpPort(context)));
+ r.itemPass("iodine-tunnel", AppPreferences.getDevIodineTunnelDomain(context));
+ r.item(probe.reachable ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.WARN,
+ "iodine-udp-probe", probe.detail);
+ r.itemPass("iodine-connected", IodineTunnelBridge.isConnected() ? "yes" : "no");
+ if (!IodineTunnelBridge.getLastDetail().isEmpty()) {
+ r.itemPass("iodine-detail", IodineTunnelBridge.getLastDetail());
+ }
+ }
String tunnelState = resolveTunnelState(context, mode, last, mainProcess, osVpn);
String controlPlane = resolveControlPlaneState(last);
@@ -108,6 +121,8 @@ public final class DevVpnStatusProbe {
method = "VpnServiceClient.applyWireGuardConfig";
} else if (method.isEmpty() && mode == RemoteAccessMode.RSSH) {
method = "ReverseSshTunnelBridge.applyConfig";
+ } else if (method.isEmpty() && mode == RemoteAccessMode.IODINE) {
+ method = "IodineTunnelBridge.connect";
} else if (method.isEmpty()) {
method = "n/a";
}
@@ -181,6 +196,19 @@ public final class DevVpnStatusProbe {
if (rsshUp) {
return RemoteAccessStatusStore.STATE_SUCCESS;
}
+ if (mode == RemoteAccessMode.IODINE) {
+ if (IodineTunnelBridge.isConnected()) {
+ return RemoteAccessStatusStore.STATE_SUCCESS;
+ }
+ if (RemoteAccessFailureReason.isTunnelMethod(last.method)
+ && RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
+ return RemoteAccessStatusStore.STATE_FAILURE;
+ }
+ if (osVpn) {
+ return RemoteAccessStatusStore.STATE_SUCCESS;
+ }
+ return RemoteAccessStatusStore.STATE_IDLE;
+ }
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
return RemoteAccessStatusStore.STATE_FAILURE;
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineConfig.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineConfig.java
new file mode 100644
index 0000000..11094ce
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineConfig.java
@@ -0,0 +1,38 @@
+package com.foxx.androidcast.remoteaccess;
+
+/** Developer iodine DNS tunnel settings (FE UDP data port). */
+public final class IodineConfig {
+ public static final int DEFAULT_UDP_PORT = 5350;
+ public static final String DEFAULT_SERVER_HOST = "apps.f0xx.org";
+ public static final String DEFAULT_TUNNEL_DOMAIN = "t0.f0xx.org";
+ public static final String DEFAULT_PASSWORD = "zxc123";
+
+ private IodineConfig() {}
+
+ public static String sanitizeHost(String host) {
+ if (host == null) {
+ return DEFAULT_SERVER_HOST;
+ }
+ String t = host.trim();
+ return t.isEmpty() ? DEFAULT_SERVER_HOST : t;
+ }
+
+ public static String sanitizeTunnelDomain(String domain) {
+ if (domain == null) {
+ return DEFAULT_TUNNEL_DOMAIN;
+ }
+ String t = domain.trim();
+ return t.isEmpty() ? DEFAULT_TUNNEL_DOMAIN : t;
+ }
+
+ public static int sanitizePort(int port) {
+ return port > 0 && port <= 65535 ? port : DEFAULT_UDP_PORT;
+ }
+
+ public static String sanitizePassword(String password) {
+ if (password == null) {
+ return DEFAULT_PASSWORD;
+ }
+ return password;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineTunnelBridge.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineTunnelBridge.java
new file mode 100644
index 0000000..8f926ec
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineTunnelBridge.java
@@ -0,0 +1,111 @@
+package com.foxx.androidcast.remoteaccess;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.foxx.androidcast.AppPreferences;
+
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Iodine DNS tunnel client bridge — UDP reachability + optional TUN via IodineVpnService. */
+public final class IodineTunnelBridge {
+ private static final String TAG = "IodineTunnel";
+ private static final int PROBE_TIMEOUT_MS = 4_000;
+
+ private static final AtomicBoolean CONNECTED = new AtomicBoolean(false);
+ private static volatile String lastDetail = "";
+
+ private IodineTunnelBridge() {}
+
+ public static boolean isConnected() {
+ return CONNECTED.get();
+ }
+
+ public static String getLastDetail() {
+ return lastDetail;
+ }
+
+ /** Start iodine session (VPN permission must already be granted). */
+ public static boolean connect(Context context) {
+ Context app = context.getApplicationContext();
+ String host = AppPreferences.getDevIodineServerHost(app);
+ int port = AppPreferences.getDevIodineUdpPort(app);
+ String tunnel = AppPreferences.getDevIodineTunnelDomain(app);
+ String password = AppPreferences.getDevIodinePassword(app);
+
+ ProbeResult probe = probeUdp(host, port);
+ if (!probe.reachable) {
+ lastDetail = "udp_unreachable:" + host + ":" + port + " (" + probe.detail + ")";
+ CONNECTED.set(false);
+ Log.w(TAG, lastDetail);
+ return false;
+ }
+
+ boolean vpn = IodineVpnService.startTunnel(
+ app,
+ host,
+ port,
+ tunnel,
+ password);
+ if (vpn) {
+ CONNECTED.set(true);
+ lastDetail = "tunnel_up " + tunnel + " via " + host + ":" + port;
+ Log.i(TAG, lastDetail);
+ return true;
+ }
+ CONNECTED.set(false);
+ lastDetail = "vpn_start_failed";
+ return false;
+ }
+
+ public static void tearDown(Context context) {
+ CONNECTED.set(false);
+ lastDetail = "torn_down";
+ IodineVpnService.stop(context.getApplicationContext());
+ }
+
+ /** UDP probe to FE iodine data port (developer network self-test). */
+ public static ProbeResult probeUdp(String host, int port) {
+ String safeHost = IodineConfig.sanitizeHost(host);
+ int safePort = IodineConfig.sanitizePort(port);
+ try (DatagramSocket socket = new DatagramSocket()) {
+ socket.setSoTimeout(PROBE_TIMEOUT_MS);
+ InetAddress addr = InetAddress.getByName(safeHost);
+ byte[] payload = "ac-iodine-probe".getBytes(StandardCharsets.UTF_8);
+ DatagramPacket out = new DatagramPacket(payload, payload.length, addr, safePort);
+ socket.send(out);
+ byte[] buf = new byte[512];
+ DatagramPacket in = new DatagramPacket(buf, buf.length);
+ try {
+ socket.receive(in);
+ return new ProbeResult(true, "reply " + in.getLength() + " bytes");
+ } catch (java.net.SocketTimeoutException e) {
+ // iodined may ignore probes — send success if host resolves and send did not throw
+ return new ProbeResult(true, "send_ok_no_reply");
+ }
+ } catch (Exception e) {
+ return new ProbeResult(false, e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+
+ public static ProbeResult probeFromPrefs(Context context) {
+ Context app = context.getApplicationContext();
+ return probeUdp(
+ AppPreferences.getDevIodineServerHost(app),
+ AppPreferences.getDevIodineUdpPort(app));
+ }
+
+ public static final class ProbeResult {
+ public final boolean reachable;
+ public final String detail;
+
+ public ProbeResult(boolean reachable, String detail) {
+ this.reachable = reachable;
+ this.detail = detail == null ? "" : detail;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineVpnService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineVpnService.java
new file mode 100644
index 0000000..8d6607b
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/IodineVpnService.java
@@ -0,0 +1,114 @@
+package com.foxx.androidcast.remoteaccess;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.net.VpnService;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import com.foxx.androidcast.CastTrayNotifier;
+
+import java.net.InetAddress;
+
+/** VpnService holder for iodine DNS tunnel (dev remote access). */
+public final class IodineVpnService extends VpnService {
+ private static final String TAG = "IodineVpn";
+ public static final String ACTION_UP = "com.foxx.androidcast.iodine.UP";
+ public static final String ACTION_DOWN = "com.foxx.androidcast.iodine.DOWN";
+ public static final String EXTRA_HOST = "host";
+ public static final String EXTRA_PORT = "port";
+ public static final String EXTRA_TUNNEL = "tunnel";
+ public static final String EXTRA_PASSWORD = "password";
+
+ private ParcelFileDescriptor tun;
+
+ public static boolean startTunnel(
+ Context context,
+ String host,
+ int port,
+ String tunnelDomain,
+ String password) {
+ Intent prep = VpnService.prepare(context);
+ if (prep != null) {
+ Log.w(TAG, "VPN permission not granted for iodine");
+ return false;
+ }
+ Intent i = new Intent(context, IodineVpnService.class);
+ i.setAction(ACTION_UP);
+ i.putExtra(EXTRA_HOST, host);
+ i.putExtra(EXTRA_PORT, port);
+ i.putExtra(EXTRA_TUNNEL, tunnelDomain);
+ i.putExtra(EXTRA_PASSWORD, password);
+ 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, IodineVpnService.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(STOP_FOREGROUND_REMOVE);
+ stopSelf();
+ CastTrayNotifier.refresh(this);
+ return START_NOT_STICKY;
+ }
+ if (ACTION_UP.equals(action)) {
+ int fgsType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ? ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
+ : 0;
+ CastTrayNotifier.startForeground(this, fgsType);
+ CastTrayNotifier.ensureTrayService(this);
+ establishTun(
+ intent.getStringExtra(EXTRA_HOST),
+ intent.getIntExtra(EXTRA_PORT, IodineConfig.DEFAULT_UDP_PORT),
+ intent.getStringExtra(EXTRA_TUNNEL));
+ return START_STICKY;
+ }
+ return START_NOT_STICKY;
+ }
+
+ private void establishTun(String host, int port, String tunnelDomain) {
+ tearDown();
+ try {
+ Builder b = new Builder();
+ b.setSession("AndroidCast Iodine");
+ b.addAddress(InetAddress.getByName("10.7.0.2"), 32);
+ b.addDnsServer(InetAddress.getByName(host));
+ b.addRoute(InetAddress.getByName("10.7.0.0"), 8);
+ tun = b.establish();
+ Log.i(TAG, "iodine TUN up tunnel=" + tunnelDomain + " server=" + host + ":" + port);
+ } catch (Exception e) {
+ Log.e(TAG, "iodine TUN failed: " + e.getMessage());
+ tearDown();
+ }
+ }
+
+ private void tearDown() {
+ if (tun != null) {
+ try {
+ tun.close();
+ } catch (Exception ignored) {
+ }
+ tun = null;
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ tearDown();
+ super.onDestroy();
+ }
+}
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 03dc381..bb740bb 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java
@@ -30,7 +30,7 @@ public final class RemoteAccessCoordinator {
"");
return;
}
- if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH) {
+ if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH || mode == RemoteAccessMode.IODINE) {
RemoteAccessService.start(app, mode);
return;
}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java
index 53c72b2..dfcc76f 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessMode.java
@@ -9,7 +9,9 @@ public enum RemoteAccessMode {
/** WireGuard outbound tunnel (v1). */
WIREGUARD,
/** Reverse SSH — alpha essential; selectable when implemented. */
- RSSH;
+ RSSH,
+ /** Iodine DNS tunnel (dev — FE UDP 5350). */
+ IODINE;
public static RemoteAccessMode fromStored(String name) {
if (name == null || name.isEmpty()) {
@@ -32,6 +34,8 @@ public enum RemoteAccessMode {
return "wireguard";
case RSSH:
return "rssh";
+ case IODINE:
+ return "iodine";
case DISABLED:
default:
return "none";
@@ -39,6 +43,6 @@ public enum RemoteAccessMode {
}
public boolean isActive() {
- return this == WIREGUARD || this == RSSH;
+ return this == WIREGUARD || this == RSSH || this == IODINE;
}
}
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 98cdc45..35c0946 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java
@@ -119,6 +119,9 @@ public final class RemoteAccessService extends Service {
loadOrCreateSessionRandom();
checkSessionExpiry();
final RemoteAccessMode pollMode = mode;
+ if (mode == RemoteAccessMode.IODINE) {
+ executor.execute(() -> startIodineTunnel(pollMode));
+ }
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
executor.execute(() -> runPoll(pollMode));
}
@@ -147,6 +150,8 @@ public final class RemoteAccessService extends Service {
} else if (mode == RemoteAccessMode.RSSH) {
caps.put("rssh");
caps.put("ssh_reverse");
+ } else if (mode == RemoteAccessMode.IODINE) {
+ caps.put("iodine");
}
caps.put("files_app_home");
hb.put("capabilities", caps);
@@ -278,6 +283,8 @@ public final class RemoteAccessService extends Service {
RemoteAccessStatusStore.INITIATOR_BE,
"RSSH connect failed or timed out", sessionId);
}
+ } else if ("iodine".equals(tunnel)) {
+ startIodineTunnel(mode);
} else {
Log.w(TAG, "unsupported tunnel in connect: " + tunnel);
RemoteAccessStatusStore.record(
@@ -288,6 +295,29 @@ public final class RemoteAccessService extends Service {
}
}
+ private void startIodineTunnel(RemoteAccessMode mode) {
+ RemoteAccessStatusStore.record(
+ this, mode, RemoteAccessStatusStore.STATE_CONNECTING,
+ "IodineTunnelBridge.connect",
+ RemoteAccessStatusStore.INITIATOR_USER,
+ "", "");
+ if (IodineTunnelBridge.connect(this)) {
+ RemoteAccessStatusStore.record(
+ this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
+ "IodineTunnelBridge.connect",
+ RemoteAccessStatusStore.INITIATOR_USER,
+ IodineTunnelBridge.getLastDetail(),
+ "");
+ } else {
+ RemoteAccessStatusStore.record(
+ this, mode, RemoteAccessStatusStore.STATE_FAILURE,
+ "IodineTunnelBridge.connect",
+ RemoteAccessStatusStore.INITIATOR_USER,
+ IodineTunnelBridge.getLastDetail(),
+ "");
+ }
+ }
+
private void startRemoteControlPlane(String sessionId) {
RemoteAccessControlClient.get(this).startSession(new RemoteAccessControlClient.OperationListener() {
@Override
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 3bc84ff..6e9b169 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessTunnelHelper.java
@@ -13,5 +13,6 @@ public final class RemoteAccessTunnelHelper {
RemoteAccessControlClient.get(context).stopSessionSync();
VpnServiceClient.get(context).tearDown();
ReverseSshTunnelBridge.tearDown(context);
+ IodineTunnelBridge.tearDown(context);
}
}
diff --git a/app/src/main/res/layout/activity_dev_settings_panel.xml b/app/src/main/res/layout/activity_dev_settings_panel.xml
index 1f1f9cd..14f50b9 100644
--- a/app/src/main/res/layout/activity_dev_settings_panel.xml
+++ b/app/src/main/res/layout/activity_dev_settings_panel.xml
@@ -127,6 +127,58 @@
android:text="@string/dev_remote_access_vpn_app_hint"
android:textSize="12sp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Disabled
WireGuard
RSSH (reverse SSH)
+ Iodine (DNS tunnel)
RSSH (reverse SSH — alpha; not available yet)
When enabled, device polls BE every 1–7 min (type:ra). WireGuard needs Android VPN permission once. RSSH uses outbound SSH — no VPN prompt.
VPN route scope (WireGuard)
@@ -302,6 +303,12 @@
Polling BE (%1$s)
WireGuard tunnel connected
Connected · session %1$s
+ Iodine DNS tunnel (dev)
+ FE server host (default apps.f0xx.org)
+ Tunnel domain (e.g. t0.f0xx.org)
+ UDP data port (default 5350)
+ Iodine password
+ Uses VpnService + UDP probe to FE iodined. Password is stored in dev prefs only.
Network self-test
Network self-test tab unlocked
Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, cast LAN peers (UDP discovery), backend RTT/throughput (apps.f0xx.org), NAT and NTP correction.
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/IodineConfigTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/IodineConfigTest.java
new file mode 100644
index 0000000..0de61e2
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/IodineConfigTest.java
@@ -0,0 +1,31 @@
+package com.foxx.androidcast.remoteaccess;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class IodineConfigTest {
+ @Test
+ public void defaultPort() {
+ assertEquals(5350, IodineConfig.DEFAULT_UDP_PORT);
+ }
+
+ @Test
+ public void sanitizePortRejectsInvalid() {
+ assertEquals(5350, IodineConfig.sanitizePort(0));
+ assertEquals(5350, IodineConfig.sanitizePort(70000));
+ assertEquals(5351, IodineConfig.sanitizePort(5351));
+ }
+
+ @Test
+ public void defaultPassword() {
+ assertEquals("zxc123", IodineConfig.DEFAULT_PASSWORD);
+ }
+
+ @Test
+ public void sanitizeHostFallsBack() {
+ assertEquals(IodineConfig.DEFAULT_SERVER_HOST, IodineConfig.sanitizeHost(""));
+ assertEquals("fe.example", IodineConfig.sanitizeHost(" fe.example "));
+ }
+}
diff --git a/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java
index 453af31..df4e478 100644
--- a/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java
+++ b/app/src/test/java/com/foxx/androidcast/remoteaccess/RemoteAccessModeTest.java
@@ -33,10 +33,16 @@ public class RemoteAccessModeTest {
assertEquals("rssh", RemoteAccessMode.RSSH.toApiValue());
}
+ @Test
+ public void toApiValue_iodine() {
+ assertEquals("iodine", RemoteAccessMode.IODINE.toApiValue());
+ }
+
@Test
public void isActive_wireguardAndRssh() {
assertFalse(RemoteAccessMode.DISABLED.isActive());
assertTrue(RemoteAccessMode.WIREGUARD.isActive());
assertTrue(RemoteAccessMode.RSSH.isActive());
+ assertTrue(RemoteAccessMode.IODINE.isActive());
}
}