diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ad90760..d221802 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -96,6 +96,11 @@
android:exported="false"
android:process=":crashwatcher" />
+
+
all = p.getAll();
+ for (String key : all.keySet()) {
+ if (key.startsWith(PREFIX_SENDER) || key.startsWith(PREFIX_RECEIVER)) {
+ e.remove(key);
+ }
+ }
+ e.apply();
+ }
+
public static CastSettings loadReceiverDefaults(Context context) {
CastSettings s = new CastSettings();
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
index 5431a49..cd29e5e 100644
--- a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
+++ b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
@@ -26,6 +26,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
@@ -190,6 +191,10 @@ public class CastSettingsFragment extends Fragment {
licensesBtn.setOnClickListener(v ->
startActivity(new Intent(requireContext(), LicensesActivity.class)));
}
+ View resetBtn = view.findViewById(R.id.btn_reset_defaults);
+ if (resetBtn != null) {
+ resetBtn.setOnClickListener(v -> showResetDefaultsConfirm());
+ }
}
private void styleSpinners(View root) {
@@ -232,4 +237,18 @@ public class CastSettingsFragment extends Fragment {
ReceiverPrefs.setAllowAudio(requireContext(), AppPreferences.isPlayIncomingAudio(requireContext()));
CastTrayNotifier.onPreferenceChanged(requireContext());
}
+
+ private void showResetDefaultsConfirm() {
+ new AlertDialog.Builder(requireContext())
+ .setMessage(R.string.reset_defaults_confirm)
+ .setPositiveButton(android.R.string.yes, (d, which) -> {
+ AppPreferences.resetUserVisibleSettings(requireContext());
+ ReceiverPrefs.setAllowAudio(requireContext(), AppPreferences.isPlayIncomingAudio(requireContext()));
+ CastTrayNotifier.onPreferenceChanged(requireContext());
+ Toast.makeText(requireContext(), R.string.reset_defaults_done, Toast.LENGTH_SHORT).show();
+ requireActivity().recreate();
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
index 52249d6..2efdc27 100644
--- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
@@ -36,6 +36,7 @@ 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.DevDiagnosticsController;
+import com.foxx.androidcast.dev.DevNetworkSelfTestController;
import com.foxx.androidcast.network.diag.CastDiagPing;
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
import com.google.android.material.tabs.TabLayout;
@@ -44,8 +45,10 @@ import com.google.android.material.textfield.TextInputEditText;
/** Hidden developer settings (opened via app-info area easter egg). */
public class DeveloperSettingsActivity extends AppCompatActivity {
private DevDiagnosticsController diagnosticsController;
+ private DevNetworkSelfTestController networkSelfTestController;
private android.view.View settingsPanel;
private android.view.View diagnosticsPanel;
+ private android.view.View networkSelfTestPanel;
private Spinner videoPresetSpinner;
private Spinner audioPresetSpinner;
private SeekBar intensitySeek;
@@ -65,15 +68,18 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
settingsPanel = findViewById(R.id.dev_panel_settings);
diagnosticsPanel = findViewById(R.id.dev_panel_diagnostics);
+ networkSelfTestPanel = findViewById(R.id.dev_panel_network_selftest);
diagnosticsController = new DevDiagnosticsController(this);
+ networkSelfTestController = new DevNetworkSelfTestController(this);
bindDeveloperTabs();
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
CheckBox adaptiveDisplay = findViewById(R.id.check_receiver_adaptive_display);
+ CheckBox backendNtpSync = findViewById(R.id.check_dev_backend_ntp_sync);
- refreshFromPreferences(debugOverlay, codecScores, sessionStats, adaptiveDisplay);
+ refreshFromPreferences(debugOverlay, codecScores, sessionStats, adaptiveDisplay, backendNtpSync);
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
@@ -111,6 +117,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
if (diagnosticsController != null) {
diagnosticsController.onDestroy();
}
+ if (networkSelfTestController != null) {
+ networkSelfTestController.onDestroy();
+ }
super.onDestroy();
}
@@ -127,7 +136,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
findViewById(R.id.check_receiver_debug_overlay),
findViewById(R.id.check_show_codec_priority_scores),
findViewById(R.id.check_grab_session_stats),
- findViewById(R.id.check_receiver_adaptive_display));
+ findViewById(R.id.check_receiver_adaptive_display),
+ findViewById(R.id.check_dev_backend_ntp_sync));
bindDiagPingSection();
refreshOtaUi(
findViewById(R.id.text_ota_installed_version),
@@ -424,7 +434,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
}
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats,
- CheckBox adaptiveDisplay) {
+ CheckBox adaptiveDisplay, CheckBox backendNtpSync) {
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
AppPreferences::setShowReceiverDebugOverlay);
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
@@ -433,6 +443,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
AppPreferences::setGrabSessionStats);
bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this),
AppPreferences::setDevReceiverAdaptiveDisplay);
+ bindDeveloperCheckbox(backendNtpSync, AppPreferences.isDevBackendNtpSyncEnabled(this),
+ AppPreferences::setDevBackendNtpSyncEnabled);
}
private interface BoolPrefWriter {
@@ -446,15 +458,22 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
}
tabs.addTab(tabs.newTab().setText(R.string.dev_tab_settings));
tabs.addTab(tabs.newTab().setText(R.string.dev_tab_diagnostics));
+ tabs.addTab(tabs.newTab().setText(R.string.dev_tab_network_self_test));
tabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
- boolean diagnostics = tab.getPosition() == 1;
- settingsPanel.setVisibility(diagnostics ? android.view.View.GONE : android.view.View.VISIBLE);
+ int pos = tab.getPosition();
+ boolean diagnostics = pos == 1;
+ boolean selfTest = pos == 2;
+ settingsPanel.setVisibility((diagnostics || selfTest) ? android.view.View.GONE : android.view.View.VISIBLE);
diagnosticsPanel.setVisibility(diagnostics ? android.view.View.VISIBLE : android.view.View.GONE);
+ networkSelfTestPanel.setVisibility(selfTest ? android.view.View.VISIBLE : android.view.View.GONE);
if (diagnostics) {
diagnosticsController.onDiagnosticsTabShown();
}
+ if (selfTest) {
+ networkSelfTestController.onSelfTestTabShown();
+ }
}
@Override
@@ -464,6 +483,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
public void onTabReselected(TabLayout.Tab tab) {
if (tab.getPosition() == 1) {
diagnosticsController.onDiagnosticsTabShown();
+ } else if (tab.getPosition() == 2) {
+ networkSelfTestController.onSelfTestTabShown();
}
}
});
diff --git a/app/src/main/java/com/foxx/androidcast/NetworkConditions.java b/app/src/main/java/com/foxx/androidcast/NetworkConditions.java
new file mode 100644
index 0000000..2d5b1fd
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/NetworkConditions.java
@@ -0,0 +1,58 @@
+package com.foxx.androidcast;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/** Parcelable network self-test snapshot shared over AIDL. */
+public final class NetworkConditions implements Parcelable {
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public NetworkConditions createFromParcel(Parcel in) {
+ return new NetworkConditions(in);
+ }
+
+ @Override
+ public NetworkConditions[] newArray(int size) {
+ return new NetworkConditions[size];
+ }
+ };
+
+ public long collectedAtMs;
+ public String fingerprint;
+ public String reportText;
+ public boolean refreshing;
+
+ public NetworkConditions() {
+ this.collectedAtMs = 0L;
+ this.fingerprint = "";
+ this.reportText = "";
+ this.refreshing = false;
+ }
+
+ public NetworkConditions(long collectedAtMs, String fingerprint, String reportText, boolean refreshing) {
+ this.collectedAtMs = collectedAtMs;
+ this.fingerprint = fingerprint != null ? fingerprint : "";
+ this.reportText = reportText != null ? reportText : "";
+ this.refreshing = refreshing;
+ }
+
+ private NetworkConditions(Parcel in) {
+ collectedAtMs = in.readLong();
+ fingerprint = in.readString();
+ reportText = in.readString();
+ refreshing = in.readInt() != 0;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeLong(collectedAtMs);
+ dest.writeString(fingerprint);
+ dest.writeString(reportText);
+ dest.writeInt(refreshing ? 1 : 0);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestController.java b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestController.java
new file mode 100644
index 0000000..49e7cb2
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestController.java
@@ -0,0 +1,194 @@
+package com.foxx.androidcast.dev;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.foxx.androidcast.INetworkChangeListener;
+import com.foxx.androidcast.INetworkSelfTestService;
+import com.foxx.androidcast.NetworkConditions;
+import com.foxx.androidcast.R;
+import com.foxx.androidcast.network.selftest.NetworkSelfTestService;
+
+/** Developer network self-test panel driven by standalone AIDL service cache. */
+public final class DevNetworkSelfTestController {
+ private final AppCompatActivity activity;
+ private final View panel;
+ private final TextView report;
+ private final ProgressBar spinner;
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private INetworkSelfTestService service;
+ private boolean bound;
+
+ private final INetworkChangeListener listener = new INetworkChangeListener.Stub() {
+ @Override
+ public void onNetworkConditionChange(NetworkConditions aidlNetworkConditions) {
+ if (aidlNetworkConditions == null) {
+ return;
+ }
+ main.post(() -> {
+ spinner.setVisibility(View.GONE);
+ report.setText(render(aidlNetworkConditions));
+ });
+ }
+ };
+
+ private final Runnable refreshPoll = new Runnable() {
+ @Override
+ public void run() {
+ INetworkSelfTestService s = service;
+ if (s == null) {
+ spinner.setVisibility(View.GONE);
+ return;
+ }
+ try {
+ if (s.isRefreshing()) {
+ main.postDelayed(this, 700);
+ return;
+ }
+ NetworkConditions cached = s.getCachedConditions();
+ spinner.setVisibility(View.GONE);
+ if (cached != null && cached.reportText != null && !cached.reportText.isEmpty()) {
+ report.setText(render(cached));
+ } else {
+ report.setText(activity.getString(R.string.dev_network_selftest_pending));
+ }
+ } catch (RemoteException e) {
+ spinner.setVisibility(View.GONE);
+ report.setText("Self-test service error: " + e.getMessage());
+ }
+ }
+ };
+
+ private final ServiceConnection connection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ service = INetworkSelfTestService.Stub.asInterface(binder);
+ try {
+ service.addListener(listener);
+ loadCachedOrRequest();
+ } catch (RemoteException e) {
+ report.setText("Self-test bind failed: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ service = null;
+ bound = false;
+ }
+ };
+
+ public DevNetworkSelfTestController(AppCompatActivity activity) {
+ this.activity = activity;
+ this.panel = activity.findViewById(R.id.dev_panel_network_selftest);
+ this.report = panel.findViewById(R.id.text_dev_network_selftest_report);
+ this.spinner = panel.findViewById(R.id.progress_dev_network_selftest);
+ Button run = panel.findViewById(R.id.btn_dev_network_selftest_force);
+ run.setOnClickListener(v -> requestForceRefresh());
+ }
+
+ public void onSelfTestTabShown() {
+ if (report.length() == 0) {
+ report.setText(activity.getString(R.string.dev_network_selftest_pending));
+ }
+ ensureBound();
+ loadCachedOrRequest();
+ }
+
+ public void onDestroy() {
+ main.removeCallbacks(refreshPoll);
+ if (service != null) {
+ try {
+ service.removeListener(listener);
+ } catch (RemoteException ignored) {
+ }
+ }
+ if (bound) {
+ try {
+ activity.unbindService(connection);
+ } catch (Exception ignored) {
+ }
+ bound = false;
+ }
+ service = null;
+ }
+
+ private void ensureBound() {
+ if (bound) {
+ return;
+ }
+ Intent i = new Intent(activity, NetworkSelfTestService.class);
+ activity.startService(i);
+ bound = activity.bindService(i, connection, Context.BIND_AUTO_CREATE);
+ }
+
+ private void loadCachedOrRequest() {
+ INetworkSelfTestService s = service;
+ if (s == null) {
+ return;
+ }
+ try {
+ NetworkConditions cached = s.getCachedConditions();
+ if (cached != null && cached.reportText != null && !cached.reportText.isEmpty()) {
+ report.setText(render(cached));
+ return;
+ }
+ requestForceRefresh();
+ } catch (RemoteException e) {
+ report.setText("Self-test service error: " + e.getMessage());
+ }
+ }
+
+ private void requestForceRefresh() {
+ INetworkSelfTestService s = service;
+ if (s == null) {
+ ensureBound();
+ s = service;
+ if (s == null) {
+ return;
+ }
+ }
+ spinner.setVisibility(View.VISIBLE);
+ report.setText(activity.getString(R.string.dev_network_selftest_running));
+ try {
+ s.requestRefresh();
+ } catch (RemoteException e) {
+ spinner.setVisibility(View.GONE);
+ report.setText("Self-test service error: " + e.getMessage());
+ return;
+ }
+ main.removeCallbacks(refreshPoll);
+ main.postDelayed(refreshPoll, 700);
+ }
+
+ private String render(NetworkConditions c) {
+ StringBuilder sb = new StringBuilder();
+ long ageMs = Math.max(0L, System.currentTimeMillis() - c.collectedAtMs);
+ String age = ageMs < 60_000L
+ ? (ageMs / 1000L) + "s ago"
+ : (ageMs / 60_000L) + "m ago";
+ String fp = c.fingerprint != null ? c.fingerprint : "";
+ if (fp.length() > 10) {
+ fp = fp.substring(0, 10) + "…";
+ }
+ sb.append("snapshot ").append(age).append(" · fp ").append(fp);
+ if (c.refreshing) {
+ sb.append(" · refreshing");
+ }
+ sb.append("\n—\n");
+ sb.append(c.reportText != null ? c.reportText : "");
+ return sb.toString();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java
new file mode 100644
index 0000000..bf04120
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java
@@ -0,0 +1,670 @@
+package com.foxx.androidcast.dev;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.ProxyInfo;
+import android.os.Build;
+import android.os.PowerManager;
+import android.telephony.TelephonyManager;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.BuildConfig;
+import com.foxx.androidcast.crash.CrashSettingsStore;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Focused network self-test for developer settings (compact structured report). */
+public final class DevNetworkSelfTestProbe {
+ private static final int HTTP_TIMEOUT_MS = 5_000;
+ private static final String API10_NA = "requires Android API 10+";
+
+ private DevNetworkSelfTestProbe() {}
+
+ public static String run(Context context, AtomicBoolean cancel) {
+ DevNetworkSelfTestReport report = new DevNetworkSelfTestReport();
+ report.header();
+
+ ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
+ Network active = cm != null ? cm.getActiveNetwork() : null;
+ NetworkCapabilities caps = (cm != null && active != null) ? cm.getNetworkCapabilities(active) : null;
+ LinkProperties lp = (cm != null && active != null) ? cm.getLinkProperties(active) : null;
+
+ probeOverall(context, caps, cancel, report);
+ probeLink(context, caps, lp, report);
+ probeCaptive(caps, cancel, report);
+ probeIntraLan(lp, cancel, report);
+ probeBackend(context, cancel, report);
+ probeNtp(context, cancel, report);
+ probeNatExternal(caps, context, cancel, report);
+ probeMtu(lp, report);
+ probeBluetooth(caps, cancel, report);
+
+ if (cancel != null && cancel.get()) {
+ report.itemWarn("run", "cancelled before completion");
+ }
+ return report.build();
+ }
+
+ private static void probeOverall(Context context, NetworkCapabilities caps, AtomicBoolean cancel,
+ DevNetworkSelfTestReport r) {
+ r.section("AVAIL", "Availability");
+ boolean online = caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
+ boolean validated = caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
+ if (!online) {
+ r.itemFail("internet", "no active route");
+ } else if (!validated) {
+ r.itemWarn("internet", "online, not validated");
+ } else {
+ r.itemPass("internet", "online + validated");
+ }
+ r.itemBool(DevNetworkSelfTestReport.Level.PASS, "wifi",
+ hasTransport(caps, NetworkCapabilities.TRANSPORT_WIFI), "active", "off");
+ r.itemBool(DevNetworkSelfTestReport.Level.PASS, "mobile",
+ hasTransport(caps, NetworkCapabilities.TRANSPORT_CELLULAR), "active", "off");
+ r.itemBool(DevNetworkSelfTestReport.Level.PASS, "ethernet",
+ hasTransport(caps, NetworkCapabilities.TRANSPORT_ETHERNET), "active", "off");
+ r.itemBool(DevNetworkSelfTestReport.Level.PASS, "bt-transport",
+ hasTransport(caps, NetworkCapabilities.TRANSPORT_BLUETOOTH), "active", "off");
+ r.itemBool(DevNetworkSelfTestReport.Level.PASS, "bt-tether",
+ hasBtPanInterface(), "bt-pan up", "none");
+
+ PowerManager pm = context.getSystemService(PowerManager.class);
+ if (pm != null) {
+ r.itemBool(pm.isPowerSaveMode() ? DevNetworkSelfTestReport.Level.WARN : DevNetworkSelfTestReport.Level.PASS,
+ "power-save", true, pm.isPowerSaveMode() ? "on" : "off", "off");
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ r.itemBool(pm.isDeviceIdleMode() ? DevNetworkSelfTestReport.Level.WARN : DevNetworkSelfTestReport.Level.PASS,
+ "doze", true, pm.isDeviceIdleMode() ? "active" : "off", "off");
+ } else {
+ r.itemNa("doze", API10_NA);
+ }
+ }
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("status", "stopped");
+ }
+ }
+
+ private static void probeLink(Context context, NetworkCapabilities caps, LinkProperties lp,
+ DevNetworkSelfTestReport r) {
+ r.section("LINK", "Link parameters");
+ List modes = new ArrayList<>();
+ if (hasTransport(caps, NetworkCapabilities.TRANSPORT_WIFI)) {
+ modes.add("wifi");
+ }
+ if (hasTransport(caps, NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ modes.add("mobile");
+ }
+ if (hasTransport(caps, NetworkCapabilities.TRANSPORT_ETHERNET)) {
+ modes.add("ethernet");
+ }
+ if (hasTransport(caps, NetworkCapabilities.TRANSPORT_BLUETOOTH)) {
+ modes.add("bluetooth");
+ }
+ r.itemPass("mode", modes.isEmpty() ? "other/unknown" : String.join("+", modes));
+ r.itemBool(hasTransport(caps, NetworkCapabilities.TRANSPORT_VPN)
+ ? DevNetworkSelfTestReport.Level.WARN : DevNetworkSelfTestReport.Level.PASS,
+ "vpn/tunnel", hasTransport(caps, NetworkCapabilities.TRANSPORT_VPN), "in use", "none");
+
+ if (lp == null) {
+ r.itemWarn("iface", "no link properties");
+ return;
+ }
+ r.itemPass("iface", lp.getInterfaceName() != null ? lp.getInterfaceName() : "?");
+ String ipv4 = firstIpv4Cidr(lp);
+ if (ipv4.isEmpty()) {
+ r.itemWarn("ipv4", "none");
+ } else {
+ r.itemPass("ipv4", ipv4);
+ }
+ if (lp.getLinkAddresses().size() > 1) {
+ r.itemPass("cidr-all", joinCidrs(lp));
+ }
+ if (lp.getDnsServers().isEmpty()) {
+ r.itemFail("dns", "none configured");
+ } else {
+ List dns = new ArrayList<>();
+ for (InetAddress addr : lp.getDnsServers()) {
+ dns.add(addr.getHostAddress());
+ }
+ r.itemPass("dns", String.join(", ", dns));
+ }
+ ProxyInfo proxy = lp.getHttpProxy();
+ if (proxy != null && proxy.getHost() != null) {
+ r.itemWarn("http-proxy", proxy.getHost() + ":" + proxy.getPort());
+ } else {
+ r.itemPass("http-proxy", "none");
+ }
+
+ TelephonyManager tm = context.getSystemService(TelephonyManager.class);
+ if (tm != null) {
+ String operator = tm.getNetworkOperatorName();
+ r.itemPass("operator", operator == null || operator.isEmpty() ? "unknown" : operator);
+ r.itemNa("apn", API10_NA);
+ }
+ }
+
+ private static void probeCaptive(NetworkCapabilities caps, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
+ r.section("PORTAL", "Captive portal");
+ boolean declared = caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL);
+ if (declared) {
+ r.itemFail("os-flag", "captive portal declared");
+ } else {
+ r.itemPass("os-flag", "clear");
+ }
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("probe", "stopped");
+ return;
+ }
+ HttpResult probe = httpHead("http://connectivitycheck.gstatic.com/generate_204");
+ if (probe.code == 204) {
+ r.itemPass("generate_204", probe.summary);
+ r.itemPass("heuristic", "no portal signs");
+ } else if (probe.code > 0) {
+ r.itemWarn("generate_204", probe.summary);
+ r.itemWarn("heuristic", "possible captive portal");
+ } else {
+ r.itemFail("generate_204", probe.summary);
+ }
+ }
+
+ private static void probeIntraLan(LinkProperties lp, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
+ r.section("LAN", "Intra-network");
+ String ip = firstIpv4Host(lp);
+ if (ip == null) {
+ r.itemNa("peer-scan", "no local IPv4");
+ return;
+ }
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("peer-scan", "stopped");
+ return;
+ }
+ String[] parts = ip.split("\\.");
+ if (parts.length != 4) {
+ r.itemNa("peer-scan", "unsupported IPv4");
+ return;
+ }
+ String prefix = parts[0] + "." + parts[1] + "." + parts[2] + ".";
+ int self = Integer.parseInt(parts[3]);
+ List rtts = new ArrayList<>();
+ int peers = 0;
+ long t0 = System.currentTimeMillis();
+ for (int d = -8; d <= 8; d++) {
+ if (cancel != null && cancel.get()) {
+ break;
+ }
+ int host = self + d;
+ if (host <= 1 || host >= 255 || host == self) {
+ continue;
+ }
+ try {
+ InetAddress addr = InetAddress.getByName(prefix + host);
+ long p0 = System.currentTimeMillis();
+ if (addr.isReachable(180)) {
+ peers++;
+ rtts.add(System.currentTimeMillis() - p0);
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ long elapsed = System.currentTimeMillis() - t0;
+ r.itemPass("subnet", prefix + "x (self " + ip + ")");
+ if (peers == 0) {
+ r.itemWarn("peers", "0 reachable (+/-8 quick scan)");
+ r.itemNa("avg-rtt", "n/a");
+ } else {
+ long sum = 0;
+ for (Long v : rtts) {
+ sum += v;
+ }
+ double avg = (double) sum / peers;
+ r.itemPass("peers", peers + " reachable");
+ r.itemPass("avg-rtt", String.format(Locale.US, "%.0f ms", avg));
+ }
+ r.itemPass("scan-ms", Long.toString(elapsed));
+ }
+
+ private static void probeBackend(Context context, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
+ r.section("BACKEND", "Backend reachability");
+ String url = resolveBackendUrl(context);
+ if (url.isEmpty()) {
+ r.itemWarn("url", "not configured");
+ return;
+ }
+ r.itemPass("url", shortenUrl(url));
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("probe", "stopped");
+ return;
+ }
+ HttpResult head = httpHead(url);
+ if (head.code > 0 && head.code < 500) {
+ r.itemPass("head", head.summary);
+ } else {
+ r.itemFail("head", head.summary);
+ }
+ HttpReadResult read = httpRead(url, 32 * 1024);
+ if (read.summary.startsWith("fail")) {
+ r.itemFail("downlink", read.summary);
+ } else {
+ r.itemPass("downlink", read.summary);
+ }
+ HttpResult uplink = httpPostJson(url, "{\"ping\":\"bandwidth\"}");
+ if (uplink.code > 0) {
+ r.itemPass("uplink", uplink.summary);
+ } else {
+ r.itemFail("uplink", uplink.summary);
+ }
+ }
+
+ private static void probeNtp(Context context, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
+ r.section("NTP", "Time sync");
+ r.itemPass("utc-epoch", Long.toString(System.currentTimeMillis() / 1000L));
+ int tzMin = java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000;
+ r.itemPass("tz-offset", tzMin + " min");
+ boolean enabled = AppPreferences.isDevBackendNtpSyncEnabled(context);
+ r.itemBool(enabled ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.NA,
+ "be-ntp-sync", enabled, "enabled", "disabled (dev setting)");
+ if (!enabled) {
+ return;
+ }
+ String backend = resolveBackendUrl(context);
+ if (backend.isEmpty()) {
+ r.itemWarn("be-ntp", "backend URL missing");
+ return;
+ }
+ String heartbeatUrl = toHeartbeatUrl(backend);
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("be-ntp", "stopped");
+ return;
+ }
+ long now = System.currentTimeMillis() / 1000L;
+ String tz = new java.text.SimpleDateFormat("Z", Locale.US).format(new java.util.Date());
+ String body = "{\"heartbeat\":{\"type\":\"ntp\",\"date\":" + now + ",\"tz\":\"" + tz + "\"}}";
+ HttpTextResult text = httpPostJsonText(heartbeatUrl, body);
+ if (text.summary.startsWith("HTTP") && !text.summary.startsWith("fail")) {
+ r.itemPass("be-ntp", text.summary);
+ if (text.body != null && !text.body.isEmpty()) {
+ r.itemPass("be-payload", truncate(text.body, 120));
+ }
+ } else {
+ r.itemFail("be-ntp", text.summary);
+ }
+ }
+
+ private static void probeNatExternal(NetworkCapabilities caps, Context context, AtomicBoolean cancel,
+ DevNetworkSelfTestReport r) {
+ r.section("WAN", "NAT / external IP");
+ String nat;
+ if (hasTransport(caps, NetworkCapabilities.TRANSPORT_VPN)) {
+ nat = "vpn/tunnel";
+ } else if (hasTransport(caps, NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ nat = "likely CGNAT (mobile)";
+ } else if (hasTransport(caps, NetworkCapabilities.TRANSPORT_WIFI)) {
+ nat = "likely router NAT";
+ } else {
+ nat = "unknown";
+ }
+ r.itemPass("nat", nat);
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("external-ip", "stopped");
+ return;
+ }
+ String backend = resolveBackendUrl(context);
+ if (!backend.isEmpty()) {
+ HttpTextResult viaBackend = httpPostJsonText(toHeartbeatUrl(backend),
+ "{\"heartbeat\":{\"type\":\"ip\"}}");
+ if (viaBackend.summary.startsWith("fail")) {
+ r.itemFail("external-ip", viaBackend.summary);
+ } else {
+ r.itemPass("external-ip", viaBackend.summary);
+ if (viaBackend.body != null && !viaBackend.body.isEmpty()) {
+ r.itemPass("be-ip-body", truncate(viaBackend.body, 80));
+ }
+ }
+ return;
+ }
+ HttpTextResult web = httpGetText("https://api.ipify.org?format=json");
+ if (web.summary.startsWith("fail")) {
+ r.itemFail("external-ip", web.summary);
+ } else {
+ r.itemPass("external-ip", truncate(web.summary, 100));
+ }
+ }
+
+ private static void probeMtu(LinkProperties lp, DevNetworkSelfTestReport r) {
+ r.section("MTU", "MTU");
+ if (lp != null) {
+ r.itemPass("active-link", Integer.toString(lp.getMtu()));
+ } else {
+ r.itemNa("active-link", "n/a");
+ }
+ try {
+ Enumeration en = NetworkInterface.getNetworkInterfaces();
+ if (en == null) {
+ r.itemNa("ifaces", "unavailable");
+ return;
+ }
+ List lines = new ArrayList<>();
+ for (NetworkInterface nif : Collections.list(en)) {
+ if (!nif.isUp()) {
+ continue;
+ }
+ lines.add(nif.getName() + "=" + nif.getMTU());
+ if (lines.size() >= 6) {
+ break;
+ }
+ }
+ if (lines.isEmpty()) {
+ r.itemWarn("ifaces", "none up");
+ } else {
+ r.itemPass("ifaces", String.join(", ", lines));
+ }
+ } catch (Exception e) {
+ r.itemFail("ifaces", e.getMessage());
+ }
+ }
+
+ private static void probeBluetooth(NetworkCapabilities caps, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
+ r.section("BT", "Bluetooth");
+ boolean btTransport = hasTransport(caps, NetworkCapabilities.TRANSPORT_BLUETOOTH);
+ boolean btPan = hasBtPanInterface();
+ if (!btTransport && !btPan) {
+ r.itemNa("available", "no BT network iface");
+ return;
+ }
+ r.itemPass("available", btTransport ? "transport+iface" : "bt-pan only");
+ if (cancel != null && cancel.get()) {
+ r.itemWarn("bw-sample", "stopped");
+ return;
+ }
+ long t0 = System.currentTimeMillis();
+ int bytes = 0;
+ try {
+ InetAddress addr = InetAddress.getByName("1.1.1.1");
+ if (addr.isReachable(350)) {
+ bytes = 512;
+ }
+ } catch (Exception ignored) {
+ }
+ long dt = Math.max(1L, System.currentTimeMillis() - t0);
+ double kbps = (bytes * 8.0) / dt;
+ r.itemPass("bw-sample", String.format(Locale.US, "~%.1f Kbit/s (rough)", kbps));
+ }
+
+ private static String firstIpv4Cidr(LinkProperties lp) {
+ if (lp == null) {
+ return "";
+ }
+ for (LinkAddress la : lp.getLinkAddresses()) {
+ InetAddress addr = la.getAddress();
+ if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
+ return la.toString();
+ }
+ }
+ return "";
+ }
+
+ private static String firstIpv4Host(LinkProperties lp) {
+ if (lp == null) {
+ return null;
+ }
+ for (LinkAddress la : lp.getLinkAddresses()) {
+ InetAddress addr = la.getAddress();
+ if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
+ return addr.getHostAddress();
+ }
+ }
+ return null;
+ }
+
+ private static String joinCidrs(LinkProperties lp) {
+ List out = new ArrayList<>();
+ for (LinkAddress la : lp.getLinkAddresses()) {
+ out.add(la.toString());
+ }
+ return String.join("; ", out);
+ }
+
+ private static String shortenUrl(String url) {
+ if (url.length() <= 72) {
+ return url;
+ }
+ return url.substring(0, 69) + "...";
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ String oneLine = s.replace('\n', ' ').trim();
+ if (oneLine.length() <= max) {
+ return oneLine;
+ }
+ return oneLine.substring(0, max - 3) + "...";
+ }
+
+ private static String toHeartbeatUrl(String backend) {
+ if (backend.contains("/api/upload.php")) {
+ return backend.replace("/api/upload.php", "/api/heartbeat.php");
+ }
+ if (backend.endsWith("/")) {
+ return backend + "api/heartbeat.php";
+ }
+ return backend + "/api/heartbeat.php";
+ }
+
+ private static String resolveBackendUrl(Context context) {
+ String crash = CrashSettingsStore.load(context).uploadUrl;
+ if (crash != null && !crash.trim().isEmpty()) {
+ return crash.trim();
+ }
+ String ota = AppPreferences.getOtaChannelUrl(context);
+ if (ota != null && !ota.trim().isEmpty()) {
+ return ota.trim();
+ }
+ if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null && !BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim().isEmpty()) {
+ return BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
+ }
+ return "";
+ }
+
+ private static boolean hasTransport(NetworkCapabilities caps, int t) {
+ return caps != null && caps.hasTransport(t);
+ }
+
+ private static boolean hasBtPanInterface() {
+ try {
+ Enumeration en = NetworkInterface.getNetworkInterfaces();
+ if (en == null) {
+ return false;
+ }
+ for (NetworkInterface nif : Collections.list(en)) {
+ String n = nif.getName();
+ if (n != null && (n.startsWith("bt-pan") || n.startsWith("bnep"))) {
+ return nif.isUp();
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ return false;
+ }
+
+ private static HttpResult httpHead(String url) {
+ long t0 = System.currentTimeMillis();
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setRequestMethod("HEAD");
+ c.setConnectTimeout(HTTP_TIMEOUT_MS);
+ c.setReadTimeout(HTTP_TIMEOUT_MS);
+ c.setInstanceFollowRedirects(true);
+ int code = c.getResponseCode();
+ long ms = System.currentTimeMillis() - t0;
+ c.disconnect();
+ return new HttpResult(code, "HTTP " + code + " · " + ms + " ms");
+ } catch (Exception e) {
+ long ms = System.currentTimeMillis() - t0;
+ return new HttpResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms");
+ }
+ }
+
+ private static HttpResult httpPostJson(String url, String body) {
+ long t0 = System.currentTimeMillis();
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setRequestMethod("POST");
+ c.setConnectTimeout(HTTP_TIMEOUT_MS);
+ c.setReadTimeout(HTTP_TIMEOUT_MS);
+ c.setDoOutput(true);
+ c.setRequestProperty("Content-Type", "application/json");
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ try (OutputStream os = c.getOutputStream()) {
+ os.write(bytes);
+ }
+ int code = c.getResponseCode();
+ long ms = System.currentTimeMillis() - t0;
+ c.disconnect();
+ return new HttpResult(code, "HTTP " + code + " · " + ms + " ms · " + bytes.length + " B");
+ } catch (Exception e) {
+ long ms = System.currentTimeMillis() - t0;
+ return new HttpResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms");
+ }
+ }
+
+ private static HttpReadResult httpRead(String url, int maxBytes) {
+ long t0 = System.currentTimeMillis();
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setRequestMethod("GET");
+ c.setConnectTimeout(HTTP_TIMEOUT_MS);
+ c.setReadTimeout(HTTP_TIMEOUT_MS);
+ int code = c.getResponseCode();
+ int read = 0;
+ try (InputStream in = c.getInputStream()) {
+ byte[] buf = new byte[4096];
+ while (read < maxBytes) {
+ int n = in.read(buf, 0, Math.min(buf.length, maxBytes - read));
+ if (n <= 0) {
+ break;
+ }
+ read += n;
+ }
+ }
+ long ms = Math.max(1L, System.currentTimeMillis() - t0);
+ double kbps = (read * 8.0) / ms;
+ c.disconnect();
+ return new HttpReadResult("HTTP " + code + " · " + read + " B · "
+ + String.format(Locale.US, "%.1f Kbit/s", kbps));
+ } catch (Exception e) {
+ long ms = System.currentTimeMillis() - t0;
+ return new HttpReadResult("fail · " + e.getClass().getSimpleName() + " · " + ms + " ms");
+ }
+ }
+
+ private static HttpTextResult httpPostJsonText(String url, String body) {
+ long t0 = System.currentTimeMillis();
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setRequestMethod("POST");
+ c.setConnectTimeout(HTTP_TIMEOUT_MS);
+ c.setReadTimeout(HTTP_TIMEOUT_MS);
+ c.setDoOutput(true);
+ c.setRequestProperty("Content-Type", "application/json");
+ try (OutputStream os = c.getOutputStream()) {
+ os.write(body.getBytes(StandardCharsets.UTF_8));
+ }
+ int code = c.getResponseCode();
+ String text = readBody(c);
+ long ms = System.currentTimeMillis() - t0;
+ c.disconnect();
+ return new HttpTextResult("HTTP " + code + " · " + ms + " ms", text);
+ } catch (Exception e) {
+ long ms = System.currentTimeMillis() - t0;
+ return new HttpTextResult("fail · " + e.getClass().getSimpleName() + " · " + ms + " ms", "");
+ }
+ }
+
+ private static HttpTextResult httpGetText(String url) {
+ long t0 = System.currentTimeMillis();
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setRequestMethod("GET");
+ c.setConnectTimeout(HTTP_TIMEOUT_MS);
+ c.setReadTimeout(HTTP_TIMEOUT_MS);
+ int code = c.getResponseCode();
+ String text = readBody(c);
+ long ms = System.currentTimeMillis() - t0;
+ c.disconnect();
+ return new HttpTextResult("HTTP " + code + " · " + ms + " ms", text);
+ } catch (Exception e) {
+ long ms = System.currentTimeMillis() - t0;
+ return new HttpTextResult("fail · " + e.getClass().getSimpleName() + " · " + ms + " ms", "");
+ }
+ }
+
+ private static String readBody(HttpURLConnection c) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = br.readLine()) != null) {
+ sb.append(line);
+ if (sb.length() > 2048) {
+ break;
+ }
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(c.getErrorStream(), StandardCharsets.UTF_8))) {
+ String line = br.readLine();
+ return line != null ? line : "";
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+ }
+
+ private static final class HttpResult {
+ final int code;
+ final String summary;
+
+ HttpResult(int code, String summary) {
+ this.code = code;
+ this.summary = summary;
+ }
+ }
+
+ private static final class HttpReadResult {
+ final String summary;
+
+ HttpReadResult(String summary) {
+ this.summary = summary;
+ }
+ }
+
+ private static final class HttpTextResult {
+ final String summary;
+ final String body;
+
+ HttpTextResult(String summary, String body) {
+ this.summary = summary;
+ this.body = body;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestReport.java b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestReport.java
new file mode 100644
index 0000000..c1897f7
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestReport.java
@@ -0,0 +1,69 @@
+package com.foxx.androidcast.dev;
+
+import java.util.Locale;
+
+/** Compact structured text report for network self-test. */
+final class DevNetworkSelfTestReport {
+ enum Level {
+ PASS, WARN, FAIL, NA;
+
+ String tag() {
+ return name();
+ }
+ }
+
+ private final StringBuilder sb = new StringBuilder(2048);
+ private boolean hasSections;
+
+ void header() {
+ sb.append("NETWORK SELF-TEST\n");
+ }
+
+ void section(String code, String title) {
+ if (hasSections) {
+ sb.append('\n');
+ }
+ hasSections = true;
+ sb.append('[').append(code).append("] ").append(title).append('\n');
+ }
+
+ void item(Level level, String key, String value) {
+ String k = padKey(key);
+ sb.append(' ').append(level.tag()).append(" ").append(k).append(' ')
+ .append(value != null ? value : "").append('\n');
+ }
+
+ void itemPass(String key, String value) {
+ item(Level.PASS, key, value);
+ }
+
+ void itemWarn(String key, String value) {
+ item(Level.WARN, key, value);
+ }
+
+ void itemFail(String key, String value) {
+ item(Level.FAIL, key, value);
+ }
+
+ void itemNa(String key, String value) {
+ item(Level.NA, key, value);
+ }
+
+ void itemBool(Level yesLevel, String key, boolean yes, String yesText, String noText) {
+ item(yesLevel, key, yes ? yesText : noText);
+ }
+
+ String build() {
+ return sb.toString().trim();
+ }
+
+ private static String padKey(String key) {
+ if (key == null) {
+ return " ";
+ }
+ if (key.length() >= 16) {
+ return key.substring(0, 16);
+ }
+ return String.format(Locale.US, "%-16s", key);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/selftest/NetworkSelfTestBridge.java b/app/src/main/java/com/foxx/androidcast/network/selftest/NetworkSelfTestBridge.java
new file mode 100644
index 0000000..7017235
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/selftest/NetworkSelfTestBridge.java
@@ -0,0 +1,26 @@
+package com.foxx.androidcast.network.selftest;
+
+import android.content.Context;
+import android.content.Intent;
+
+/** Helper to signal network self-test service from cast services. */
+public final class NetworkSelfTestBridge {
+ private NetworkSelfTestBridge() {}
+
+ public static void startCapture(Context context) {
+ send(context, NetworkSelfTestService.ACTION_START_CAPTURE);
+ }
+
+ public static void stopCapture(Context context) {
+ send(context, NetworkSelfTestService.ACTION_STOP_CAPTURE);
+ }
+
+ private static void send(Context context, String action) {
+ if (context == null) {
+ return;
+ }
+ Intent i = new Intent(context, NetworkSelfTestService.class);
+ i.setAction(action);
+ context.startService(i);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/network/selftest/NetworkSelfTestService.java b/app/src/main/java/com/foxx/androidcast/network/selftest/NetworkSelfTestService.java
new file mode 100644
index 0000000..670735a
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/selftest/NetworkSelfTestService.java
@@ -0,0 +1,365 @@
+package com.foxx.androidcast.network.selftest;
+
+import android.app.Service;
+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.HandlerThread;
+import android.os.IBinder;
+import android.os.RemoteCallbackList;
+import android.os.RemoteException;
+
+import com.foxx.androidcast.BuildConfig;
+import com.foxx.androidcast.INetworkChangeListener;
+import com.foxx.androidcast.INetworkSelfTestService;
+import com.foxx.androidcast.NetworkConditions;
+import com.foxx.androidcast.dev.DevNetworkSelfTestProbe;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Standalone network self-test service with AIDL cache and change subscriptions. */
+public final class NetworkSelfTestService extends Service {
+ public static final String ACTION_START_CAPTURE = "com.foxx.androidcast.NET_SELFTEST_START_CAPTURE";
+ public static final String ACTION_STOP_CAPTURE = "com.foxx.androidcast.NET_SELFTEST_STOP_CAPTURE";
+ public static final String ACTION_FORCE_REFRESH = "com.foxx.androidcast.NET_SELFTEST_FORCE_REFRESH";
+
+ private final RemoteCallbackList listeners = new RemoteCallbackList<>();
+ private final AtomicBoolean refreshing = new AtomicBoolean(false);
+ private final Object lock = new Object();
+
+ private HandlerThread workerThread;
+ private Handler worker;
+ private ConnectivityManager connectivityManager;
+ private ConnectivityManager.NetworkCallback networkCallback;
+ private NetworkConditions cached;
+ private int periodicIntervalMinutes;
+
+ private boolean captureActive;
+ private long captureSessionStartedUtcSec;
+ private JSONArray captureEvents = new JSONArray();
+
+ private final Runnable periodicTask = new Runnable() {
+ @Override
+ public void run() {
+ requestRefreshInternal(false, "periodic");
+ if (worker != null) {
+ worker.postDelayed(this, periodicIntervalMinutes * 60_000L);
+ }
+ }
+ };
+
+ private final INetworkSelfTestService.Stub binder = new INetworkSelfTestService.Stub() {
+ @Override
+ public NetworkConditions getCachedConditions() {
+ synchronized (lock) {
+ if (cached == null) {
+ return null;
+ }
+ return new NetworkConditions(cached.collectedAtMs, cached.fingerprint, cached.reportText,
+ refreshing.get());
+ }
+ }
+
+ @Override
+ public void requestRefresh() {
+ requestRefreshInternal(true, "client_request");
+ }
+
+ @Override
+ public boolean isRefreshing() {
+ return refreshing.get();
+ }
+
+ @Override
+ public void addListener(INetworkChangeListener listener) {
+ if (listener != null) {
+ listeners.register(listener);
+ }
+ }
+
+ @Override
+ public void removeListener(INetworkChangeListener listener) {
+ if (listener != null) {
+ listeners.unregister(listener);
+ }
+ }
+
+ @Override
+ public void startCapture() {
+ setCaptureActive(true);
+ }
+
+ @Override
+ public void stopCapture() {
+ setCaptureActive(false);
+ }
+
+ @Override
+ public void setPeriodicIntervalMinutes(int minutes) {
+ if (minutes != 1 && minutes != 5 && minutes != 15) {
+ return;
+ }
+ periodicIntervalMinutes = minutes;
+ restartPeriodicTask();
+ }
+
+ @Override
+ public int getPeriodicIntervalMinutes() {
+ return periodicIntervalMinutes;
+ }
+ };
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ periodicIntervalMinutes = BuildConfig.DEBUG ? 1 : 5;
+ workerThread = new HandlerThread("NetSelfTestWorker");
+ workerThread.start();
+ worker = new Handler(workerThread.getLooper());
+ connectivityManager = getSystemService(ConnectivityManager.class);
+ registerNetworkCallback();
+ worker.post(periodicTask);
+ requestRefreshInternal(true, "service_start");
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null) {
+ String action = intent.getAction();
+ if (ACTION_START_CAPTURE.equals(action)) {
+ setCaptureActive(true);
+ return START_STICKY;
+ } else if (ACTION_STOP_CAPTURE.equals(action)) {
+ setCaptureActive(false);
+ return START_STICKY;
+ } else if (ACTION_FORCE_REFRESH.equals(action)) {
+ requestRefreshInternal(true, "intent_force");
+ return START_STICKY;
+ }
+ }
+ return START_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (worker != null) {
+ worker.removeCallbacksAndMessages(null);
+ }
+ unregisterNetworkCallback();
+ listeners.kill();
+ if (workerThread != null) {
+ workerThread.quitSafely();
+ }
+ super.onDestroy();
+ }
+
+ private void requestRefreshInternal(boolean forceNotifyIfChanged, String reason) {
+ if (worker == null || !refreshing.compareAndSet(false, true)) {
+ return;
+ }
+ worker.post(() -> {
+ try {
+ long now = System.currentTimeMillis();
+ String report = DevNetworkSelfTestProbe.run(getApplicationContext(), new AtomicBoolean(false));
+ String fingerprint = sha256(report);
+ NetworkConditions next = new NetworkConditions(now, fingerprint, report, false);
+ NetworkConditions prev;
+ synchronized (lock) {
+ prev = cached;
+ cached = next;
+ }
+ boolean changed = prev == null || !fingerprint.equals(prev.fingerprint);
+ if (captureActive && changed) {
+ appendCaptureEvent(reason, next);
+ }
+ if (changed || forceNotifyIfChanged) {
+ notifyListeners(next, changed);
+ }
+ } finally {
+ refreshing.set(false);
+ }
+ });
+ }
+
+ private void notifyListeners(NetworkConditions current, boolean changed) {
+ // Requirement: no push notifications when conditions have not changed.
+ if (!changed) {
+ return;
+ }
+ int n = listeners.beginBroadcast();
+ try {
+ for (int i = 0; i < n; i++) {
+ INetworkChangeListener cb = listeners.getBroadcastItem(i);
+ if (cb == null) {
+ continue;
+ }
+ try {
+ cb.onNetworkConditionChange(current);
+ } catch (RemoteException ignored) {
+ }
+ }
+ } finally {
+ listeners.finishBroadcast();
+ }
+ }
+
+ private void registerNetworkCallback() {
+ if (connectivityManager == null || networkCallback != null) {
+ return;
+ }
+ networkCallback = new ConnectivityManager.NetworkCallback() {
+ @Override
+ public void onAvailable(Network network) {
+ onNetworkChange();
+ }
+
+ @Override
+ public void onLost(Network network) {
+ onNetworkChange();
+ }
+
+ @Override
+ public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCapabilities) {
+ onNetworkChange();
+ }
+ };
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ connectivityManager.registerDefaultNetworkCallback(networkCallback);
+ } else {
+ NetworkRequest req = new NetworkRequest.Builder().build();
+ connectivityManager.registerNetworkCallback(req, networkCallback);
+ }
+ } catch (Exception ignored) {
+ }
+ }
+
+ private void unregisterNetworkCallback() {
+ if (connectivityManager == null || networkCallback == null) {
+ return;
+ }
+ try {
+ connectivityManager.unregisterNetworkCallback(networkCallback);
+ } catch (Exception ignored) {
+ }
+ networkCallback = null;
+ }
+
+ private void onNetworkChange() {
+ if (!captureActive) {
+ return;
+ }
+ requestRefreshInternal(true, "network_changed");
+ }
+
+ private void restartPeriodicTask() {
+ if (worker == null) {
+ return;
+ }
+ worker.removeCallbacks(periodicTask);
+ worker.post(periodicTask);
+ }
+
+ private void setCaptureActive(boolean active) {
+ synchronized (lock) {
+ if (captureActive == active) {
+ return;
+ }
+ captureActive = active;
+ if (active) {
+ captureSessionStartedUtcSec = System.currentTimeMillis() / 1000L;
+ captureEvents = new JSONArray();
+ rotateCaptureFiles();
+ requestRefreshInternal(true, "capture_start");
+ } else {
+ flushCaptureJournal();
+ }
+ }
+ }
+
+ private void appendCaptureEvent(String reason, NetworkConditions conditions) {
+ synchronized (lock) {
+ if (!captureActive) {
+ return;
+ }
+ try {
+ JSONObject event = new JSONObject();
+ event.put("ts_ms", System.currentTimeMillis());
+ event.put("reason", reason);
+ event.put("collected_at_ms", conditions.collectedAtMs);
+ event.put("fingerprint", conditions.fingerprint);
+ event.put("report", conditions.reportText);
+ captureEvents.put(event);
+ } catch (Exception ignored) {
+ }
+ flushCaptureJournal();
+ }
+ }
+
+ private void flushCaptureJournal() {
+ synchronized (lock) {
+ if (captureSessionStartedUtcSec <= 0) {
+ return;
+ }
+ File out = new File(getFilesDir(), "net_conn_" + captureSessionStartedUtcSec + ".json");
+ try {
+ JSONObject root = new JSONObject();
+ root.put("session_start_utc", captureSessionStartedUtcSec);
+ root.put("capture_active", captureActive);
+ root.put("events", captureEvents);
+ try (FileOutputStream fos = new FileOutputStream(out, false)) {
+ fos.write(root.toString(2).getBytes(StandardCharsets.UTF_8));
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ private void rotateCaptureFiles() {
+ File dir = getFilesDir();
+ File[] files = dir.listFiles((d, name) -> name.startsWith("net_conn_") && name.endsWith(".json"));
+ if (files == null || files.length <= 20) {
+ return;
+ }
+ List all = new ArrayList<>(Arrays.asList(files));
+ all.sort(Comparator.comparingLong(File::lastModified).reversed());
+ for (int i = 20; i < all.size(); i++) {
+ //noinspection ResultOfMethodCallIgnored
+ all.get(i).delete();
+ }
+ }
+
+ private static String sha256(String text) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest((text != null ? text : "").getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return Long.toHexString(System.currentTimeMillis());
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index 509b1bc..134c80f 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -51,6 +51,7 @@ import com.foxx.androidcast.stats.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
+import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge;
import com.foxx.androidcast.util.CastBackgroundExecutor;
import java.util.Arrays;
@@ -177,6 +178,7 @@ public class ReceiverCastService extends Service {
@Override
public void onCreate() {
super.onCreate();
+ NetworkSelfTestBridge.startCapture(this);
CastNotifications.createChannels(this);
if (AppPreferences.isGrabSessionStats(this)) {
SessionStatsStore.pruneOnAppStart(this);
@@ -1044,6 +1046,7 @@ public class ReceiverCastService extends Service {
videoDecodeHandler = null;
}
callbacks.kill();
+ NetworkSelfTestBridge.stopCapture(this);
super.onDestroy();
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
index 5559664..fd8ac26 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -54,6 +54,7 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
+import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge;
import com.foxx.androidcast.media.codec.AudioCodecFactory;
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
@@ -171,6 +172,7 @@ public class ScreenCastService extends Service implements
@Override
public void onCreate() {
super.onCreate();
+ NetworkSelfTestBridge.startCapture(this);
if (AppPreferences.isGrabSessionStats(this)) {
SessionStatsStore.pruneOnAppStart(this);
}
@@ -1225,6 +1227,7 @@ public class ScreenCastService extends Service implements
stopping.set(true);
releaseAll();
callbacks.kill();
+ NetworkSelfTestBridge.stopCapture(this);
super.onDestroy();
}
}
diff --git a/app/src/main/res/layout/activity_dev_network_selftest_panel.xml b/app/src/main/res/layout/activity_dev_network_selftest_panel.xml
new file mode 100644
index 0000000..00b5925
--- /dev/null
+++ b/app/src/main/res/layout/activity_dev_network_selftest_panel.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 3c2790d..cff2d75 100644
--- a/app/src/main/res/layout/activity_dev_settings_panel.xml
+++ b/app/src/main/res/layout/activity_dev_settings_panel.xml
@@ -37,6 +37,20 @@
android:layout_marginTop="12dp"
android:text="@string/dev_diag_ping_pong" />
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_app_info_section.xml b/app/src/main/res/layout/include_app_info_section.xml
index 322008a..5adeaa2 100644
--- a/app/src/main/res/layout/include_app_info_section.xml
+++ b/app/src/main/res/layout/include_app_info_section.xml
@@ -120,4 +120,11 @@
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/btn_licenses" />
+
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 5b68f9b..8e6c421 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -192,6 +192,9 @@
Сборка:
Автор:
Лицензии
+ Сбросить по умолчанию
+ Восстановить все настройки приложения по умолчанию?
+ Настройки приложения сброшены по умолчанию
ПРИНЯТЬ
Лицензии
Не могу найти факл лицензий
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d4c7be0..131ad8b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -203,6 +203,9 @@
Built:
Author:
Licenses
+ Reset to defaults
+ Restore all app settings to default?
+ App settings restored to default
ACCEPT
Licenses
Could not load license text.
@@ -256,6 +259,15 @@
Mirror smart (local timestamp)
Reply-with (seq + timestamps)
Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.
+ Enable backend NTP sync
+ Disabled by default. Allows self-test/backend heartbeat to fetch server time correction without changing system clock.
+ Network self-test
+ Network self-test tab unlocked
+ Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, peer reachability, backend RTT/throughput, NAT and NTP correction.
+ Run self-test
+ Force re-check
+ Waiting for self-test run…
+ Running self-test…
Session statistics
Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).
Open statistics analyzer
diff --git a/examples/build_console/backend/public/api/heartbeat.php b/examples/build_console/backend/public/api/heartbeat.php
new file mode 100644
index 0000000..f88a5d7
--- /dev/null
+++ b/examples/build_console/backend/public/api/heartbeat.php
@@ -0,0 +1,51 @@
+ 'method_not_allowed']);
+ exit;
+}
+
+$raw = file_get_contents('php://input') ?: '';
+$req = json_decode($raw, true);
+if (!is_array($req)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'invalid_json']);
+ exit;
+}
+
+$heartbeat = $req['heartbeat'] ?? [];
+if (!is_array($heartbeat)) {
+ $heartbeat = [];
+}
+
+$type = (string)($heartbeat['type'] ?? 'generic');
+$srvEpoch = time();
+$srvTz = date('O');
+
+$resp = [
+ 'heartbeat' => [
+ 'type' => $type,
+ 'server_date' => $srvEpoch,
+ 'server_tz' => $srvTz,
+ ],
+];
+
+if ($type === 'ntp') {
+ $clientDate = (int)($heartbeat['date'] ?? 0);
+ $clientTz = (string)($heartbeat['tz'] ?? '');
+ $resp['heartbeat']['client_date'] = $clientDate;
+ $resp['heartbeat']['client_tz'] = $clientTz;
+ $resp['heartbeat']['correction_seconds'] = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
+ $resp['heartbeat']['enabled'] = true;
+}
+
+if ($type === 'ip') {
+ $resp['heartbeat']['external_ip'] = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
+}
+
+echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
diff --git a/examples/build_console/backend/public/index.php b/examples/build_console/backend/public/index.php
index e7e88c3..bb36819 100644
--- a/examples/build_console/backend/public/index.php
+++ b/examples/build_console/backend/public/index.php
@@ -25,6 +25,10 @@ if ($route === '/api/build_log.php' || str_ends_with($route, '/api/build_log.php
require __DIR__ . '/api/build_log.php';
exit;
}
+if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
+ require __DIR__ . '/api/heartbeat.php';
+ exit;
+}
if ($route === '/logout') {
Auth::logout();
diff --git a/examples/crash_reporter/backend/public/api/heartbeat.php b/examples/crash_reporter/backend/public/api/heartbeat.php
new file mode 100644
index 0000000..f88a5d7
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/heartbeat.php
@@ -0,0 +1,51 @@
+ 'method_not_allowed']);
+ exit;
+}
+
+$raw = file_get_contents('php://input') ?: '';
+$req = json_decode($raw, true);
+if (!is_array($req)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'invalid_json']);
+ exit;
+}
+
+$heartbeat = $req['heartbeat'] ?? [];
+if (!is_array($heartbeat)) {
+ $heartbeat = [];
+}
+
+$type = (string)($heartbeat['type'] ?? 'generic');
+$srvEpoch = time();
+$srvTz = date('O');
+
+$resp = [
+ 'heartbeat' => [
+ 'type' => $type,
+ 'server_date' => $srvEpoch,
+ 'server_tz' => $srvTz,
+ ],
+];
+
+if ($type === 'ntp') {
+ $clientDate = (int)($heartbeat['date'] ?? 0);
+ $clientTz = (string)($heartbeat['tz'] ?? '');
+ $resp['heartbeat']['client_date'] = $clientDate;
+ $resp['heartbeat']['client_tz'] = $clientTz;
+ $resp['heartbeat']['correction_seconds'] = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
+ $resp['heartbeat']['enabled'] = true;
+}
+
+if ($type === 'ip') {
+ $resp['heartbeat']['external_ip'] = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
+}
+
+echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index 25859cd..c744722 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -30,6 +30,11 @@ if ($route === '/api/diag.php' || str_ends_with($route, '/api/diag.php')) {
exit;
}
+if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
+ require __DIR__ . '/api/heartbeat.php';
+ exit;
+}
+
if ($route === '/api/reports.php' || str_ends_with($route, '/api/reports.php')) {
require __DIR__ . '/api/reports.php';
exit;