1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:58:14 +03:00

Merge branch 'next' into feature/3d_earth

This commit is contained in:
Anton Afanasyeu
2026-06-02 13:56:58 +02:00
25 changed files with 1700 additions and 5 deletions

View File

@@ -96,6 +96,11 @@
android:exported="false"
android:process=":crashwatcher" />
<service
android:name=".network.selftest.NetworkSelfTestService"
android:exported="false"
android:process=":netselftest" />
<service
android:name=".sender.ScreenCastService"
android:exported="false"

View File

@@ -0,0 +1,7 @@
package com.foxx.androidcast;
import com.foxx.androidcast.NetworkConditions;
interface INetworkChangeListener {
void onNetworkConditionChange(in NetworkConditions aidlNetworkConditions);
}

View File

@@ -0,0 +1,16 @@
package com.foxx.androidcast;
import com.foxx.androidcast.INetworkChangeListener;
import com.foxx.androidcast.NetworkConditions;
interface INetworkSelfTestService {
NetworkConditions getCachedConditions();
void requestRefresh();
boolean isRefreshing();
void addListener(INetworkChangeListener listener);
void removeListener(INetworkChangeListener listener);
void startCapture();
void stopCapture();
void setPeriodicIntervalMinutes(int minutes);
int getPeriodicIntervalMinutes();
}

View File

@@ -0,0 +1,3 @@
package com.foxx.androidcast;
parcelable NetworkConditions;

View File

@@ -60,6 +60,7 @@ public final class AppPreferences {
private static final String KEY_DEV_COMMERCIAL_PLAY_STORE = "dev_commercial_play_store";
private static final String KEY_DEV_DIAG_PING_PONG = "dev_diag_ping_pong";
private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode";
private static final String KEY_DEV_BACKEND_NTP_SYNC = "dev_backend_ntp_sync";
/** Minimum interval between automatic OTA checks on app launch. */
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
@@ -418,6 +419,42 @@ public final class AppPreferences {
prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply();
}
/** Developer: allow backend-provided NTP correction for diagnostics (default off). */
public static boolean isDevBackendNtpSyncEnabled(Context context) {
return prefs(context).getBoolean(KEY_DEV_BACKEND_NTP_SYNC, false);
}
public static void setDevBackendNtpSyncEnabled(Context context, boolean enabled) {
prefs(context).edit().putBoolean(KEY_DEV_BACKEND_NTP_SYNC, enabled).apply();
}
/**
* Reset user-facing settings shown in Android Cast settings.
* Developer-only settings are intentionally preserved.
*/
public static void resetUserVisibleSettings(Context context) {
SharedPreferences p = prefs(context);
SharedPreferences.Editor e = p.edit();
e.putString(KEY_USERNAME, defaultDeviceName(context));
e.putString(KEY_PIN, CastConfig.DEFAULT_PIN);
e.putBoolean(KEY_SHOW_TRAY, true);
e.putString(KEY_THEME, CastThemeHelper.AppThemeMode.SYSTEM.name());
e.putString(KEY_LOCALE, CastLocaleHelper.AppLocaleMode.SYSTEM.name());
e.putBoolean(KEY_PLAY_INCOMING_AUDIO, true);
e.putBoolean(KEY_SEND_ANONYMOUS_CRASH_LOGS, true);
e.putBoolean(KEY_RECEIVER_PIP_USER_DEFINED, false);
e.putBoolean(KEY_RECEIVER_PIP_ENABLED, false);
java.util.Map<String, ?> 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);

View File

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

View File

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

View File

@@ -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<NetworkConditions> CREATOR = new Creator<NetworkConditions>() {
@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);
}
}

View File

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

View File

@@ -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<String> 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<String> 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<Long> 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<NetworkInterface> en = NetworkInterface.getNetworkInterfaces();
if (en == null) {
r.itemNa("ifaces", "unavailable");
return;
}
List<String> 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<String> 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<NetworkInterface> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/dev_network_selftest_intro"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/progress_dev_network_selftest"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:fillViewport="true">
<TextView
android:id="@+id/text_dev_network_selftest_report"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/dev_network_selftest_pending"
android:textIsSelectable="true"
android:textSize="12sp" />
</ScrollView>
<Button
android:id="@+id/btn_dev_network_selftest_force"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_network_selftest_force" />
</LinearLayout>

View File

@@ -37,6 +37,20 @@
android:layout_marginTop="12dp"
android:text="@string/dev_diag_ping_pong" />
<CheckBox
android:id="@+id/check_dev_backend_ntp_sync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dev_backend_ntp_sync_enable" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/dev_backend_ntp_sync_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -30,5 +30,12 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<include
android:id="@+id/dev_panel_network_selftest"
layout="@layout/activity_dev_network_selftest_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -120,4 +120,11 @@
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/btn_licenses" />
<Button
android:id="@+id/btn_reset_defaults"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/btn_reset_defaults" />
</LinearLayout>

View File

@@ -192,6 +192,9 @@
<string name="label_app_built">Сборка:</string>
<string name="label_app_author">Автор:</string>
<string name="btn_licenses">Лицензии</string>
<string name="btn_reset_defaults">Сбросить по умолчанию</string>
<string name="reset_defaults_confirm">Восстановить все настройки приложения по умолчанию?</string>
<string name="reset_defaults_done">Настройки приложения сброшены по умолчанию</string>
<string name="btn_accept">ПРИНЯТЬ</string>
<string name="licenses_title">Лицензии</string>
<string name="licenses_load_error">Не могу найти факл лицензий</string>

View File

@@ -203,6 +203,9 @@
<string name="label_app_built">Built:</string>
<string name="label_app_author">Author:</string>
<string name="btn_licenses">Licenses</string>
<string name="btn_reset_defaults">Reset to defaults</string>
<string name="reset_defaults_confirm">Restore all app settings to default?</string>
<string name="reset_defaults_done">App settings restored to default</string>
<string name="btn_accept">ACCEPT</string>
<string name="licenses_title">Licenses</string>
<string name="licenses_load_error">Could not load license text.</string>
@@ -256,6 +259,15 @@
<string name="dev_diag_ping_response_smart">Mirror smart (local timestamp)</string>
<string name="dev_diag_ping_response_reply">Reply-with (seq + timestamps)</string>
<string name="dev_diag_ping_hint">Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.</string>
<string name="dev_backend_ntp_sync_enable">Enable backend NTP sync</string>
<string name="dev_backend_ntp_sync_hint">Disabled by default. Allows self-test/backend heartbeat to fetch server time correction without changing system clock.</string>
<string name="dev_tab_network_self_test">Network self-test</string>
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
<string name="dev_network_selftest_intro">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.</string>
<string name="dev_network_selftest_run">Run self-test</string>
<string name="dev_network_selftest_force">Force re-check</string>
<string name="dev_network_selftest_pending">Waiting for self-test run…</string>
<string name="dev_network_selftest_running">Running self-test…</string>
<string name="session_stats_title">Session statistics</string>
<string name="session_stats_subtitle">Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).</string>
<string name="session_stats_open">Open statistics analyzer</string>

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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);

View File

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

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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);

View File

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