1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 08:57:44 +03:00

test adb reconnect persistence

This commit is contained in:
Anton Afanasyeu
2026-06-11 14:55:46 +02:00
parent 2c3accf4e8
commit 0431bafd74
16 changed files with 1178 additions and 77 deletions

View File

@@ -19,6 +19,10 @@
<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" />
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.DUMP"
tools:ignore="ProtectedPermissions" />
<application
android:name=".AndroidCastApplication"
@@ -165,5 +169,13 @@
<receiver
android:name=".TrayNotificationReceiver"
android:exported="false" />
<receiver
android:name=".dev.DevAdbWifiReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.foxx.androidcast.dev.REFRESH_ADB_WIFI" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -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);
}
}

View File

@@ -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()));

View File

@@ -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) {

View File

@@ -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<String> 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();
}
}

View File

@@ -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<String> ips;
public final List<String> connect;
public final boolean adbEnabled;
public final boolean adbWifiEnabled;
public final boolean secureSettingsGranted;
public final int legacyPort;
public final long updatedMs;
public DevAdbConnectInfo(
List<String> ips,
List<String> 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<String> values) {
JSONArray array = new JSONArray();
for (String value : values) {
if (!TextUtils.isEmpty(value)) {
array.put(value);
}
}
return array;
}
public static List<String> mergeEndpoints(List<String> ips, List<Integer> ports) {
Set<String> 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);
}
}

View File

@@ -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<String> ips = collectWifiIpv4Addresses(app);
List<Integer> ports = discoverPorts(app);
List<String> connect = DevAdbConnectInfo.mergeEndpoints(ips, ports);
connect.addAll(parseDumpsysEndpoints(runDumpsysAdb(app)));
List<String> 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<String> collectWifiIpv4Addresses(Context context) {
Set<String> 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<Integer> discoverPorts(Context context) {
Set<Integer> 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<String> parseDumpsysEndpoints(String dumpsys) {
Set<String> 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<String> dedupe(List<String> values) {
Set<String> out = new LinkedHashSet<>();
for (String value : values) {
if (!TextUtils.isEmpty(value)) {
out.add(value.trim());
}
}
return new ArrayList<>(out);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -330,6 +330,51 @@
android:text="@string/dev_wired_display_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/dev_adb_wifi_section"
android:textSize="16sp"
android:textStyle="bold" />
<CheckBox
android:id="@+id/check_dev_adb_wifi_keeper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dev_adb_wifi_keeper" />
<TextView
android:id="@+id/text_dev_adb_wifi_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textIsSelectable="true"
android:textSize="12sp"
android:typeface="monospace" />
<Button
android:id="@+id/btn_dev_adb_wifi_refresh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_adb_wifi_refresh" />
<Button
android:id="@+id/btn_dev_adb_wifi_copy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_adb_wifi_copy" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/dev_adb_wifi_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -237,6 +237,20 @@
<string name="dev_usb_tether_transport">Show USB-tether transport in cast settings (stub, roadmap E)</string>
<string name="wired_display_detected_hint">External display detected — use system mirror or WiFi cast to receiver</string>
<string name="transport_usb_tether">USB tether (experimental)</string>
<string name="dev_adb_wifi_section">ADB over WiFi (debug)</string>
<string name="dev_adb_wifi_keeper">Keep wireless adb alive after WiFi drops</string>
<string name="dev_adb_wifi_refresh">Refresh adb connect info now</string>
<string name="dev_adb_wifi_copy">Copy adb connect line</string>
<string name="dev_adb_wifi_status_waiting">Waiting for WiFi and adb endpoints…</string>
<string name="dev_adb_wifi_status_line">adb connect %1$s</string>
<string name="dev_adb_wifi_status_detail">HTTP :5039/adb.json · secure=%1$s · wifi adb=%2$s · %3$s</string>
<string name="dev_adb_wifi_copied">Copied adb connect line</string>
<string name="dev_adb_wifi_hint">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.</string>
<string name="dev_adb_wifi_notification_channel">ADB WiFi keeper</string>
<string name="dev_adb_wifi_notification_channel_desc">Shows adb connect line while debugging on flaky WiFi.</string>
<string name="dev_adb_wifi_notification_title">ADB WiFi ready</string>
<string name="dev_adb_wifi_notification_waiting">Waiting for WiFi IP…</string>
<string name="dev_adb_wifi_notification_line">adb connect %1$s</string>
<string name="dev_commercial_section">Commercial (off until enabled here)</string>
<string name="dev_commercial_ads">Enable in-app ads (placeholder)</string>
<string name="dev_commercial_iap">Enable in-app purchases (Play Billing)</string>

View File

@@ -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<String> endpoints = DevAdbWifiHelper.parseDumpsysEndpoints(dumpsys);
assertTrue(endpoints.contains("192.168.33.106:37145"));
assertEquals(1, endpoints.size());
}
@Test
public void mergeEndpoints_buildsCartesianProduct() {
List<String> 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);
}
}