mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:57:50 +03:00
snap
This commit is contained in:
@@ -96,6 +96,11 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:process=":crashwatcher" />
|
android:process=":crashwatcher" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".network.selftest.NetworkSelfTestService"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":netselftest" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".sender.ScreenCastService"
|
android:name=".sender.ScreenCastService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.NetworkConditions;
|
||||||
|
|
||||||
|
interface INetworkChangeListener {
|
||||||
|
void onNetworkConditionChange(in NetworkConditions aidlNetworkConditions);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
parcelable NetworkConditions;
|
||||||
@@ -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_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_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_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. */
|
/** Minimum interval between automatic OTA checks on app launch. */
|
||||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
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();
|
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) {
|
public static CastSettings loadReceiverDefaults(Context context) {
|
||||||
CastSettings s = new CastSettings();
|
CastSettings s = new CastSettings();
|
||||||
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
|
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import android.widget.Toast;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
@@ -190,6 +191,10 @@ public class CastSettingsFragment extends Fragment {
|
|||||||
licensesBtn.setOnClickListener(v ->
|
licensesBtn.setOnClickListener(v ->
|
||||||
startActivity(new Intent(requireContext(), LicensesActivity.class)));
|
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) {
|
private void styleSpinners(View root) {
|
||||||
@@ -232,4 +237,18 @@ public class CastSettingsFragment extends Fragment {
|
|||||||
ReceiverPrefs.setAllowAudio(requireContext(), AppPreferences.isPlayIncomingAudio(requireContext()));
|
ReceiverPrefs.setAllowAudio(requireContext(), AppPreferences.isPlayIncomingAudio(requireContext()));
|
||||||
CastTrayNotifier.onPreferenceChanged(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
|||||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||||
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
||||||
import com.foxx.androidcast.dev.DevDiagnosticsController;
|
import com.foxx.androidcast.dev.DevDiagnosticsController;
|
||||||
|
import com.foxx.androidcast.dev.DevNetworkSelfTestController;
|
||||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
import com.foxx.androidcast.network.diag.CastDiagPing;
|
||||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||||
import com.google.android.material.tabs.TabLayout;
|
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). */
|
/** Hidden developer settings (opened via app-info area easter egg). */
|
||||||
public class DeveloperSettingsActivity extends AppCompatActivity {
|
public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||||
private DevDiagnosticsController diagnosticsController;
|
private DevDiagnosticsController diagnosticsController;
|
||||||
|
private DevNetworkSelfTestController networkSelfTestController;
|
||||||
private android.view.View settingsPanel;
|
private android.view.View settingsPanel;
|
||||||
private android.view.View diagnosticsPanel;
|
private android.view.View diagnosticsPanel;
|
||||||
|
private android.view.View networkSelfTestPanel;
|
||||||
private Spinner videoPresetSpinner;
|
private Spinner videoPresetSpinner;
|
||||||
private Spinner audioPresetSpinner;
|
private Spinner audioPresetSpinner;
|
||||||
private SeekBar intensitySeek;
|
private SeekBar intensitySeek;
|
||||||
@@ -65,15 +68,18 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
settingsPanel = findViewById(R.id.dev_panel_settings);
|
settingsPanel = findViewById(R.id.dev_panel_settings);
|
||||||
diagnosticsPanel = findViewById(R.id.dev_panel_diagnostics);
|
diagnosticsPanel = findViewById(R.id.dev_panel_diagnostics);
|
||||||
|
networkSelfTestPanel = findViewById(R.id.dev_panel_network_selftest);
|
||||||
diagnosticsController = new DevDiagnosticsController(this);
|
diagnosticsController = new DevDiagnosticsController(this);
|
||||||
|
networkSelfTestController = new DevNetworkSelfTestController(this);
|
||||||
bindDeveloperTabs();
|
bindDeveloperTabs();
|
||||||
|
|
||||||
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
|
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
|
||||||
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
|
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
|
||||||
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
|
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
|
||||||
CheckBox adaptiveDisplay = findViewById(R.id.check_receiver_adaptive_display);
|
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 ->
|
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
|
||||||
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
||||||
@@ -111,6 +117,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
if (diagnosticsController != null) {
|
if (diagnosticsController != null) {
|
||||||
diagnosticsController.onDestroy();
|
diagnosticsController.onDestroy();
|
||||||
}
|
}
|
||||||
|
if (networkSelfTestController != null) {
|
||||||
|
networkSelfTestController.onDestroy();
|
||||||
|
}
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +136,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
findViewById(R.id.check_receiver_debug_overlay),
|
findViewById(R.id.check_receiver_debug_overlay),
|
||||||
findViewById(R.id.check_show_codec_priority_scores),
|
findViewById(R.id.check_show_codec_priority_scores),
|
||||||
findViewById(R.id.check_grab_session_stats),
|
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();
|
bindDiagPingSection();
|
||||||
refreshOtaUi(
|
refreshOtaUi(
|
||||||
findViewById(R.id.text_ota_installed_version),
|
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,
|
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats,
|
||||||
CheckBox adaptiveDisplay) {
|
CheckBox adaptiveDisplay, CheckBox backendNtpSync) {
|
||||||
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
|
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
|
||||||
AppPreferences::setShowReceiverDebugOverlay);
|
AppPreferences::setShowReceiverDebugOverlay);
|
||||||
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
|
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
|
||||||
@@ -433,6 +443,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
AppPreferences::setGrabSessionStats);
|
AppPreferences::setGrabSessionStats);
|
||||||
bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this),
|
bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this),
|
||||||
AppPreferences::setDevReceiverAdaptiveDisplay);
|
AppPreferences::setDevReceiverAdaptiveDisplay);
|
||||||
|
bindDeveloperCheckbox(backendNtpSync, AppPreferences.isDevBackendNtpSyncEnabled(this),
|
||||||
|
AppPreferences::setDevBackendNtpSyncEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface BoolPrefWriter {
|
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_settings));
|
||||||
tabs.addTab(tabs.newTab().setText(R.string.dev_tab_diagnostics));
|
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() {
|
tabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onTabSelected(TabLayout.Tab tab) {
|
public void onTabSelected(TabLayout.Tab tab) {
|
||||||
boolean diagnostics = tab.getPosition() == 1;
|
int pos = tab.getPosition();
|
||||||
settingsPanel.setVisibility(diagnostics ? android.view.View.GONE : android.view.View.VISIBLE);
|
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);
|
diagnosticsPanel.setVisibility(diagnostics ? android.view.View.VISIBLE : android.view.View.GONE);
|
||||||
|
networkSelfTestPanel.setVisibility(selfTest ? android.view.View.VISIBLE : android.view.View.GONE);
|
||||||
if (diagnostics) {
|
if (diagnostics) {
|
||||||
diagnosticsController.onDiagnosticsTabShown();
|
diagnosticsController.onDiagnosticsTabShown();
|
||||||
}
|
}
|
||||||
|
if (selfTest) {
|
||||||
|
networkSelfTestController.onSelfTestTabShown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -464,6 +483,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
public void onTabReselected(TabLayout.Tab tab) {
|
public void onTabReselected(TabLayout.Tab tab) {
|
||||||
if (tab.getPosition() == 1) {
|
if (tab.getPosition() == 1) {
|
||||||
diagnosticsController.onDiagnosticsTabShown();
|
diagnosticsController.onDiagnosticsTabShown();
|
||||||
|
} else if (tab.getPosition() == 2) {
|
||||||
|
networkSelfTestController.onSelfTestTabShown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import com.foxx.androidcast.stats.SessionStatsContext;
|
|||||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge;
|
||||||
import com.foxx.androidcast.util.CastBackgroundExecutor;
|
import com.foxx.androidcast.util.CastBackgroundExecutor;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -177,6 +178,7 @@ public class ReceiverCastService extends Service {
|
|||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
NetworkSelfTestBridge.startCapture(this);
|
||||||
CastNotifications.createChannels(this);
|
CastNotifications.createChannels(this);
|
||||||
if (AppPreferences.isGrabSessionStats(this)) {
|
if (AppPreferences.isGrabSessionStats(this)) {
|
||||||
SessionStatsStore.pruneOnAppStart(this);
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
@@ -1044,6 +1046,7 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecodeHandler = null;
|
videoDecodeHandler = null;
|
||||||
}
|
}
|
||||||
callbacks.kill();
|
callbacks.kill();
|
||||||
|
NetworkSelfTestBridge.stopCapture(this);
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
|||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
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.AudioCodecFactory;
|
||||||
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
||||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||||
@@ -171,6 +172,7 @@ public class ScreenCastService extends Service implements
|
|||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
NetworkSelfTestBridge.startCapture(this);
|
||||||
if (AppPreferences.isGrabSessionStats(this)) {
|
if (AppPreferences.isGrabSessionStats(this)) {
|
||||||
SessionStatsStore.pruneOnAppStart(this);
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
}
|
}
|
||||||
@@ -1225,6 +1227,7 @@ public class ScreenCastService extends Service implements
|
|||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
releaseAll();
|
releaseAll();
|
||||||
callbacks.kill();
|
callbacks.kill();
|
||||||
|
NetworkSelfTestBridge.stopCapture(this);
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -37,6 +37,20 @@
|
|||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:text="@string/dev_diag_ping_pong" />
|
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
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -30,5 +30,12 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:visibility="gone" />
|
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>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -120,4 +120,11 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:text="@string/btn_licenses" />
|
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>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -192,6 +192,9 @@
|
|||||||
<string name="label_app_built">Сборка:</string>
|
<string name="label_app_built">Сборка:</string>
|
||||||
<string name="label_app_author">Автор:</string>
|
<string name="label_app_author">Автор:</string>
|
||||||
<string name="btn_licenses">Лицензии</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="btn_accept">ПРИНЯТЬ</string>
|
||||||
<string name="licenses_title">Лицензии</string>
|
<string name="licenses_title">Лицензии</string>
|
||||||
<string name="licenses_load_error">Не могу найти факл лицензий</string>
|
<string name="licenses_load_error">Не могу найти факл лицензий</string>
|
||||||
|
|||||||
@@ -203,6 +203,9 @@
|
|||||||
<string name="label_app_built">Built:</string>
|
<string name="label_app_built">Built:</string>
|
||||||
<string name="label_app_author">Author:</string>
|
<string name="label_app_author">Author:</string>
|
||||||
<string name="btn_licenses">Licenses</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="btn_accept">ACCEPT</string>
|
||||||
<string name="licenses_title">Licenses</string>
|
<string name="licenses_title">Licenses</string>
|
||||||
<string name="licenses_load_error">Could not load license text.</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_smart">Mirror smart (local timestamp)</string>
|
||||||
<string name="dev_diag_ping_response_reply">Reply-with (seq + timestamps)</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_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_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_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>
|
<string name="session_stats_open">Open statistics analyzer</string>
|
||||||
|
|||||||
51
examples/build_console/backend/public/api/heartbeat.php
Normal file
51
examples/build_console/backend/public/api/heartbeat.php
Normal 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);
|
||||||
@@ -25,6 +25,10 @@ if ($route === '/api/build_log.php' || str_ends_with($route, '/api/build_log.php
|
|||||||
require __DIR__ . '/api/build_log.php';
|
require __DIR__ . '/api/build_log.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
|
||||||
|
require __DIR__ . '/api/heartbeat.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/logout') {
|
if ($route === '/logout') {
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
|
|||||||
51
examples/crash_reporter/backend/public/api/heartbeat.php
Normal file
51
examples/crash_reporter/backend/public/api/heartbeat.php
Normal 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);
|
||||||
@@ -30,6 +30,11 @@ if ($route === '/api/diag.php' || str_ends_with($route, '/api/diag.php')) {
|
|||||||
exit;
|
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')) {
|
if ($route === '/api/reports.php' || str_ends_with($route, '/api/reports.php')) {
|
||||||
require __DIR__ . '/api/reports.php';
|
require __DIR__ . '/api/reports.php';
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
Reference in New Issue
Block a user