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 @@ + + + + + + + + + + + + +