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" /> + + + + + + +