diff --git a/.cursorignore b/.cursorignore
new file mode 100644
index 0000000..a562274
--- /dev/null
+++ b/.cursorignore
@@ -0,0 +1,10 @@
+tmp/
+third-party/
+**/.gradle/
+**/build/
+**/.cxx/
+**/node_modules/
+out/
+ota-publish/
+*.apk
+*.jar
diff --git a/.gitignore b/.gitignore
index 494dc9f..230d5da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,7 @@ config.php.alpine
# --- CI / OTA / local artifacts ---
out/
ota-publish/
+.adb_connect_cache
# --- Scratch (not for commit) ---
tmp/
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6fd2132..b85469f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,6 +19,10 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java
index f6c03eb..536e64e 100644
--- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java
+++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java
@@ -16,6 +16,7 @@ import android.content.Context;
import com.foxx.androidcast.commercial.CommercialFeatures;
import com.foxx.androidcast.crash.CrashReporter;
+import com.foxx.androidcast.dev.DevAdbWifiKeeper;
import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.network.CastTransportFactory;
@@ -44,5 +45,6 @@ public class AndroidCastApplication extends Application {
WiredDisplayMonitor.getInstance(this).ensureRegistered();
CommercialFeatures.refreshFromPreferences(this);
RemoteAccessCoordinator.onBootIfNeeded(this);
+ DevAdbWifiKeeper.ensureStarted(this);
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java
index 343d961..9717331 100644
--- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java
+++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java
@@ -66,6 +66,7 @@ public final class AppPreferences {
private static final String KEY_DEV_REMOTE_ACCESS_RANDOM = "dev_remote_access_random";
private static final String KEY_DEV_REMOTE_ACCESS_SESSION_ID = "dev_remote_access_session_id";
private static final String KEY_DEV_REMOTE_ACCESS_EXPIRES_AT = "dev_remote_access_expires_at";
+ private static final String KEY_DEV_ADB_WIFI_KEEPER = "dev_adb_wifi_keeper";
/** Minimum interval between automatic OTA checks on app launch. */
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
@@ -442,6 +443,15 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_BACKEND_NTP_SYNC, enabled).apply();
}
+ /** Debug builds: auto re-enable wireless adb after Wi-Fi reconnects. */
+ public static boolean isDevAdbWifiKeeperEnabled(Context context) {
+ return prefs(context).getBoolean(KEY_DEV_ADB_WIFI_KEEPER, true);
+ }
+
+ public static void setDevAdbWifiKeeperEnabled(Context context, boolean enabled) {
+ prefs(context).edit().putBoolean(KEY_DEV_ADB_WIFI_KEEPER, 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()));
diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
index a30afdc..b6327cc 100644
--- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
@@ -19,6 +19,7 @@ import android.widget.CheckBox;
import android.widget.SeekBar;
import android.widget.Spinner;
import android.widget.TextView;
+import android.text.TextUtils;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
@@ -35,6 +36,8 @@ import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
import com.foxx.androidcast.sender.SenderResolutionUiMode;
+import com.foxx.androidcast.dev.DevAdbConnectInfo;
+import com.foxx.androidcast.dev.DevAdbWifiKeeper;
import com.foxx.androidcast.dev.DevDiagnosticsController;
import com.foxx.androidcast.dev.DevNetworkSelfTestController;
import com.foxx.androidcast.network.diag.CastDiagPing;
@@ -118,6 +121,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
bindDiagPingSection();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
+ bindAdbWifiSection();
bindCommercialSection();
}
@@ -157,9 +161,64 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
bindRemoteAccessSection();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
+ bindAdbWifiSection();
bindCommercialSection();
}
+ private void bindAdbWifiSection() {
+ if (!BuildConfig.DEBUG) {
+ android.view.View section = findViewById(R.id.check_dev_adb_wifi_keeper);
+ if (section != null) {
+ android.view.View parent = (android.view.View) section.getParent();
+ // Hide adb wifi block in release UI if layout is shared.
+ section.setVisibility(android.view.View.GONE);
+ }
+ return;
+ }
+ CheckBox keeper = findViewById(R.id.check_dev_adb_wifi_keeper);
+ TextView status = findViewById(R.id.text_dev_adb_wifi_status);
+ Button refresh = findViewById(R.id.btn_dev_adb_wifi_refresh);
+ Button copy = findViewById(R.id.btn_dev_adb_wifi_copy);
+ if (keeper == null) {
+ return;
+ }
+ bindDeveloperCheckbox(keeper, AppPreferences.isDevAdbWifiKeeperEnabled(this), (ctx, on) -> {
+ AppPreferences.setDevAdbWifiKeeperEnabled(ctx, on);
+ DevAdbWifiKeeper.onPreferenceChanged(ctx, on);
+ refreshAdbWifiStatus(status);
+ });
+ if (refresh != null) {
+ refresh.setOnClickListener(v -> {
+ DevAdbWifiKeeper.refreshNow(this, "dev-settings");
+ status.postDelayed(() -> refreshAdbWifiStatus(status), 400L);
+ });
+ }
+ if (copy != null) {
+ copy.setOnClickListener(v -> {
+ DevAdbWifiKeeper.copyPrimaryConnectLine(this);
+ Toast.makeText(this, R.string.dev_adb_wifi_copied, Toast.LENGTH_SHORT).show();
+ });
+ }
+ refreshAdbWifiStatus(status);
+ }
+
+ private void refreshAdbWifiStatus(TextView status) {
+ if (status == null || !BuildConfig.DEBUG) {
+ return;
+ }
+ DevAdbConnectInfo info = DevAdbWifiKeeper.getLastInfo(this);
+ String line = info.primaryConnectLine();
+ String headline = line.isEmpty()
+ ? getString(R.string.dev_adb_wifi_status_waiting)
+ : getString(R.string.dev_adb_wifi_status_line, line);
+ String detail = getString(
+ R.string.dev_adb_wifi_status_detail,
+ info.secureSettingsGranted ? "yes" : "no",
+ info.adbWifiEnabled ? "on" : "off",
+ info.connect.isEmpty() ? "no endpoints yet" : TextUtils.join(", ", info.connect));
+ status.setText(headline + "\n" + detail);
+ }
+
private void bindRemoteAccessSection() {
remoteAccessSpinner = findViewById(R.id.spinner_dev_remote_access_mode);
if (remoteAccessSpinner == null) {
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectHttpServer.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectHttpServer.java
new file mode 100644
index 0000000..83cce45
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectHttpServer.java
@@ -0,0 +1,107 @@
+package com.foxx.androidcast.dev;
+
+import android.util.Log;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Tiny LAN HTTP server so rebuild.sh can fetch adb connect lines without adb. */
+final class DevAdbConnectHttpServer implements Runnable {
+ private final AtomicReference payload = new AtomicReference<>("{}");
+ private volatile ServerSocket serverSocket;
+ private volatile Thread thread;
+
+ void updatePayload(String json) {
+ payload.set(json != null ? json : "{}");
+ }
+
+ synchronized void start() {
+ if (thread != null && thread.isAlive()) {
+ return;
+ }
+ thread = new Thread(this, "DevAdbConnectHttp");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ synchronized void stop() {
+ Thread local = thread;
+ thread = null;
+ ServerSocket socket = serverSocket;
+ serverSocket = null;
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (IOException ignored) {
+ }
+ }
+ if (local != null) {
+ local.interrupt();
+ }
+ }
+
+ @Override
+ public void run() {
+ try (ServerSocket socket = new ServerSocket()) {
+ socket.setReuseAddress(true);
+ socket.bind(new InetSocketAddress(DevAdbConnectInfo.HTTP_PORT));
+ serverSocket = socket;
+ Log.i(DevAdbConnectInfo.LOG_TAG,
+ "HTTP adb info on :" + DevAdbConnectInfo.HTTP_PORT + "/adb.json");
+ while (!Thread.currentThread().isInterrupted()) {
+ try (Socket client = socket.accept()) {
+ handleClient(client);
+ } catch (IOException e) {
+ if (!Thread.currentThread().isInterrupted()) {
+ Log.d(DevAdbConnectInfo.LOG_TAG, "HTTP client error", e);
+ }
+ }
+ }
+ } catch (IOException e) {
+ Log.w(DevAdbConnectInfo.LOG_TAG, "HTTP server stopped", e);
+ } finally {
+ serverSocket = null;
+ }
+ }
+
+ private void handleClient(Socket client) throws IOException {
+ byte[] buffer = new byte[512];
+ int read = client.getInputStream().read(buffer);
+ if (read <= 0) {
+ return;
+ }
+ String request = new String(buffer, 0, read, StandardCharsets.US_ASCII);
+ String path = "/";
+ int lineEnd = request.indexOf('\n');
+ String firstLine = lineEnd >= 0 ? request.substring(0, lineEnd) : request;
+ String[] parts = firstLine.split(" ");
+ if (parts.length >= 2) {
+ path = parts[1];
+ }
+ String body = payload.get();
+ if (!"/adb.json".equals(path) && !"/".equals(path)) {
+ writeResponse(client, 404, "text/plain", "not found");
+ return;
+ }
+ writeResponse(client, 200, "application/json; charset=utf-8", body);
+ }
+
+ private static void writeResponse(Socket client, int code, String contentType, String body)
+ throws IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ String status = code == 200 ? "200 OK" : "404 Not Found";
+ String headers = "HTTP/1.1 " + status + "\r\n"
+ + "Content-Type: " + contentType + "\r\n"
+ + "Content-Length: " + bytes.length + "\r\n"
+ + "Connection: close\r\n\r\n";
+ OutputStream out = client.getOutputStream();
+ out.write(headers.getBytes(StandardCharsets.US_ASCII));
+ out.write(bytes);
+ out.flush();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java
new file mode 100644
index 0000000..4c1b327
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbConnectInfo.java
@@ -0,0 +1,106 @@
+package com.foxx.androidcast.dev;
+
+import android.text.TextUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Snapshot of wireless adb endpoints for rebuild.sh / curl when adb is down. */
+public final class DevAdbConnectInfo {
+ public static final int HTTP_PORT = 5039;
+ public static final int LEGACY_TCP_PORT = 5555;
+ public static final String LOG_TAG = "DevAdbConnect";
+ public static final String ACTION_REFRESH = "com.foxx.androidcast.dev.REFRESH_ADB_WIFI";
+ public static final String RELATIVE_JSON_PATH = "dev/adb_connect.json";
+
+ public final List ips;
+ public final List connect;
+ public final boolean adbEnabled;
+ public final boolean adbWifiEnabled;
+ public final boolean secureSettingsGranted;
+ public final int legacyPort;
+ public final long updatedMs;
+
+ public DevAdbConnectInfo(
+ List ips,
+ List connect,
+ boolean adbEnabled,
+ boolean adbWifiEnabled,
+ boolean secureSettingsGranted,
+ int legacyPort,
+ long updatedMs) {
+ this.ips = ips != null ? ips : Collections.emptyList();
+ this.connect = connect != null ? connect : Collections.emptyList();
+ this.adbEnabled = adbEnabled;
+ this.adbWifiEnabled = adbWifiEnabled;
+ this.secureSettingsGranted = secureSettingsGranted;
+ this.legacyPort = legacyPort;
+ this.updatedMs = updatedMs;
+ }
+
+ public String primaryConnectLine() {
+ return connect.isEmpty() ? "" : connect.get(0);
+ }
+
+ public String toJson() {
+ try {
+ JSONObject root = new JSONObject();
+ root.put("package", "com.foxx.androidcast");
+ root.put("http_port", HTTP_PORT);
+ root.put("legacy_port", legacyPort);
+ root.put("adb_enabled", adbEnabled);
+ root.put("adb_wifi_enabled", adbWifiEnabled);
+ root.put("secure_settings_granted", secureSettingsGranted);
+ root.put("updated_ms", updatedMs);
+ root.put("ips", toJsonArray(ips));
+ root.put("connect", toJsonArray(connect));
+ JSONArray commands = new JSONArray();
+ for (String endpoint : connect) {
+ if (!TextUtils.isEmpty(endpoint)) {
+ commands.put("adb connect " + endpoint);
+ }
+ }
+ root.put("commands", commands);
+ root.put("hint", "When connect lines fail, run: adb mdns services");
+ return root.toString(2);
+ } catch (JSONException e) {
+ return "{}";
+ }
+ }
+
+ public String toLogLine() {
+ return "ADB_JSON " + toJson().replace('\n', ' ');
+ }
+
+ private static JSONArray toJsonArray(List values) {
+ JSONArray array = new JSONArray();
+ for (String value : values) {
+ if (!TextUtils.isEmpty(value)) {
+ array.put(value);
+ }
+ }
+ return array;
+ }
+
+ public static List mergeEndpoints(List ips, List ports) {
+ Set out = new LinkedHashSet<>();
+ for (String ip : ips) {
+ if (TextUtils.isEmpty(ip)) {
+ continue;
+ }
+ for (Integer port : ports) {
+ if (port != null && port > 0 && port < 65536) {
+ out.add(ip + ":" + port);
+ }
+ }
+ }
+ return new ArrayList<>(out);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java
new file mode 100644
index 0000000..4eed568
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiHelper.java
@@ -0,0 +1,246 @@
+package com.foxx.androidcast.dev;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.os.Build;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.lang.reflect.Method;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Re-enable adb / wireless debugging and collect adb connect endpoints (debug builds). */
+public final class DevAdbWifiHelper {
+ private static final String GLOBAL_ADB_WIFI_ENABLED = "adb_wifi_enabled";
+ private static final Pattern ENDPOINT_PATTERN =
+ Pattern.compile("(\\d{1,3}(?:\\.\\d{1,3}){3}):(\\d{2,5})");
+
+ private DevAdbWifiHelper() {}
+
+ public static DevAdbConnectInfo refresh(Context context) {
+ Context app = context.getApplicationContext();
+ boolean secure = hasWriteSecureSettings(app);
+ boolean adbEnabled = isGlobalFlagOn(app, Settings.Global.ADB_ENABLED);
+ boolean adbWifiEnabled = Build.VERSION.SDK_INT < 30
+ || isGlobalFlagOn(app, GLOBAL_ADB_WIFI_ENABLED);
+
+ if (secure) {
+ enableDeveloperAdb(app);
+ adbEnabled = isGlobalFlagOn(app, Settings.Global.ADB_ENABLED);
+ adbWifiEnabled = Build.VERSION.SDK_INT < 30
+ || isGlobalFlagOn(app, GLOBAL_ADB_WIFI_ENABLED);
+ } else {
+ Log.w(DevAdbConnectInfo.LOG_TAG,
+ "WRITE_SECURE_SETTINGS not granted — cannot auto re-enable adb wifi");
+ }
+
+ List ips = collectWifiIpv4Addresses(app);
+ List ports = discoverPorts(app);
+ List connect = DevAdbConnectInfo.mergeEndpoints(ips, ports);
+ connect.addAll(parseDumpsysEndpoints(runDumpsysAdb(app)));
+
+ List dedupedConnect = dedupe(connect);
+ long now = System.currentTimeMillis();
+ return new DevAdbConnectInfo(
+ ips,
+ dedupedConnect,
+ adbEnabled,
+ adbWifiEnabled,
+ secure,
+ DevAdbConnectInfo.LEGACY_TCP_PORT,
+ now);
+ }
+
+ private static void enableDeveloperAdb(Context context) {
+ try {
+ Settings.Global.putInt(
+ context.getContentResolver(),
+ Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
+ 1);
+ if (Build.VERSION.SDK_INT >= 30) {
+ Settings.Global.putInt(context.getContentResolver(), GLOBAL_ADB_WIFI_ENABLED, 1);
+ }
+ // Toggle USB debugging on to kick adbd after Wi-Fi drops.
+ Settings.Global.putInt(context.getContentResolver(), Settings.Global.ADB_ENABLED, 0);
+ Settings.Global.putInt(context.getContentResolver(), Settings.Global.ADB_ENABLED, 1);
+ if (Build.VERSION.SDK_INT >= 30) {
+ Settings.Global.putInt(context.getContentResolver(), GLOBAL_ADB_WIFI_ENABLED, 1);
+ }
+ } catch (SecurityException e) {
+ Log.w(DevAdbConnectInfo.LOG_TAG, "enableDeveloperAdb denied", e);
+ }
+ }
+
+ public static boolean hasWriteSecureSettings(Context context) {
+ return context.checkSelfPermission("android.permission.WRITE_SECURE_SETTINGS")
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private static boolean isGlobalFlagOn(Context context, String key) {
+ try {
+ return Settings.Global.getInt(context.getContentResolver(), key, 0) == 1;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ static List collectWifiIpv4Addresses(Context context) {
+ Set ips = new LinkedHashSet<>();
+ ConnectivityManager cm =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (cm == null) {
+ return new ArrayList<>();
+ }
+ for (Network network : cm.getAllNetworks()) {
+ NetworkCapabilities caps = cm.getNetworkCapabilities(network);
+ if (caps == null || !caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
+ continue;
+ }
+ LinkProperties props = cm.getLinkProperties(network);
+ if (props == null) {
+ continue;
+ }
+ for (LinkAddress link : props.getLinkAddresses()) {
+ InetAddress address = link.getAddress();
+ if (address instanceof Inet4Address && !address.isLoopbackAddress()) {
+ ips.add(address.getHostAddress());
+ }
+ }
+ }
+ return new ArrayList<>(ips);
+ }
+
+ private static List discoverPorts(Context context) {
+ Set ports = new LinkedHashSet<>();
+ ports.add(DevAdbConnectInfo.LEGACY_TCP_PORT);
+
+ int tcpProp = parsePositiveInt(readSystemProperty("service.adb.tcp.port"));
+ if (tcpProp > 0) {
+ ports.add(tcpProp);
+ }
+
+ for (String key : new String[] {"adb_wifi_port", "adb_wifi_tls_port", "adb_port"}) {
+ int fromSettings = readGlobalInt(context, key, -1);
+ if (fromSettings > 0) {
+ ports.add(fromSettings);
+ }
+ String asString = readGlobalString(context, key);
+ int parsed = parsePositiveInt(asString);
+ if (parsed > 0) {
+ ports.add(parsed);
+ }
+ }
+ return new ArrayList<>(ports);
+ }
+
+ private static String readSystemProperty(String key) {
+ try {
+ Class> clazz = Class.forName("android.os.SystemProperties");
+ Method get = clazz.getMethod("get", String.class, String.class);
+ Object value = get.invoke(null, key, "");
+ return value != null ? value.toString() : "";
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ private static int readGlobalInt(Context context, String key, int def) {
+ try {
+ return Settings.Global.getInt(context.getContentResolver(), key, def);
+ } catch (Exception e) {
+ return def;
+ }
+ }
+
+ private static String readGlobalString(Context context, String key) {
+ try {
+ String value = Settings.Global.getString(context.getContentResolver(), key);
+ return value != null ? value : "";
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ private static int parsePositiveInt(String raw) {
+ if (TextUtils.isEmpty(raw)) {
+ return -1;
+ }
+ try {
+ int value = Integer.parseInt(raw.trim());
+ return value > 0 ? value : -1;
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ private static String runDumpsysAdb(Context context) {
+ if (context.checkSelfPermission("android.permission.DUMP")
+ != PackageManager.PERMISSION_GRANTED) {
+ return "";
+ }
+ Process process = null;
+ try {
+ process = new ProcessBuilder("dumpsys", "adb")
+ .redirectErrorStream(true)
+ .start();
+ if (!process.waitFor(3, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ return "";
+ }
+ StringBuilder out = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ out.append(line).append('\n');
+ }
+ }
+ return out.toString();
+ } catch (Exception e) {
+ Log.d(DevAdbConnectInfo.LOG_TAG, "dumpsys adb unavailable", e);
+ return "";
+ } finally {
+ if (process != null) {
+ process.destroy();
+ }
+ }
+ }
+
+ static List parseDumpsysEndpoints(String dumpsys) {
+ Set endpoints = new LinkedHashSet<>();
+ if (TextUtils.isEmpty(dumpsys)) {
+ return new ArrayList<>();
+ }
+ Matcher matcher = ENDPOINT_PATTERN.matcher(dumpsys);
+ while (matcher.find()) {
+ endpoints.add(matcher.group(1) + ":" + matcher.group(2));
+ }
+ return new ArrayList<>(endpoints);
+ }
+
+ private static List dedupe(List values) {
+ Set out = new LinkedHashSet<>();
+ for (String value : values) {
+ if (!TextUtils.isEmpty(value)) {
+ out.add(value.trim());
+ }
+ }
+ return new ArrayList<>(out);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java
new file mode 100644
index 0000000..c3278f7
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java
@@ -0,0 +1,274 @@
+package com.foxx.androidcast.dev;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.BuildConfig;
+import com.foxx.androidcast.DeveloperSettingsActivity;
+import com.foxx.androidcast.R;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** Debug-only: re-enable adb wifi on network changes and publish connect info. */
+public final class DevAdbWifiKeeper {
+ private static final String CHANNEL_ID = "dev_adb_wifi";
+ private static final int NOTIFICATION_ID = 50391;
+
+ private static final ExecutorService WORKER = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "DevAdbWifiKeeper");
+ t.setDaemon(true);
+ return t;
+ });
+ private static final Handler MAIN = new Handler(Looper.getMainLooper());
+
+ private static volatile DevAdbWifiKeeper instance;
+
+ private final Context appContext;
+ private final DevAdbConnectHttpServer httpServer = new DevAdbConnectHttpServer();
+ private final ConnectivityManager.NetworkCallback networkCallback =
+ new ConnectivityManager.NetworkCallback() {
+ @Override
+ public void onAvailable(Network network) {
+ scheduleRefresh("network-available");
+ }
+
+ @Override
+ public void onCapabilitiesChanged(Network network, NetworkCapabilities caps) {
+ if (caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
+ scheduleRefresh("network-validated");
+ }
+ }
+
+ @Override
+ public void onLost(Network network) {
+ scheduleRefresh("network-lost");
+ }
+ };
+
+ private DevAdbConnectInfo lastInfo = new DevAdbConnectInfo(
+ null, null, false, false, false, DevAdbConnectInfo.LEGACY_TCP_PORT, 0L);
+ private boolean registered;
+
+ private DevAdbWifiKeeper(Context context) {
+ appContext = context.getApplicationContext();
+ }
+
+ public static void ensureStarted(Context context) {
+ if (!BuildConfig.DEBUG) {
+ return;
+ }
+ if (!AppPreferences.isDevAdbWifiKeeperEnabled(context)) {
+ stop(context);
+ return;
+ }
+ if (instance == null) {
+ synchronized (DevAdbWifiKeeper.class) {
+ if (instance == null) {
+ instance = new DevAdbWifiKeeper(context);
+ }
+ }
+ }
+ instance.start();
+ }
+
+ public static void stop(Context context) {
+ DevAdbWifiKeeper keeper = instance;
+ if (keeper != null) {
+ keeper.shutdown();
+ }
+ instance = null;
+ }
+
+ public static void onPreferenceChanged(Context context, boolean enabled) {
+ if (!BuildConfig.DEBUG) {
+ return;
+ }
+ if (enabled) {
+ ensureStarted(context);
+ refreshNow(context, "preference-enabled");
+ } else {
+ stop(context);
+ }
+ }
+
+ public static void refreshNow(Context context, String reason) {
+ if (!BuildConfig.DEBUG) {
+ return;
+ }
+ ensureStarted(context);
+ DevAdbWifiKeeper keeper = instance;
+ if (keeper != null) {
+ keeper.scheduleRefresh(reason);
+ }
+ }
+
+ public static DevAdbConnectInfo getLastInfo(Context context) {
+ DevAdbWifiKeeper keeper = instance;
+ if (keeper != null) {
+ return keeper.lastInfo;
+ }
+ return DevAdbWifiHelper.refresh(context);
+ }
+
+ public static void copyPrimaryConnectLine(Context context) {
+ DevAdbConnectInfo info = getLastInfo(context);
+ String line = info.primaryConnectLine();
+ if (line.isEmpty()) {
+ return;
+ }
+ ClipboardManager clipboard =
+ (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null) {
+ clipboard.setPrimaryClip(ClipData.newPlainText("adb connect", "adb connect " + line));
+ }
+ }
+
+ private void start() {
+ ensureNotificationChannel();
+ httpServer.start();
+ registerNetworkCallback();
+ scheduleRefresh("keeper-start");
+ }
+
+ private void shutdown() {
+ unregisterNetworkCallback();
+ httpServer.stop();
+ NotificationManager nm =
+ (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm != null) {
+ nm.cancel(NOTIFICATION_ID);
+ }
+ }
+
+ private void registerNetworkCallback() {
+ if (registered) {
+ return;
+ }
+ ConnectivityManager cm =
+ (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (cm == null) {
+ return;
+ }
+ NetworkRequest request = new NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .build();
+ cm.registerNetworkCallback(request, networkCallback);
+ registered = true;
+ }
+
+ private void unregisterNetworkCallback() {
+ if (!registered) {
+ return;
+ }
+ ConnectivityManager cm =
+ (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (cm != null) {
+ try {
+ cm.unregisterNetworkCallback(networkCallback);
+ } catch (Exception ignored) {
+ }
+ }
+ registered = false;
+ }
+
+ private void scheduleRefresh(String reason) {
+ WORKER.execute(() -> performRefresh(reason));
+ }
+
+ private void performRefresh(String reason) {
+ DevAdbConnectInfo info = DevAdbWifiHelper.refresh(appContext);
+ lastInfo = info;
+ String json = info.toJson();
+ httpServer.updatePayload(json);
+ writeJsonFile(json);
+ Log.i(DevAdbConnectInfo.LOG_TAG, info.toLogLine());
+ Log.d(DevAdbConnectInfo.LOG_TAG, "refresh reason=" + reason
+ + " secure=" + info.secureSettingsGranted
+ + " connect=" + info.connect);
+ MAIN.post(() -> updateNotification(info));
+ }
+
+ private void writeJsonFile(String json) {
+ File dir = new File(appContext.getFilesDir(), "dev");
+ if (!dir.exists() && !dir.mkdirs()) {
+ return;
+ }
+ File out = new File(dir, "adb_connect.json");
+ try (FileOutputStream fos = new FileOutputStream(out, false)) {
+ fos.write(json.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ Log.w(DevAdbConnectInfo.LOG_TAG, "write adb_connect.json failed", e);
+ }
+ }
+
+ private void ensureNotificationChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+ NotificationManager nm =
+ (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) {
+ return;
+ }
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ appContext.getString(R.string.dev_adb_wifi_notification_channel),
+ NotificationManager.IMPORTANCE_LOW);
+ channel.setDescription(appContext.getString(R.string.dev_adb_wifi_notification_channel_desc));
+ nm.createNotificationChannel(channel);
+ }
+
+ private void updateNotification(DevAdbConnectInfo info) {
+ if (!AppPreferences.isDevAdbWifiKeeperEnabled(appContext)) {
+ return;
+ }
+ NotificationManager nm =
+ (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) {
+ return;
+ }
+ String line = info.primaryConnectLine();
+ String text = line.isEmpty()
+ ? appContext.getString(R.string.dev_adb_wifi_notification_waiting)
+ : appContext.getString(R.string.dev_adb_wifi_notification_line, line);
+ Intent openDev = new Intent(appContext, DeveloperSettingsActivity.class);
+ openDev.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ PendingIntent content = PendingIntent.getActivity(
+ appContext,
+ 0,
+ openDev,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ Notification notification = new NotificationCompat.Builder(appContext, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(appContext.getString(R.string.dev_adb_wifi_notification_title))
+ .setContentText(text)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(text))
+ .setContentIntent(content)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build();
+ nm.notify(NOTIFICATION_ID, notification);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiReceiver.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiReceiver.java
new file mode 100644
index 0000000..b32fd07
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiReceiver.java
@@ -0,0 +1,18 @@
+package com.foxx.androidcast.dev;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.foxx.androidcast.BuildConfig;
+
+/** adb shell am broadcast -a com.foxx.androidcast.dev.REFRESH_ADB_WIFI */
+public final class DevAdbWifiReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!BuildConfig.DEBUG) {
+ return;
+ }
+ DevAdbWifiKeeper.refreshNow(context, "broadcast");
+ }
+}
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 a36c13d..ba88c10 100644
--- a/app/src/main/res/layout/activity_dev_settings_panel.xml
+++ b/app/src/main/res/layout/activity_dev_settings_panel.xml
@@ -330,6 +330,51 @@
android:text="@string/dev_wired_display_hint"
android:textSize="12sp" />
+
+
+
+
+
+
+
+
+
+
+
+
Show USB-tether transport in cast settings (stub, roadmap E)
External display detected — use system mirror or Wi‑Fi cast to receiver
USB tether (experimental)
+ ADB over Wi‑Fi (debug)
+ Keep wireless adb alive after Wi‑Fi drops
+ Refresh adb connect info now
+ Copy adb connect line
+ Waiting for Wi‑Fi and adb endpoints…
+ adb connect %1$s
+ HTTP :5039/adb.json · secure=%1$s · wifi adb=%2$s · %3$s
+ Copied adb connect line
+ One-time (USB): adb shell pm grant com.foxx.androidcast android.permission.WRITE_SECURE_SETTINGS — optional adb shell pm grant … android.permission.DUMP for port discovery. Pair wireless debugging once in system settings. rebuild.sh curls http://DEVICE:5039/adb.json when adb is down.
+ ADB Wi‑Fi keeper
+ Shows adb connect line while debugging on flaky Wi‑Fi.
+ ADB Wi‑Fi ready
+ Waiting for Wi‑Fi IP…
+ adb connect %1$s
Commercial (off until enabled here)
Enable in-app ads (placeholder)
Enable in-app purchases (Play Billing)
diff --git a/app/src/test/java/com/foxx/androidcast/dev/DevAdbWifiHelperTest.java b/app/src/test/java/com/foxx/androidcast/dev/DevAdbWifiHelperTest.java
new file mode 100644
index 0000000..ffe9c4d
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/dev/DevAdbWifiHelperTest.java
@@ -0,0 +1,29 @@
+package com.foxx.androidcast.dev;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class DevAdbWifiHelperTest {
+ @Test
+ public void parseDumpsysEndpoints_findsHostPortPairs() {
+ String dumpsys = "mTLS port: 37145\n"
+ + "registered: 192.168.33.106:37145\n"
+ + "noise 10.0.0.1:5";
+ List endpoints = DevAdbWifiHelper.parseDumpsysEndpoints(dumpsys);
+ assertTrue(endpoints.contains("192.168.33.106:37145"));
+ assertEquals(1, endpoints.size());
+ }
+
+ @Test
+ public void mergeEndpoints_buildsCartesianProduct() {
+ List connect = DevAdbConnectInfo.mergeEndpoints(
+ Arrays.asList("192.168.1.2"),
+ Arrays.asList(5555, 37145));
+ assertEquals(Arrays.asList("192.168.1.2:5555", "192.168.1.2:37145"), connect);
+ }
+}
diff --git a/rebuild.sh b/rebuild.sh
index 7bb60ed..6f9e27d 100755
--- a/rebuild.sh
+++ b/rebuild.sh
@@ -1,63 +1,35 @@
#!/usr/bin/env bash
-# Rebuild debug APK, run unit tests, reconnect known wireless adb targets, install, relaunch.
+# Rebuild debug APK, run unit tests, reconnect wireless adb targets, install, relaunch.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
-RECONNECT_KNOWN_DEVICES=${RECONNECT_KNOWN_DEVICES:-no}
+RECONNECT_KNOWN_DEVICES=${RECONNECT_KNOWN_DEVICES:-yes}
-# Hints for wireless debugging. Edit as needed.
-## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
-declare -A CONNECTIONS_LIST_HINTS
-## android tv projector / android 13
-CONNECTIONS_LIST_HINTS["192_168_33_153"]="[A13] Android Projector"
-## tab g6 max / android 16
-CONNECTIONS_LIST_HINTS["192_168_33_39"]="[A16] Doogee Tab G6 Max / P8_EEA"
-## bl6000pro main / android 11
-CONNECTIONS_LIST_HINTS["192_168_33_106"]="[A11] BlackView BL6000Pro / BL6000Pro_EEA"
-## bl6000pro aux / android 11
-CONNECTIONS_LIST_HINTS["192_168_33_173"]="[A11] BlackView BL6000Pro / BL6000Pro"
-
-get_ip_for_hint_key() {
- local ip=${1}
- ip="$(echo "${ip}" | sed -e s'@_@\.@g' -e s'@:.*@@' | tr -d '\n' )"
- printf "%s" "${ip}"
-}
-
-get_hint_key_for_ip() {
- local ip=${1}
- ip="$(echo "${ip}" | sed -e s'@\.@_@g' -e s'@:.*@@' | tr -d '\n' )"
- printf "%s" "${ip}"
-}
-
-get_hint() {
- local ip_mask="${1}"
- local -n hint
- ip_mask="$(get_hint_key_for_ip "${ip_mask}")"
- hint=CONNECTIONS_LIST_HINTS[${ip_mask}]
- printf "%s" "${hint}"
-}
+# shellcheck source=scripts/adb-reconnect.sh
+source "$ROOT/scripts/adb-reconnect.sh"
+adb_reconnect_init_hints
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
PACKAGE="com.foxx.androidcast"
LAUNCHER="$PACKAGE/.MainActivity"
RUN_UNIT_TESTS="${RUN_UNIT_TESTS:-1}"
-ADB_CONNECT_RETRIES="${ADB_CONNECT_RETRIES:-4}"
-ADB_CONNECT_DELAY_SEC="${ADB_CONNECT_DELAY_SEC:-2}"
for arg in "$@"; do
case "$arg" in
--skip-tests) RUN_UNIT_TESTS=0 ;;
--no-tests) RUN_UNIT_TESTS=0 ;;
+ --no-reconnect) RECONNECT_KNOWN_DEVICES=no ;;
--java-home=*)
ANDROID_CAST_JAVA_HOME="${arg#--java-home=}"
;;
-h|--help)
- echo "Usage: $0 [--skip-tests] [--java-home=PATH]"
- echo " Builds debug APK, runs unit tests (unless skipped), reconnects devices in CONNECTIONS_LIST_HINTS,"
- echo " installs on all adb devices in 'device' state, and launches the app."
+ echo "Usage: $0 [--skip-tests] [--no-reconnect] [--java-home=PATH]"
+ echo " Builds debug APK, runs unit tests (unless skipped), reconnects adb (mdns + HTTP"
+ echo " :5039/adb.json + known IPs), installs on all adb devices, and launches the app."
+ echo " Env: RECONNECT_KNOWN_DEVICES=yes|no (default yes), DEV_ADB_HTTP_PORT=5039"
exit 0
;;
esac
@@ -98,42 +70,6 @@ if ! picked="$(pick_java_home)"; then
fi
export JAVA_HOME="$picked"
-reconnect_endpoint() {
- local endpoint="$1"
- [[ -z "$endpoint" ]] && return 1
- local attempt out
- local hint=""
- hint="$(get_hint "${endpoint}")"
- [[ -n "${hint}" ]] && hint="(having hint: ${hint})"
- echo " trying to connect to $endpoint ${hint}"
- adb disconnect "$endpoint" >/dev/null 2>&1 || true
- for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
- out="$(adb connect "$endpoint" 2>&1)" || true
- if grep -qiE 'connected|already' <<<"$out"; then
- if adb -s "$endpoint" get-state 2>/dev/null | grep -q '^device$'; then
- echo " $endpoint — ready"
- return 0
- fi
- fi
- if [[ "$attempt" -lt "$ADB_CONNECT_RETRIES" ]]; then
- sleep "$ADB_CONNECT_DELAY_SEC"
- fi
- done
- echo " $endpoint — not reachable ($out)" >&2
- return 1
-}
-
-reconnect_known_devices() {
- echo "==> Reconnecting known wireless adb endpoints"
- adb start-server >/dev/null 2>&1 || true
- adb reconnect offline >/dev/null 2>&1 || true
- local endpoint
- for endpoint in "${!CONNECTIONS_LIST_HINTS[@]}"; do
- reconnect_endpoint "$(get_ip_for_hint_key "$endpoint")" || true
- done
- adb devices -l
-}
-
# shellcheck source=scripts/android-ndk.sh
source "$ROOT/scripts/android-ndk.sh"
# shellcheck source=scripts/native-build-cache.sh
@@ -187,15 +123,16 @@ if [[ ! -f "$APK" ]]; then
exit 1
fi
-if [[ "${RECONNECT_KNOWN_DEVICES}" =~ "[yY]*" ]]; then
- reconnect_known_devices
+if [[ "${RECONNECT_KNOWN_DEVICES}" =~ [yY] ]]; then
+ adb_reconnect_all || true
fi
mapfile -t DEVICES < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }')
if [[ ${#DEVICES[@]} -eq 0 ]]; then
echo "ERROR: No adb devices in 'device' state after reconnect." >&2
- echo " Wake devices, re-enable wireless debugging, or: adb connect HOST:5555" >&2
+ echo " Enable dev setting “ADB over Wi‑Fi keeper”, grant WRITE_SECURE_SETTINGS once via USB," >&2
+ echo " pair wireless debugging once, then retry. Or: curl http://DEVICE:5039/adb.json" >&2
exit 1
fi
@@ -205,6 +142,9 @@ for serial in "${DEVICES[@]}"; do
echo "--- $serial"
if adb -s "$serial" install -r "$APK"; then
echo " Installed"
+ adb -s "$serial" shell am broadcast \
+ -a com.foxx.androidcast.dev.REFRESH_ADB_WIFI \
+ -n com.foxx.androidcast/.dev.DevAdbWifiReceiver >/dev/null 2>&1 || true
if adb -s "$serial" shell am start -n "$LAUNCHER" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER; then
echo " Launched $LAUNCHER"
else
@@ -216,6 +156,8 @@ for serial in "${DEVICES[@]}"; do
fi
done
+adb_update_connect_cache
+
if [[ $FAILED -gt 0 ]]; then
echo "Done with $FAILED failure(s)." >&2
exit 1
diff --git a/scripts/adb-reconnect.sh b/scripts/adb-reconnect.sh
new file mode 100755
index 0000000..cc418a1
--- /dev/null
+++ b/scripts/adb-reconnect.sh
@@ -0,0 +1,226 @@
+#!/usr/bin/env bash
+# Wireless adb discovery + reconnect helpers (sourced by rebuild.sh).
+# Discovers endpoints via: connected devices, adb mdns, HTTP :5039/adb.json, cache, static hints.
+
+: "${ADB_CONNECT_RETRIES:=4}"
+: "${ADB_CONNECT_DELAY_SEC:=2}"
+: "${DEV_ADB_HTTP_PORT:=5039}"
+: "${ADB_CONNECT_CACHE:=$ROOT/.adb_connect_cache}"
+: "${ADB_RECONNECT_POKE_DELAY_SEC:=2}"
+
+declare -gA CONNECTIONS_LIST_HINTS
+
+adb_reconnect_init_hints() {
+ CONNECTIONS_LIST_HINTS["192_168_33_153"]="[A13] Android Projector"
+ CONNECTIONS_LIST_HINTS["192_168_33_39"]="[A16] Doogee Tab G6 Max / P8_EEA"
+ CONNECTIONS_LIST_HINTS["192_168_33_106"]="[A11] BlackView BL6000Pro / BL6000Pro_EEA"
+ CONNECTIONS_LIST_HINTS["192_168_33_173"]="[A11] BlackView BL6000Pro / BL6000Pro"
+}
+
+adb_hint_key_for_ip() {
+ local ip=${1}
+ ip="$(echo "${ip}" | sed -e 's@\.@_@g' -e 's@:.*@@' | tr -d '\n')"
+ printf '%s' "${ip}"
+}
+
+adb_ip_for_hint_key() {
+ local ip=${1}
+ ip="$(echo "${ip}" | sed -e 's/@_@/./g' -e 's/:.*//' | tr -d '\n')"
+ printf '%s' "${ip}"
+}
+
+adb_hint_for_endpoint() {
+ local endpoint=${1}
+ local key
+ key="$(adb_hint_key_for_ip "${endpoint}")"
+ printf '%s' "${CONNECTIONS_LIST_HINTS[${key}]:-}"
+}
+
+adb_collect_candidate_ips() {
+ local ip endpoint key
+ if [[ -f "$ADB_CONNECT_CACHE" ]]; then
+ while IFS= read -r endpoint; do
+ [[ -z "$endpoint" ]] && continue
+ ip="${endpoint%%:*}"
+ [[ -n "$ip" ]] && printf '%s\n' "$ip"
+ done < "$ADB_CONNECT_CACHE"
+ fi
+ for key in "${!CONNECTIONS_LIST_HINTS[@]}"; do
+ adb_ip_for_hint_key "$key"
+ done
+ adb devices -l 2>/dev/null | awk '/^[^ ]+ +device/ { print $1 }' | while read -r serial; do
+ [[ "$serial" == *:* ]] || continue
+ printf '%s\n' "${serial%%:*}"
+ done
+}
+
+adb_json_connect_lines() {
+ local json=${1}
+ [[ -n "$json" ]] || return 0
+ printf '%s' "$json" | python3 -c '
+import json, sys
+raw = sys.stdin.read()
+if not raw.strip():
+ raise SystemExit(0)
+try:
+ data = json.loads(raw)
+except json.JSONDecodeError:
+ raise SystemExit(0)
+for item in data.get("connect") or []:
+ if item:
+ print(item)
+for item in data.get("commands") or []:
+ if isinstance(item, str) and item.startswith("adb connect "):
+ print(item[len("adb connect "):])
+' 2>/dev/null || true
+}
+
+adb_fetch_http_endpoints() {
+ local ip json
+ for ip in $(adb_collect_candidate_ips | sort -u); do
+ [[ -z "$ip" ]] && continue
+ json="$(curl -m 3 -sf "http://${ip}:${DEV_ADB_HTTP_PORT}/adb.json" 2>/dev/null || true)"
+ [[ -z "$json" ]] && continue
+ adb_json_connect_lines "$json"
+ done
+}
+
+adb_discover_mdns_endpoints() {
+ local line name service hostport
+ while IFS= read -r line; do
+ [[ "$line" == *"_adb._tcp"* ]] || continue
+ hostport="$(awk '{print $NF}' <<<"$line")"
+ [[ "$hostport" == *:* ]] || continue
+ printf '%s\n' "$hostport"
+ done < <(adb mdns services 2>/dev/null || true)
+}
+
+adb_pull_device_json_endpoints() {
+ local serial json tmp
+ while IFS= read -r serial; do
+ [[ -z "$serial" ]] && continue
+ tmp="$(mktemp)"
+ if adb -s "$serial" exec-out run-as com.foxx.androidcast cat files/dev/adb_connect.json \
+ >"$tmp" 2>/dev/null; then
+ json="$(cat "$tmp")"
+ rm -f "$tmp"
+ adb_json_connect_lines "$json"
+ else
+ rm -f "$tmp"
+ fi
+ done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
+}
+
+adb_discover_endpoints() {
+ local endpoint
+ declare -A seen=()
+ local -a ordered=()
+
+ add_endpoint() {
+ local e=${1}
+ [[ -z "$e" ]] && return 0
+ [[ -n "${seen[$e]:-}" ]] && return 0
+ seen["$e"]=1
+ ordered+=("$e")
+ }
+
+ while IFS= read -r endpoint; do
+ add_endpoint "$endpoint"
+ done < <(adb_discover_mdns_endpoints)
+
+ while IFS= read -r endpoint; do
+ add_endpoint "$endpoint"
+ done < <(adb_fetch_http_endpoints)
+
+ while IFS= read -r endpoint; do
+ add_endpoint "$endpoint"
+ done < <(adb_pull_device_json_endpoints)
+
+ if [[ -f "$ADB_CONNECT_CACHE" ]]; then
+ while IFS= read -r endpoint; do
+ add_endpoint "$endpoint"
+ done < "$ADB_CONNECT_CACHE"
+ fi
+
+ local key ip
+ for key in "${!CONNECTIONS_LIST_HINTS[@]}"; do
+ ip="$(adb_ip_for_hint_key "$key")"
+ add_endpoint "${ip}:5555"
+ add_endpoint "${ip}"
+ done
+
+ printf '%s\n' "${ordered[@]}"
+}
+
+adb_poke_refresh_broadcast() {
+ local serial
+ while IFS= read -r serial; do
+ [[ -z "$serial" ]] && continue
+ adb -s "$serial" shell am broadcast \
+ -a com.foxx.androidcast.dev.REFRESH_ADB_WIFI \
+ -n com.foxx.androidcast/.dev.DevAdbWifiReceiver >/dev/null 2>&1 || true
+ done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
+}
+
+adb_reconnect_endpoint() {
+ local endpoint="$1"
+ [[ -z "$endpoint" ]] && return 1
+ local attempt out hint
+ hint="$(adb_hint_for_endpoint "${endpoint}")"
+ [[ -n "${hint}" ]] && hint="(${hint})"
+ echo " adb connect ${endpoint} ${hint}"
+ adb disconnect "$endpoint" >/dev/null 2>&1 || true
+ for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
+ out="$(adb connect "$endpoint" 2>&1)" || true
+ if grep -qiE 'connected|already' <<<"$out"; then
+ if adb -s "$endpoint" get-state 2>/dev/null | grep -q '^device$'; then
+ echo " ${endpoint} — ready"
+ return 0
+ fi
+ fi
+ if [[ "$attempt" -lt "$ADB_CONNECT_RETRIES" ]]; then
+ sleep "$ADB_CONNECT_DELAY_SEC"
+ fi
+ done
+ echo " ${endpoint} — not reachable (${out})" >&2
+ return 1
+}
+
+adb_update_connect_cache() {
+ local serial
+ mkdir -p "$(dirname "$ADB_CONNECT_CACHE")"
+ : >"$ADB_CONNECT_CACHE"
+ while IFS= read -r serial; do
+ [[ "$serial" == *:* ]] || continue
+ echo "$serial" >>"$ADB_CONNECT_CACHE"
+ done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
+}
+
+adb_reconnect_all() {
+ echo "==> Reconnecting wireless adb targets"
+ adb start-server >/dev/null 2>&1 || true
+ adb reconnect offline >/dev/null 2>&1 || true
+ adb_poke_refresh_broadcast
+ if [[ "${ADB_RECONNECT_POKE_DELAY_SEC:-0}" -gt 0 ]]; then
+ sleep "$ADB_RECONNECT_POKE_DELAY_SEC"
+ fi
+
+ local endpoint connected=0
+ while IFS= read -r endpoint; do
+ [[ -z "$endpoint" ]] && continue
+ if adb_reconnect_endpoint "$endpoint"; then
+ connected=$((connected + 1))
+ fi
+ done < <(adb_discover_endpoints)
+
+ adb_update_connect_cache
+ adb devices -l
+ if [[ "$connected" -gt 0 ]]; then
+ return 0
+ fi
+ return 1
+}
+
+adb_count_ready_devices() {
+ adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { c++ } END { print c+0 }'
+}