mirror of
git://f0xx.org/android_cast
synced 2026-07-29 08:57:44 +03:00
sync
This commit is contained in:
@@ -177,5 +177,16 @@
|
||||
<action android:name="com.foxx.androidcast.dev.REFRESH_ADB_WIFI" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".dev.DebugDeployWakeReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.foxx.androidcast.dev.PREPARE_FOR_DEPLOY" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface IVpnService {
|
||||
void registerStateCallback(IVpnStateCallback callback);
|
||||
void unregisterStateCallback(IVpnStateCallback callback);
|
||||
int getState();
|
||||
String getStateDetail();
|
||||
boolean isVpnPermissionGranted();
|
||||
void applyWireGuardConfig(String wgQuickConfig, IVpnOperationCallback callback);
|
||||
void tearDown(IVpnOperationCallback callback);
|
||||
|
||||
@@ -62,6 +62,9 @@ public final class AppPreferences {
|
||||
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";
|
||||
private static final String KEY_DEV_BACKEND_NTP_OFFSET_MS = "dev_backend_ntp_offset_ms";
|
||||
private static final String KEY_DEV_BACKEND_NTP_LAST_SYNC_MS = "dev_backend_ntp_last_sync_ms";
|
||||
private static final String KEY_DEV_BACKEND_NTP_LAST_SKEW_MS = "dev_backend_ntp_last_skew_ms";
|
||||
private static final String KEY_DEV_REMOTE_ACCESS_MODE = "dev_remote_access_mode";
|
||||
private static final String KEY_DEV_REMOTE_ACCESS_RANDOM = "dev_remote_access_random";
|
||||
private static final String KEY_DEV_REMOTE_ACCESS_SESSION_ID = "dev_remote_access_session_id";
|
||||
@@ -441,6 +444,41 @@ public final class AppPreferences {
|
||||
|
||||
public static void setDevBackendNtpSyncEnabled(Context context, boolean enabled) {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_BACKEND_NTP_SYNC, enabled).apply();
|
||||
if (!enabled) {
|
||||
clearDevBackendNtpOffset(context);
|
||||
}
|
||||
}
|
||||
|
||||
public static long getDevBackendNtpOffsetMs(Context context) {
|
||||
return prefs(context).getLong(KEY_DEV_BACKEND_NTP_OFFSET_MS, 0L);
|
||||
}
|
||||
|
||||
public static void setDevBackendNtpOffsetMs(Context context, long offsetMs) {
|
||||
prefs(context).edit().putLong(KEY_DEV_BACKEND_NTP_OFFSET_MS, offsetMs).apply();
|
||||
}
|
||||
|
||||
public static long getDevBackendNtpLastSyncMs(Context context) {
|
||||
return prefs(context).getLong(KEY_DEV_BACKEND_NTP_LAST_SYNC_MS, 0L);
|
||||
}
|
||||
|
||||
public static void setDevBackendNtpLastSyncMs(Context context, long whenMs) {
|
||||
prefs(context).edit().putLong(KEY_DEV_BACKEND_NTP_LAST_SYNC_MS, whenMs).apply();
|
||||
}
|
||||
|
||||
public static long getDevBackendNtpLastSkewMs(Context context) {
|
||||
return prefs(context).getLong(KEY_DEV_BACKEND_NTP_LAST_SKEW_MS, 0L);
|
||||
}
|
||||
|
||||
public static void setDevBackendNtpLastSkewMs(Context context, long skewMs) {
|
||||
prefs(context).edit().putLong(KEY_DEV_BACKEND_NTP_LAST_SKEW_MS, skewMs).apply();
|
||||
}
|
||||
|
||||
public static void clearDevBackendNtpOffset(Context context) {
|
||||
prefs(context).edit()
|
||||
.remove(KEY_DEV_BACKEND_NTP_OFFSET_MS)
|
||||
.remove(KEY_DEV_BACKEND_NTP_LAST_SYNC_MS)
|
||||
.remove(KEY_DEV_BACKEND_NTP_LAST_SKEW_MS)
|
||||
.apply();
|
||||
}
|
||||
|
||||
/** Debug builds: auto re-enable wireless adb after Wi-Fi reconnects. */
|
||||
|
||||
@@ -18,6 +18,7 @@ import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.foxx.androidcast.dev.DevInstallWakeHelper;
|
||||
import com.foxx.androidcast.ota.OtaManifest;
|
||||
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
||||
import com.foxx.androidcast.ota.OtaUpdateService;
|
||||
@@ -54,6 +55,9 @@ public class MainActivity extends DrawerHostActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
if (DevInstallWakeHelper.shouldShowAfterDeploy(getIntent())) {
|
||||
DevInstallWakeHelper.prepareActivityShowOnDeploy(this);
|
||||
}
|
||||
super.onCreate(savedInstanceState);
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
SessionStatsStore.pruneOnAppStart(this);
|
||||
@@ -87,13 +91,33 @@ public class MainActivity extends DrawerHostActivity {
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
if (DevInstallWakeHelper.shouldShowAfterDeploy(intent)) {
|
||||
DevInstallWakeHelper.prepareActivityShowOnDeploy(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (DevInstallWakeHelper.shouldShowAfterDeploy(getIntent())) {
|
||||
DevInstallWakeHelper.prepareActivityShowOnDeploy(this);
|
||||
}
|
||||
PermissionHelper.remindIfMissing(this, false);
|
||||
OtaUpdateCoordinator.requestCheckOnLaunch(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (isFinishing() && DevInstallWakeHelper.shouldShowAfterDeploy(getIntent())) {
|
||||
DevInstallWakeHelper.releaseWakeLock();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.foxx.androidcast.dev;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import com.foxx.androidcast.BuildConfig;
|
||||
|
||||
/**
|
||||
* Debug: wake on {@link DevInstallWakeHelper#ACTION_PREPARE_FOR_DEPLOY} (before adb install)
|
||||
* and relaunch UI on {@link Intent#ACTION_MY_PACKAGE_REPLACED}.
|
||||
*/
|
||||
public final class DebugDeployWakeReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (!BuildConfig.DEBUG || intent == null) {
|
||||
return;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (DevInstallWakeHelper.ACTION_PREPARE_FOR_DEPLOY.equals(action)) {
|
||||
DevInstallWakeHelper.wakeForDeploy(context, "prepare-broadcast");
|
||||
return;
|
||||
}
|
||||
if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
|
||||
DevInstallWakeHelper.wakeForDeploy(context, "package-replaced");
|
||||
DevInstallWakeHelper.launchMainAfterDeploy(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ final class DevAdbConnectHttpServer implements Runnable {
|
||||
"HTTP adb info on :" + DevAdbConnectInfo.HTTP_PORT + "/adb.json");
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try (Socket client = socket.accept()) {
|
||||
client.setSoTimeout(5_000);
|
||||
handleClient(client);
|
||||
} catch (IOException e) {
|
||||
if (!Thread.currentThread().isInterrupted()) {
|
||||
@@ -70,20 +71,11 @@ final class DevAdbConnectHttpServer implements Runnable {
|
||||
}
|
||||
|
||||
private void handleClient(Socket client) throws IOException {
|
||||
byte[] buffer = new byte[512];
|
||||
int read = client.getInputStream().read(buffer);
|
||||
if (read <= 0) {
|
||||
return;
|
||||
}
|
||||
String request = new String(buffer, 0, read, StandardCharsets.US_ASCII);
|
||||
String path = "/";
|
||||
int lineEnd = request.indexOf('\n');
|
||||
String firstLine = lineEnd >= 0 ? request.substring(0, lineEnd) : request;
|
||||
String[] parts = firstLine.split(" ");
|
||||
if (parts.length >= 2) {
|
||||
path = parts[1];
|
||||
}
|
||||
String path = readRequestPath(client);
|
||||
String body = payload.get();
|
||||
if (body == null || body.isEmpty()) {
|
||||
body = "{}";
|
||||
}
|
||||
if (!"/adb.json".equals(path) && !"/".equals(path)) {
|
||||
writeResponse(client, 404, "text/plain", "not found");
|
||||
return;
|
||||
@@ -91,6 +83,22 @@ final class DevAdbConnectHttpServer implements Runnable {
|
||||
writeResponse(client, 200, "application/json; charset=utf-8", body);
|
||||
}
|
||||
|
||||
private static String readRequestPath(Socket client) throws IOException {
|
||||
byte[] buffer = new byte[1024];
|
||||
int read = client.getInputStream().read(buffer);
|
||||
if (read <= 0) {
|
||||
return "/";
|
||||
}
|
||||
String request = new String(buffer, 0, read, StandardCharsets.US_ASCII);
|
||||
int lineEnd = request.indexOf('\n');
|
||||
String firstLine = lineEnd >= 0 ? request.substring(0, lineEnd) : request;
|
||||
String[] parts = firstLine.split(" ");
|
||||
if (parts.length >= 2) {
|
||||
return parts[1].trim();
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
|
||||
private static void writeResponse(Socket client, int code, String contentType, String body)
|
||||
throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -61,6 +61,9 @@ public final class DevAdbConnectInfo {
|
||||
root.put("updated_ms", updatedMs);
|
||||
root.put("ips", toJsonArray(ips));
|
||||
root.put("connect", toJsonArray(connect));
|
||||
root.put("tls_port",
|
||||
findFirstNonLegacyPort(connect));
|
||||
root.put("needs_secure_grant", !secureSettingsGranted);
|
||||
JSONArray commands = new JSONArray();
|
||||
for (String endpoint : connect) {
|
||||
if (!TextUtils.isEmpty(endpoint)) {
|
||||
@@ -68,7 +71,8 @@ public final class DevAdbConnectInfo {
|
||||
}
|
||||
}
|
||||
root.put("commands", commands);
|
||||
root.put("hint", "When connect lines fail, run: adb mdns services");
|
||||
root.put("hint", "Grant WRITE_SECURE_SETTINGS once via USB; use tls_port "
|
||||
+ "(not 5555) on Android 11+. Host: avahi-browse -rpt _adb-tls-connect._tcp");
|
||||
return root.toString(2);
|
||||
} catch (JSONException e) {
|
||||
return "{}";
|
||||
@@ -79,6 +83,31 @@ public final class DevAdbConnectInfo {
|
||||
return "ADB_JSON " + toJson().replace('\n', ' ');
|
||||
}
|
||||
|
||||
private static int findFirstNonLegacyPort(List<String> connect) {
|
||||
for (String endpoint : connect) {
|
||||
if (TextUtils.isEmpty(endpoint) || !endpoint.contains(":")) {
|
||||
continue;
|
||||
}
|
||||
int port = parsePort(endpoint);
|
||||
if (port > 0 && port != LEGACY_TCP_PORT) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int parsePort(String endpoint) {
|
||||
int idx = endpoint.lastIndexOf(':');
|
||||
if (idx < 0) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(endpoint.substring(idx + 1));
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static JSONArray toJsonArray(List<String> values) {
|
||||
JSONArray array = new JSONArray();
|
||||
for (String value : values) {
|
||||
|
||||
117
app/src/main/java/com/foxx/androidcast/dev/DevAdbMdnsProbe.java
Normal file
117
app/src/main/java/com/foxx/androidcast/dev/DevAdbMdnsProbe.java
Normal file
@@ -0,0 +1,117 @@
|
||||
package com.foxx.androidcast.dev;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.nsd.NsdManager;
|
||||
import android.net.nsd.NsdServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Discover local wireless-adb ports via mDNS (_adb-tls-connect._tcp, legacy _adb._tcp). */
|
||||
final class DevAdbMdnsProbe {
|
||||
private static final String[] SERVICE_TYPES = {
|
||||
"_adb-tls-connect._tcp",
|
||||
"_adb._tcp",
|
||||
"_adb-tls-pairing._tcp",
|
||||
};
|
||||
|
||||
private DevAdbMdnsProbe() {}
|
||||
|
||||
static List<Integer> discoverPorts(Context context, long timeoutMs) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
NsdManager nsd = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
|
||||
if (nsd == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<Integer> ports = new LinkedHashSet<>();
|
||||
Set<String> localIps = new LinkedHashSet<>(DevAdbWifiHelper.collectWifiIpv4Addresses(context));
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicBoolean stopped = new AtomicBoolean(false);
|
||||
|
||||
NsdManager.DiscoveryListener listener = new NsdManager.DiscoveryListener() {
|
||||
@Override
|
||||
public void onDiscoveryStarted(String serviceType) {
|
||||
Log.d(DevAdbConnectInfo.LOG_TAG, "mDNS discovery started: " + serviceType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceFound(NsdServiceInfo serviceInfo) {
|
||||
nsd.resolveService(serviceInfo, new NsdManager.ResolveListener() {
|
||||
@Override
|
||||
public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
|
||||
Log.d(DevAdbConnectInfo.LOG_TAG,
|
||||
"mDNS resolve failed code=" + errorCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceResolved(NsdServiceInfo info) {
|
||||
int port = info.getPort();
|
||||
if (port <= 0 || port >= 65536) {
|
||||
return;
|
||||
}
|
||||
String host = info.getHost() != null ? info.getHost().getHostAddress() : "";
|
||||
if (!localIps.isEmpty()
|
||||
&& host != null
|
||||
&& !host.isEmpty()
|
||||
&& !localIps.contains(host)) {
|
||||
return;
|
||||
}
|
||||
ports.add(port);
|
||||
Log.i(DevAdbConnectInfo.LOG_TAG,
|
||||
"mDNS adb port " + port + " type=" + info.getServiceType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceLost(NsdServiceInfo serviceInfo) {}
|
||||
|
||||
@Override
|
||||
public void onDiscoveryStopped(String serviceType) {
|
||||
if (stopped.compareAndSet(false, true)) {
|
||||
done.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
|
||||
Log.d(DevAdbConnectInfo.LOG_TAG,
|
||||
"mDNS start failed type=" + serviceType + " code=" + errorCode);
|
||||
if (stopped.compareAndSet(false, true)) {
|
||||
done.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
|
||||
if (stopped.compareAndSet(false, true)) {
|
||||
done.countDown();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
nsd.discoverServices(SERVICE_TYPES[0], NsdManager.PROTOCOL_DNS_SD, listener);
|
||||
if (!done.await(timeoutMs, TimeUnit.MILLISECONDS)) {
|
||||
Log.d(DevAdbConnectInfo.LOG_TAG, "mDNS discovery timed out");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(listener);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(ports);
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,11 @@ public final class DevAdbWifiHelper {
|
||||
|
||||
List<String> ips = collectWifiIpv4Addresses(app);
|
||||
List<Integer> ports = discoverPorts(app);
|
||||
List<String> connect = DevAdbConnectInfo.mergeEndpoints(ips, ports);
|
||||
ports.addAll(DevAdbMdnsProbe.discoverPorts(app, 2500L));
|
||||
List<String> connect = buildConnectEndpoints(ips, ports);
|
||||
connect.addAll(parseDumpsysEndpoints(runDumpsysAdb(app)));
|
||||
|
||||
List<String> dedupedConnect = dedupe(connect);
|
||||
List<String> dedupedConnect = orderConnectEndpoints(dedupe(connect), ports);
|
||||
long now = System.currentTimeMillis();
|
||||
return new DevAdbConnectInfo(
|
||||
ips,
|
||||
@@ -128,12 +129,19 @@ public final class DevAdbWifiHelper {
|
||||
|
||||
private static List<Integer> discoverPorts(Context context) {
|
||||
Set<Integer> ports = new LinkedHashSet<>();
|
||||
ports.add(DevAdbConnectInfo.LEGACY_TCP_PORT);
|
||||
|
||||
int tlsProp = parsePositiveInt(readSystemProperty("service.adb.tls.port"));
|
||||
if (tlsProp > 0) {
|
||||
ports.add(tlsProp);
|
||||
}
|
||||
int tcpProp = parsePositiveInt(readSystemProperty("service.adb.tcp.port"));
|
||||
if (tcpProp > 0) {
|
||||
ports.add(tcpProp);
|
||||
}
|
||||
int persistTls = parsePositiveInt(readSystemProperty("persist.adb.tls_server.port"));
|
||||
if (persistTls > 0) {
|
||||
ports.add(persistTls);
|
||||
}
|
||||
|
||||
for (String key : new String[] {"adb_wifi_port", "adb_wifi_tls_port", "adb_port"}) {
|
||||
int fromSettings = readGlobalInt(context, key, -1);
|
||||
@@ -146,9 +154,45 @@ public final class DevAdbWifiHelper {
|
||||
ports.add(parsed);
|
||||
}
|
||||
}
|
||||
if (ports.isEmpty()) {
|
||||
ports.add(DevAdbConnectInfo.LEGACY_TCP_PORT);
|
||||
}
|
||||
return new ArrayList<>(ports);
|
||||
}
|
||||
|
||||
private static List<String> buildConnectEndpoints(List<String> ips, List<Integer> ports) {
|
||||
List<String> endpoints = DevAdbConnectInfo.mergeEndpoints(ips, ports);
|
||||
if (endpoints.isEmpty() && !ips.isEmpty()) {
|
||||
endpoints.add(ips.get(0) + ":" + DevAdbConnectInfo.LEGACY_TCP_PORT);
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** Prefer TLS / mDNS ports over legacy :5555 guesses. */
|
||||
private static List<String> orderConnectEndpoints(List<String> endpoints, List<Integer> ports) {
|
||||
boolean hasNonLegacy = false;
|
||||
for (Integer port : ports) {
|
||||
if (port != null && port != DevAdbConnectInfo.LEGACY_TCP_PORT) {
|
||||
hasNonLegacy = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasNonLegacy) {
|
||||
return endpoints;
|
||||
}
|
||||
List<String> preferred = new ArrayList<>();
|
||||
List<String> legacy = new ArrayList<>();
|
||||
for (String endpoint : endpoints) {
|
||||
if (endpoint.endsWith(":" + DevAdbConnectInfo.LEGACY_TCP_PORT)) {
|
||||
legacy.add(endpoint);
|
||||
} else {
|
||||
preferred.add(endpoint);
|
||||
}
|
||||
}
|
||||
preferred.addAll(legacy);
|
||||
return preferred;
|
||||
}
|
||||
|
||||
private static String readSystemProperty(String key) {
|
||||
try {
|
||||
Class<?> clazz = Class.forName("android.os.SystemProperties");
|
||||
|
||||
@@ -147,11 +147,28 @@ public final class DevAdbWifiKeeper {
|
||||
|
||||
private void start() {
|
||||
ensureNotificationChannel();
|
||||
loadCachedJsonIntoHttpServer();
|
||||
httpServer.start();
|
||||
registerNetworkCallback();
|
||||
scheduleRefresh("keeper-start");
|
||||
}
|
||||
|
||||
private void loadCachedJsonIntoHttpServer() {
|
||||
File cached = new File(appContext.getFilesDir(), "dev/adb_connect.json");
|
||||
if (!cached.isFile()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] bytes = java.nio.file.Files.readAllBytes(cached.toPath());
|
||||
String json = new String(bytes, StandardCharsets.UTF_8).trim();
|
||||
if (!json.isEmpty()) {
|
||||
httpServer.updatePayload(json);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d(DevAdbConnectInfo.LOG_TAG, "no cached adb_connect.json", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdown() {
|
||||
unregisterNetworkCallback();
|
||||
httpServer.stop();
|
||||
|
||||
@@ -166,14 +166,39 @@ public final class DevDiagnosticsProbe {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(connectivitySummary(context)).append('\n');
|
||||
String crashUrl = resolveCrashUploadUrl(context);
|
||||
sb.append('\n').append(probeHttp("Crash upload", crashUrl, cancel)).append('\n');
|
||||
String otaUrl = resolveOtaUrl(context);
|
||||
if (!otaUrl.isEmpty()) {
|
||||
sb.append('\n').append(probeHttp("OTA channel", otaUrl, cancel));
|
||||
}
|
||||
sb.append('\n').append(probeHttp("Crash upload", crashUrl, cancel));
|
||||
appendOtaProbe(sb, context, cancel);
|
||||
sb.append('\n').append("-----").append('\n');
|
||||
sb.append(DevVpnStatusProbe.formatForDiagnostics(context));
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
private static void appendOtaProbe(StringBuilder sb, Context context, AtomicBoolean cancel) {
|
||||
String otaUrl = resolveOtaUrl(context);
|
||||
if (otaUrl.isEmpty()) {
|
||||
sb.append('\n').append("OTA channel: (not configured — ok for debug)");
|
||||
return;
|
||||
}
|
||||
String trimmed = otaUrl.trim();
|
||||
String host = null;
|
||||
try {
|
||||
host = Uri.parse(trimmed).getHost();
|
||||
} catch (Exception ignored) {
|
||||
// fall through
|
||||
}
|
||||
if (host == null || host.isEmpty()) {
|
||||
String shown = trimmed.length() > 96 ? trimmed.substring(0, 96) + "…" : trimmed;
|
||||
sb.append('\n').append("OTA channel: (invalid URL — ").append(shown)
|
||||
.append("; ok if OTA unused)");
|
||||
return;
|
||||
}
|
||||
if (cancel != null && cancel.get()) {
|
||||
sb.append('\n').append("OTA channel: stopped");
|
||||
return;
|
||||
}
|
||||
sb.append('\n').append(probeHttp("OTA channel", trimmed, cancel));
|
||||
}
|
||||
|
||||
/** Wire advertisement summary (last card on diagnostics tab). */
|
||||
public static String probeFullAdvertisementInfo(Context context) {
|
||||
CastSettings settings = AppPreferences.loadSenderDefaults(context);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.foxx.androidcast.dev;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.KeyguardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.PowerManager;
|
||||
import android.util.Log;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.foxx.androidcast.BuildConfig;
|
||||
import com.foxx.androidcast.MainActivity;
|
||||
|
||||
/** Debug deploy: wake screen, keep awake briefly, show UI over lockscreen after adb install. */
|
||||
public final class DevInstallWakeHelper {
|
||||
public static final String ACTION_PREPARE_FOR_DEPLOY =
|
||||
"com.foxx.androidcast.dev.PREPARE_FOR_DEPLOY";
|
||||
public static final String EXTRA_AFTER_DEPLOY = "after_deploy";
|
||||
private static final String TAG = "DevDeployWake";
|
||||
private static final long WAKE_MS = 45_000L;
|
||||
|
||||
private static PowerManager.WakeLock deployWakeLock;
|
||||
|
||||
private DevInstallWakeHelper() {}
|
||||
|
||||
/** Called from running app when adb install is about to start (broadcast from rebuild.sh). */
|
||||
public static void wakeForDeploy(Context context, String reason) {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
return;
|
||||
}
|
||||
Context app = context.getApplicationContext();
|
||||
PowerManager pm = app.getSystemService(PowerManager.class);
|
||||
if (pm == null) {
|
||||
return;
|
||||
}
|
||||
releaseWakeLock();
|
||||
int flags = PowerManager.PARTIAL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
@SuppressWarnings("deprecation")
|
||||
int legacy = flags | PowerManager.ON_AFTER_RELEASE;
|
||||
flags = legacy;
|
||||
}
|
||||
deployWakeLock = pm.newWakeLock(flags, "androidcast:deploy");
|
||||
deployWakeLock.acquire(WAKE_MS);
|
||||
Log.i(TAG, "wake for deploy (" + reason + ")");
|
||||
}
|
||||
|
||||
public static void prepareActivityShowOnDeploy(Activity activity) {
|
||||
if (!BuildConfig.DEBUG || activity == null) {
|
||||
return;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
activity.setShowWhenLocked(true);
|
||||
activity.setTurnScreenOn(true);
|
||||
}
|
||||
activity.getWindow().addFlags(
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
|
||||
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
KeyguardManager km = activity.getSystemService(KeyguardManager.class);
|
||||
if (km != null && km.isKeyguardLocked()) {
|
||||
km.requestDismissKeyguard(activity, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void launchMainAfterDeploy(Context context) {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
return;
|
||||
}
|
||||
Intent launch = new Intent(context, MainActivity.class);
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
launch.putExtra(EXTRA_AFTER_DEPLOY, true);
|
||||
context.startActivity(launch);
|
||||
Log.i(TAG, "launch MainActivity after deploy");
|
||||
}
|
||||
|
||||
public static boolean shouldShowAfterDeploy(Intent intent) {
|
||||
return intent != null && intent.getBooleanExtra(EXTRA_AFTER_DEPLOY, false);
|
||||
}
|
||||
|
||||
public static void releaseWakeLock() {
|
||||
PowerManager.WakeLock lock = deployWakeLock;
|
||||
deployWakeLock = null;
|
||||
if (lock != null && lock.isHeld()) {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||
import com.foxx.androidcast.discovery.CastLanPeerProbe;
|
||||
import com.foxx.androidcast.network.BackendEndpoints;
|
||||
import com.foxx.androidcast.network.BackendNtpSync;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
@@ -55,6 +56,7 @@ public final class DevNetworkSelfTestProbe {
|
||||
probeCaptive(caps, cancel, report);
|
||||
probeIntraLan(context, lp, cancel, report);
|
||||
probeBackend(context, cancel, report);
|
||||
DevVpnStatusProbe.probe(context, report);
|
||||
probeNtp(context, cancel, report);
|
||||
probeNatExternal(caps, context, cancel, report);
|
||||
probeMtu(lp, report);
|
||||
@@ -243,10 +245,10 @@ public final class DevNetworkSelfTestProbe {
|
||||
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");
|
||||
r.itemPass("local-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)");
|
||||
"be-ntp-sync", enabled, "enabled (±3 min threshold)", "disabled (dev setting)");
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
@@ -255,22 +257,54 @@ public final class DevNetworkSelfTestProbe {
|
||||
r.itemWarn("be-ntp", "stopped");
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
long clientEpochSec = 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 + "\"}}";
|
||||
String body = "{\"heartbeat\":{\"type\":\"ntp\",\"date\":" + clientEpochSec
|
||||
+ ",\"tz\":\"" + tz + "\",\"time_source\":\"device\"}}";
|
||||
long t0 = System.currentTimeMillis();
|
||||
HttpTextResult text = httpPostJsonText(backend, body);
|
||||
if (httpSuccess(text.code)) {
|
||||
r.itemPass("be-ntp", text.summary);
|
||||
if (text.body != null && !text.body.isEmpty()) {
|
||||
r.itemPass("be-payload", truncate(text.body, 120));
|
||||
long t1 = System.currentTimeMillis();
|
||||
if (!httpSuccess(text.code)) {
|
||||
if (text.code == 404) {
|
||||
r.itemFail("be-ntp", text.summary + " · heartbeat.php missing on host");
|
||||
} else if (text.code > 0) {
|
||||
r.itemFail("be-ntp", text.summary);
|
||||
} else {
|
||||
r.itemFail("be-ntp", text.summary);
|
||||
}
|
||||
} else if (text.code == 404) {
|
||||
r.itemFail("be-ntp", text.summary + " · heartbeat.php missing on host");
|
||||
} else if (text.code > 0) {
|
||||
r.itemFail("be-ntp", text.summary);
|
||||
} else {
|
||||
r.itemFail("be-ntp", text.summary);
|
||||
return;
|
||||
}
|
||||
BackendNtpSync.Result sync = BackendNtpSync.evaluateResponse(
|
||||
context, t0, t1, clientEpochSec, text.body);
|
||||
switch (sync.action) {
|
||||
case SYNCED:
|
||||
r.itemPass("be-ntp", text.summary);
|
||||
r.itemPass("skew", sync.skewMs + " ms");
|
||||
r.itemPass("action", "synced (|skew| > 3 min) · offset=" + sync.appliedOffsetMs + " ms");
|
||||
break;
|
||||
case WITHIN_THRESHOLD:
|
||||
r.itemPass("be-ntp", text.summary);
|
||||
r.itemPass("skew", sync.skewMs + " ms");
|
||||
r.itemPass("action", "within ±3 min · offset=" + sync.appliedOffsetMs + " ms");
|
||||
break;
|
||||
case FAILED:
|
||||
r.itemFail("be-ntp", sync.detail);
|
||||
break;
|
||||
default:
|
||||
r.itemNa("be-ntp", sync.detail);
|
||||
break;
|
||||
}
|
||||
if (!sync.serverWallBeTz.isEmpty()) {
|
||||
r.itemPass("be-wall", sync.serverWallBeTz);
|
||||
}
|
||||
if (!sync.localWallBeTz.isEmpty()) {
|
||||
r.itemPass("local-as-be", sync.localWallBeTz);
|
||||
}
|
||||
if (sync.detail != null && !sync.detail.isEmpty()) {
|
||||
r.itemPass("detail", truncate(sync.detail, 160));
|
||||
}
|
||||
long corrected = BackendNtpSync.correctedNowMs(context);
|
||||
r.itemPass("corrected-utc", Long.toString(corrected / 1000L));
|
||||
}
|
||||
|
||||
private static void probeNatExternal(NetworkCapabilities caps, Context context, AtomicBoolean cancel,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.foxx.androidcast.dev;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.TrafficStats;
|
||||
import android.os.Build;
|
||||
import android.os.Process;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.crash.CrashReporter;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
|
||||
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
|
||||
import com.foxx.androidcast.vpn.VpnServiceClient;
|
||||
import com.foxx.androidcast.vpn.VpnState;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.FileReader;
|
||||
import java.util.Locale;
|
||||
|
||||
/** VPN / remote-access snapshot for diagnostics and network self-test. */
|
||||
public final class DevVpnStatusProbe {
|
||||
private DevVpnStatusProbe() {}
|
||||
|
||||
/** Plain-text VPN section for the diagnostics network card. */
|
||||
public static String formatForDiagnostics(Context context) {
|
||||
DevNetworkSelfTestReport r = new DevNetworkSelfTestReport();
|
||||
probe(context, r);
|
||||
return r.build();
|
||||
}
|
||||
|
||||
public static void probe(Context context, DevNetworkSelfTestReport r) {
|
||||
r.section("VPN", "Remote access / VPN status");
|
||||
RemoteAccessMode mode = AppPreferences.getDevRemoteAccessMode(context);
|
||||
RemoteAccessStatusStore.Snapshot last = RemoteAccessStatusStore.load(context);
|
||||
boolean mainProcess = CrashReporter.isMainProcess(context, CrashReporter.currentProcessName());
|
||||
boolean osVpn = hasOsVpnTransport(context);
|
||||
|
||||
String typeLabel = mode == RemoteAccessMode.DISABLED ? "off" : mode.name().toLowerCase(Locale.US);
|
||||
r.itemPass("type", typeLabel);
|
||||
|
||||
StringBuilder vpnDetailBuf = new StringBuilder();
|
||||
String failReason = last.failReason;
|
||||
String state = resolveState(context, mode, last, mainProcess, osVpn, vpnDetailBuf::append);
|
||||
String vpnDetail = vpnDetailBuf.toString();
|
||||
|
||||
if (mode == RemoteAccessMode.WIREGUARD
|
||||
&& (failReason == null || failReason.isEmpty())
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(state)
|
||||
&& !vpnDetail.isEmpty()) {
|
||||
failReason = vpnDetail;
|
||||
}
|
||||
|
||||
DevNetworkSelfTestReport.Level level;
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
level = DevNetworkSelfTestReport.Level.NA;
|
||||
} else if (RemoteAccessStatusStore.STATE_SUCCESS.equals(state)) {
|
||||
level = DevNetworkSelfTestReport.Level.PASS;
|
||||
} else if (RemoteAccessStatusStore.STATE_FAILURE.equals(state)) {
|
||||
level = DevNetworkSelfTestReport.Level.FAIL;
|
||||
} else {
|
||||
level = DevNetworkSelfTestReport.Level.WARN;
|
||||
}
|
||||
r.item(level, "state", state);
|
||||
r.itemPass("os-vpn-transport", osVpn ? "active" : "none");
|
||||
if (!vpnDetail.isEmpty()) {
|
||||
r.itemPass("detail", vpnDetail);
|
||||
}
|
||||
|
||||
String method = last.method;
|
||||
if (method.isEmpty() && mode == RemoteAccessMode.WIREGUARD) {
|
||||
method = "VpnServiceClient.applyWireGuardConfig";
|
||||
} else if (method.isEmpty() && mode == RemoteAccessMode.RSSH) {
|
||||
method = "ReverseSshTunnelBridge.applyConfig";
|
||||
} else if (method.isEmpty()) {
|
||||
method = "n/a";
|
||||
}
|
||||
r.itemPass("method", method);
|
||||
|
||||
String initiator = formatInitiator(last.initiator, mode);
|
||||
r.itemPass("initiator", initiator);
|
||||
|
||||
if (!last.sessionId.isEmpty()) {
|
||||
r.itemPass("session", last.sessionId);
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_FAILURE.equals(state)
|
||||
&& failReason != null && !failReason.isEmpty()) {
|
||||
r.itemFail("fail-reason", failReason);
|
||||
}
|
||||
|
||||
appendTraffic(r, context);
|
||||
if (last.updatedMs > 0L) {
|
||||
r.itemPass("last-event-ms", Long.toString(last.updatedMs));
|
||||
}
|
||||
if (!mainProcess) {
|
||||
r.itemPass("probe-process", CrashReporter.currentProcessName() + " (status store + OS VPN)");
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveState(
|
||||
Context context,
|
||||
RemoteAccessMode mode,
|
||||
RemoteAccessStatusStore.Snapshot last,
|
||||
boolean mainProcess,
|
||||
boolean osVpn,
|
||||
java.util.function.Consumer<String> detailOut) {
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
return RemoteAccessStatusStore.STATE_OFF;
|
||||
}
|
||||
if (mode == RemoteAccessMode.WIREGUARD) {
|
||||
int live = VpnState.DOWN;
|
||||
String detail = "";
|
||||
if (mainProcess) {
|
||||
VpnServiceClient vpn = VpnServiceClient.get(context);
|
||||
live = vpn.getState();
|
||||
detail = vpn.getStateDetail();
|
||||
}
|
||||
if (detailOut != null && detail != null && !detail.isEmpty()) {
|
||||
detailOut.accept(detail);
|
||||
}
|
||||
if (mainProcess) {
|
||||
if (live == VpnState.UP) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (live == VpnState.ERROR) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
}
|
||||
if (live == VpnState.CONNECTING) {
|
||||
return RemoteAccessStatusStore.STATE_CONNECTING;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state) && osVpn) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)
|
||||
|| RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
return last.state;
|
||||
}
|
||||
return RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state) && osVpn) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (!last.state.isEmpty() && !RemoteAccessStatusStore.STATE_OFF.equals(last.state)) {
|
||||
return last.state;
|
||||
}
|
||||
return osVpn ? RemoteAccessStatusStore.STATE_SUCCESS : RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
|
||||
boolean rsshUp;
|
||||
if (mainProcess) {
|
||||
rsshUp = ReverseSshTunnelBridge.isConnected();
|
||||
} else {
|
||||
rsshUp = RemoteAccessStatusStore.STATE_SUCCESS.equals(last.state);
|
||||
}
|
||||
if (rsshUp) {
|
||||
return RemoteAccessStatusStore.STATE_SUCCESS;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_CONNECTING.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_CONNECTING;
|
||||
}
|
||||
return RemoteAccessStatusStore.STATE_IDLE;
|
||||
}
|
||||
|
||||
private static String formatInitiator(String stored, RemoteAccessMode mode) {
|
||||
if (!stored.isEmpty()) {
|
||||
switch (stored) {
|
||||
case RemoteAccessStatusStore.INITIATOR_DEVICE:
|
||||
return "device (polls BE heartbeat)";
|
||||
case RemoteAccessStatusStore.INITIATOR_BE:
|
||||
return "backend (connect action)";
|
||||
case RemoteAccessStatusStore.INITIATOR_USER:
|
||||
return "user (dev settings)";
|
||||
default:
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
return "n/a";
|
||||
}
|
||||
return "device (heartbeat poll → BE connect)";
|
||||
}
|
||||
|
||||
private static boolean hasOsVpnTransport(Context context) {
|
||||
ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
|
||||
if (cm == null) {
|
||||
return false;
|
||||
}
|
||||
android.net.Network net = cm.getActiveNetwork();
|
||||
NetworkCapabilities caps = net != null ? cm.getNetworkCapabilities(net) : null;
|
||||
return caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
|
||||
}
|
||||
|
||||
private static void appendTraffic(DevNetworkSelfTestReport r, Context context) {
|
||||
int uid = Process.myUid();
|
||||
long rx = TrafficStats.getUidRxBytes(uid);
|
||||
long tx = TrafficStats.getUidTxBytes(uid);
|
||||
if (rx == TrafficStats.UNSUPPORTED || tx == TrafficStats.UNSUPPORTED) {
|
||||
r.itemNa("traffic", "uid stats unsupported");
|
||||
} else {
|
||||
r.itemPass("uid-rx-tx", formatBytes(rx) + " / " + formatBytes(tx));
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
long rxPkt = TrafficStats.getUidRxPackets(uid);
|
||||
long txPkt = TrafficStats.getUidTxPackets(uid);
|
||||
if (rxPkt != TrafficStats.UNSUPPORTED && txPkt != TrafficStats.UNSUPPORTED) {
|
||||
r.itemPass("uid-rx-tx-pkts", rxPkt + " / " + txPkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
String tun = readTunIfaceStats();
|
||||
if (!tun.isEmpty()) {
|
||||
r.itemPass("tun-iface", tun);
|
||||
}
|
||||
}
|
||||
|
||||
private static String formatBytes(long bytes) {
|
||||
if (bytes < 0) {
|
||||
return "?";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return String.format(Locale.US, "%.1f KiB", bytes / 1024.0);
|
||||
}
|
||||
return String.format(Locale.US, "%.1f MiB", bytes / (1024.0 * 1024.0));
|
||||
}
|
||||
|
||||
private static String readTunIfaceStats() {
|
||||
try (BufferedReader br = new BufferedReader(new FileReader("/proc/net/dev"))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.contains(":")) {
|
||||
continue;
|
||||
}
|
||||
String name = trimmed.split(":", 2)[0].trim();
|
||||
if (!name.startsWith("tun") && !name.startsWith("wg")) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split(":\\s*");
|
||||
if (parts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
String[] cols = parts[1].trim().split("\\s+");
|
||||
if (cols.length >= 16) {
|
||||
return name + " rx=" + cols[0] + " tx=" + cols[8] + " bytes";
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Backend heartbeat NTP correction (developer setting).
|
||||
* When |skew| exceeds {@link #SYNC_THRESHOLD_MS}, stores offset for app-local "corrected" time.
|
||||
* Does not change the system clock.
|
||||
*/
|
||||
public final class BackendNtpSync {
|
||||
/** ±3 minutes — sync when skew exceeds this. */
|
||||
public static final long SYNC_THRESHOLD_MS = 3L * 60L * 1000L;
|
||||
|
||||
private static final Pattern TZ_OFFSET = Pattern.compile("([+-])(\\d{2})(\\d{2})");
|
||||
|
||||
private BackendNtpSync() {}
|
||||
|
||||
public enum Action {
|
||||
DISABLED,
|
||||
WITHIN_THRESHOLD,
|
||||
SYNCED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
public static final class Result {
|
||||
public final Action action;
|
||||
public final long skewMs;
|
||||
public final long appliedOffsetMs;
|
||||
public final String serverWallBeTz;
|
||||
public final String localWallBeTz;
|
||||
public final String detail;
|
||||
|
||||
Result(Action action, long skewMs, long appliedOffsetMs,
|
||||
String serverWallBeTz, String localWallBeTz, String detail) {
|
||||
this.action = action;
|
||||
this.skewMs = skewMs;
|
||||
this.appliedOffsetMs = appliedOffsetMs;
|
||||
this.serverWallBeTz = serverWallBeTz;
|
||||
this.localWallBeTz = localWallBeTz;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/** Corrected wall time for diagnostics when sync is active. */
|
||||
public static long correctedNowMs(android.content.Context context) {
|
||||
if (!AppPreferences.isDevBackendNtpSyncEnabled(context)) {
|
||||
return System.currentTimeMillis();
|
||||
}
|
||||
return System.currentTimeMillis() + AppPreferences.getDevBackendNtpOffsetMs(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param requestStartMs wall clock when POST started
|
||||
* @param requestEndMs wall clock when response body was read
|
||||
* @param clientEpochSec epoch seconds sent in heartbeat.date
|
||||
*/
|
||||
public static Result evaluateResponse(
|
||||
android.content.Context context,
|
||||
long requestStartMs,
|
||||
long requestEndMs,
|
||||
long clientEpochSec,
|
||||
String responseBody) {
|
||||
if (!AppPreferences.isDevBackendNtpSyncEnabled(context)) {
|
||||
return new Result(Action.DISABLED, 0L, 0L, "", "", "dev setting off");
|
||||
}
|
||||
try {
|
||||
JSONObject root = new JSONObject(responseBody != null ? responseBody : "{}");
|
||||
JSONObject hb = root.optJSONObject("heartbeat");
|
||||
if (hb == null) {
|
||||
return fail("missing heartbeat object");
|
||||
}
|
||||
long serverEpochSec = hb.optLong("server_date", 0L);
|
||||
String serverTz = hb.optString("server_tz", "");
|
||||
if (serverEpochSec <= 0L) {
|
||||
return fail("missing server_date");
|
||||
}
|
||||
TimeZone beTz = parseBeTimeZone(serverTz);
|
||||
long skewMs = computeSkewMs(requestStartMs, requestEndMs, serverEpochSec);
|
||||
long serverMs = serverEpochSec * 1000L;
|
||||
long localMidMs = requestStartMs + Math.max(0L, requestEndMs - requestStartMs) / 2L;
|
||||
|
||||
long correctionFromBe = hb.has("correction_seconds")
|
||||
? hb.optLong("correction_seconds", 0L) * 1000L
|
||||
: (serverEpochSec - clientEpochSec) * 1000L;
|
||||
|
||||
String serverWall = formatWallClock(serverMs, beTz);
|
||||
String localWallInBeTz = formatWallClock(localMidMs, beTz);
|
||||
String summary = String.format(Locale.US,
|
||||
"skew=%d ms (BE correction=%d ms) · BE %s · local→BE %s",
|
||||
skewMs, correctionFromBe, serverWall, localWallInBeTz);
|
||||
|
||||
if (Math.abs(skewMs) > SYNC_THRESHOLD_MS) {
|
||||
AppPreferences.setDevBackendNtpOffsetMs(context, skewMs);
|
||||
AppPreferences.setDevBackendNtpLastSyncMs(context, System.currentTimeMillis());
|
||||
AppPreferences.setDevBackendNtpLastSkewMs(context, skewMs);
|
||||
return new Result(Action.SYNCED, skewMs, skewMs,
|
||||
serverWall, localWallInBeTz, summary + " · offset stored");
|
||||
}
|
||||
AppPreferences.setDevBackendNtpLastSkewMs(context, skewMs);
|
||||
return new Result(Action.WITHIN_THRESHOLD, skewMs,
|
||||
AppPreferences.getDevBackendNtpOffsetMs(context),
|
||||
serverWall, localWallInBeTz, summary + " · within ±3 min");
|
||||
} catch (Exception e) {
|
||||
return fail(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Result fail(String detail) {
|
||||
return new Result(Action.FAILED, 0L, 0L, "", "", detail);
|
||||
}
|
||||
|
||||
static long computeSkewMs(long requestStartMs, long requestEndMs, long serverEpochSec) {
|
||||
long serverMs = serverEpochSec * 1000L;
|
||||
long localMidMs = requestStartMs + Math.max(0L, requestEndMs - requestStartMs) / 2L;
|
||||
return serverMs - localMidMs;
|
||||
}
|
||||
|
||||
static boolean shouldSync(long skewMs) {
|
||||
return Math.abs(skewMs) > SYNC_THRESHOLD_MS;
|
||||
}
|
||||
|
||||
static TimeZone parseBeTimeZone(String serverTz) {
|
||||
if (serverTz == null || serverTz.isEmpty()) {
|
||||
return TimeZone.getDefault();
|
||||
}
|
||||
Matcher m = TZ_OFFSET.matcher(serverTz.trim());
|
||||
if (!m.matches()) {
|
||||
return TimeZone.getDefault();
|
||||
}
|
||||
String sign = m.group(1);
|
||||
int hours = Integer.parseInt(m.group(2));
|
||||
int mins = Integer.parseInt(m.group(3));
|
||||
String id = String.format(Locale.US, "GMT%s%02d:%02d", sign, hours, mins);
|
||||
return TimeZone.getTimeZone(id);
|
||||
}
|
||||
|
||||
private static String formatWallClock(long epochMs, TimeZone tz) {
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
|
||||
fmt.setTimeZone(tz);
|
||||
return fmt.format(new Date(epochMs));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,14 @@ public final class RemoteAccessCoordinator {
|
||||
RemoteAccessService.stop(app);
|
||||
VpnServiceClient.get(app).tearDown();
|
||||
AppPreferences.clearDevRemoteAccessSession(app);
|
||||
RemoteAccessStatusStore.record(
|
||||
app,
|
||||
RemoteAccessMode.DISABLED,
|
||||
RemoteAccessStatusStore.STATE_OFF,
|
||||
"RemoteAccessCoordinator.applyMode",
|
||||
RemoteAccessStatusStore.INITIATOR_USER,
|
||||
"",
|
||||
"");
|
||||
return;
|
||||
}
|
||||
if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH) {
|
||||
|
||||
@@ -147,16 +147,37 @@ public final class RemoteAccessService extends Service {
|
||||
|
||||
JSONObject resp = RemoteAccessHeartbeatClient.postRa(this, hb);
|
||||
if (resp == null) {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"RemoteAccessHeartbeatClient.postRa",
|
||||
RemoteAccessStatusStore.INITIATOR_DEVICE,
|
||||
"no response from BE", "");
|
||||
return;
|
||||
}
|
||||
String raAction = resp.optString("action", "wait");
|
||||
if ("connect".equals(raAction)) {
|
||||
handleConnect(resp);
|
||||
handleConnect(mode, resp);
|
||||
} else if ("disabled".equals(raAction) || "deny".equals(raAction)) {
|
||||
RemoteAccessTunnelHelper.tearDownAll(this);
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_OFF,
|
||||
"RemoteAccessService.runPoll",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
raAction, resp.optString("session_id", ""));
|
||||
} else {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_IDLE,
|
||||
"RemoteAccessService.runPoll",
|
||||
RemoteAccessStatusStore.INITIATOR_DEVICE,
|
||||
"", resp.optString("session_id", ""));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "poll failed: " + e.getMessage());
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"RemoteAccessService.runPoll",
|
||||
RemoteAccessStatusStore.INITIATOR_DEVICE,
|
||||
e.getMessage(), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +196,7 @@ public final class RemoteAccessService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleConnect(JSONObject resp) {
|
||||
private void handleConnect(RemoteAccessMode mode, JSONObject resp) {
|
||||
String tunnel = resp.optString("tunnel", "");
|
||||
String sessionId = resp.optString("session_id", "");
|
||||
long expiresAt = resp.optLong("expires_at", 0L);
|
||||
@@ -184,31 +205,77 @@ public final class RemoteAccessService extends Service {
|
||||
}
|
||||
JSONObject creds = resp.optJSONObject("credentials");
|
||||
if (creds == null) {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"RemoteAccessService.handleConnect",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"connect without credentials", sessionId);
|
||||
return;
|
||||
}
|
||||
if ("wireguard".equals(tunnel)) {
|
||||
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||
if (wgConfig.isEmpty()) {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"VpnServiceClient.applyWireGuardConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"empty wireguard config", sessionId);
|
||||
return;
|
||||
}
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_CONNECTING,
|
||||
"VpnServiceClient.applyWireGuardConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"", sessionId);
|
||||
final String sid = sessionId;
|
||||
VpnServiceClient.get(this).applyWireGuardConfig(wgConfig, new VpnServiceClient.OperationListener() {
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
updateNotificationConnected(sid, tunnel);
|
||||
RemoteAccessStatusStore.record(
|
||||
RemoteAccessService.this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
|
||||
"VpnServiceClient.applyWireGuardConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"", sid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String reason) {
|
||||
Log.w(TAG, "wireguard connect failed: " + reason);
|
||||
RemoteAccessStatusStore.record(
|
||||
RemoteAccessService.this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"VpnServiceClient.applyWireGuardConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
reason, sid);
|
||||
}
|
||||
});
|
||||
} else if ("ssh_reverse".equals(tunnel)) {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_CONNECTING,
|
||||
"ReverseSshTunnelBridge.applyConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"", sessionId);
|
||||
if (ReverseSshTunnelBridge.applyConfig(this, creds)) {
|
||||
updateNotificationConnected(sessionId, tunnel);
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
|
||||
"ReverseSshTunnelBridge.applyConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"", sessionId);
|
||||
} else {
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"ReverseSshTunnelBridge.applyConfig",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"RSSH connect failed or timed out", sessionId);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "unsupported tunnel in connect: " + tunnel);
|
||||
RemoteAccessStatusStore.record(
|
||||
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
|
||||
"RemoteAccessService.handleConnect",
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"unsupported tunnel: " + tunnel, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.foxx.androidcast.remoteaccess;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* Last remote-access / VPN tunnel event for developer network self-test.
|
||||
*/
|
||||
public final class RemoteAccessStatusStore {
|
||||
private static final String PREFS = "androidcast_remote_access_status";
|
||||
private static final String KEY_TYPE = "tunnel_type";
|
||||
private static final String KEY_STATE = "state";
|
||||
private static final String KEY_METHOD = "method";
|
||||
private static final String KEY_INITIATOR = "initiator";
|
||||
private static final String KEY_FAIL_REASON = "fail_reason";
|
||||
private static final String KEY_UPDATED_MS = "updated_ms";
|
||||
private static final String KEY_SESSION_ID = "session_id";
|
||||
|
||||
public static final String STATE_SUCCESS = "success";
|
||||
public static final String STATE_FAILURE = "failure";
|
||||
public static final String STATE_CONNECTING = "connecting";
|
||||
public static final String STATE_IDLE = "idle";
|
||||
public static final String STATE_OFF = "off";
|
||||
|
||||
public static final String INITIATOR_DEVICE = "device";
|
||||
public static final String INITIATOR_BE = "backend";
|
||||
public static final String INITIATOR_USER = "user";
|
||||
|
||||
private RemoteAccessStatusStore() {}
|
||||
|
||||
public static void record(
|
||||
Context context,
|
||||
RemoteAccessMode mode,
|
||||
String state,
|
||||
String method,
|
||||
String initiator,
|
||||
String failReason,
|
||||
String sessionId) {
|
||||
String type = mode == null || mode == RemoteAccessMode.DISABLED
|
||||
? "off" : mode.name().toLowerCase();
|
||||
prefs(context).edit()
|
||||
.putString(KEY_TYPE, type)
|
||||
.putString(KEY_STATE, state != null ? state : STATE_IDLE)
|
||||
.putString(KEY_METHOD, method != null ? method : "")
|
||||
.putString(KEY_INITIATOR, initiator != null ? initiator : "")
|
||||
.putString(KEY_FAIL_REASON, failReason != null ? failReason : "")
|
||||
.putString(KEY_SESSION_ID, sessionId != null ? sessionId : "")
|
||||
.putLong(KEY_UPDATED_MS, System.currentTimeMillis())
|
||||
.apply();
|
||||
}
|
||||
|
||||
public static Snapshot load(Context context) {
|
||||
SharedPreferences p = prefs(context);
|
||||
return new Snapshot(
|
||||
p.getString(KEY_TYPE, "off"),
|
||||
p.getString(KEY_STATE, STATE_OFF),
|
||||
p.getString(KEY_METHOD, ""),
|
||||
p.getString(KEY_INITIATOR, ""),
|
||||
p.getString(KEY_FAIL_REASON, ""),
|
||||
p.getString(KEY_SESSION_ID, ""),
|
||||
p.getLong(KEY_UPDATED_MS, 0L));
|
||||
}
|
||||
|
||||
private static SharedPreferences prefs(Context context) {
|
||||
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public static final class Snapshot {
|
||||
public final String type;
|
||||
public final String state;
|
||||
public final String method;
|
||||
public final String initiator;
|
||||
public final String failReason;
|
||||
public final String sessionId;
|
||||
public final long updatedMs;
|
||||
|
||||
Snapshot(
|
||||
String type,
|
||||
String state,
|
||||
String method,
|
||||
String initiator,
|
||||
String failReason,
|
||||
String sessionId,
|
||||
long updatedMs) {
|
||||
this.type = type != null ? type : "off";
|
||||
this.state = state != null ? state : STATE_OFF;
|
||||
this.method = method != null ? method : "";
|
||||
this.initiator = initiator != null ? initiator : "";
|
||||
this.failReason = failReason != null ? failReason : "";
|
||||
this.sessionId = sessionId != null ? sessionId : "";
|
||||
this.updatedMs = updatedMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,13 @@ public final class AndroidCastVpnService extends Service {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStateDetail() {
|
||||
synchronized (stateLock) {
|
||||
return stateDetail != null ? stateDetail : "";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVpnPermissionGranted() {
|
||||
return VpnService.prepare(AndroidCastVpnService.this) == null;
|
||||
|
||||
@@ -108,6 +108,18 @@ public final class VpnServiceClient {
|
||||
}
|
||||
}
|
||||
|
||||
public String getStateDetail() {
|
||||
if (!ensureBoundSync()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
String detail = service.getStateDetail();
|
||||
return detail != null ? detail : "";
|
||||
} catch (RemoteException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public void registerStateListener(StateListener listener) {
|
||||
runWithService(s -> {
|
||||
IVpnStateCallback cb = new IVpnStateCallback.Stub() {
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
<string name="dev_adb_wifi_status_line">adb connect %1$s</string>
|
||||
<string name="dev_adb_wifi_status_detail">HTTP :5039/adb.json · secure=%1$s · wifi adb=%2$s · %3$s</string>
|
||||
<string name="dev_adb_wifi_copied">Copied adb connect line</string>
|
||||
<string name="dev_adb_wifi_hint">One-time (USB): adb shell pm grant com.foxx.androidcast android.permission.WRITE_SECURE_SETTINGS — optional adb shell pm grant … android.permission.DUMP for port discovery. Pair wireless debugging once in system settings. rebuild.sh curls http://DEVICE:5039/adb.json when adb is down.</string>
|
||||
<string name="dev_adb_wifi_hint">One-time (USB): ./scripts/grant-dev-adb-perms.sh — enables auto re-enable after Wi‑Fi drops and TLS port discovery. Pair wireless debugging once in system settings (Android 11+ uses a random TLS port, not 5555). rebuild.sh uses curl :5039/adb.json and avahi-browse _adb-tls-connect._tcp.</string>
|
||||
<string name="dev_adb_wifi_notification_channel">ADB Wi‑Fi keeper</string>
|
||||
<string name="dev_adb_wifi_notification_channel_desc">Shows adb connect line while debugging on flaky Wi‑Fi.</string>
|
||||
<string name="dev_adb_wifi_notification_title">ADB Wi‑Fi ready</string>
|
||||
@@ -278,7 +278,7 @@
|
||||
<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_backend_ntp_sync_hint">Disabled by default. Network self-test compares device time to BE (shown in BE timezone). If skew exceeds ±3 minutes, stores a correction offset for app diagnostics — does not change the system clock.</string>
|
||||
<string name="dev_remote_access_mode">Remote access (on-demand debug)</string>
|
||||
<string name="dev_remote_access_disabled">Disabled</string>
|
||||
<string name="dev_remote_access_wireguard">WireGuard</string>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class BackendNtpSyncTest {
|
||||
@Test
|
||||
public void parseBeTimeZone_parsesPlus0200() {
|
||||
TimeZone tz = BackendNtpSync.parseBeTimeZone("+0200");
|
||||
assertEquals(2 * 60 * 60 * 1000, tz.getOffset(0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSync_trueAboveThreeMinutes() {
|
||||
assertTrue(BackendNtpSync.shouldSync(BackendNtpSync.SYNC_THRESHOLD_MS + 1L));
|
||||
assertTrue(BackendNtpSync.shouldSync(-BackendNtpSync.SYNC_THRESHOLD_MS - 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSync_falseWithinThreeMinutes() {
|
||||
assertFalse(BackendNtpSync.shouldSync(BackendNtpSync.SYNC_THRESHOLD_MS));
|
||||
assertFalse(BackendNtpSync.shouldSync(30_000L));
|
||||
assertFalse(BackendNtpSync.shouldSync(-120_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computeSkewMs_usesRequestMidpoint() {
|
||||
long skew = BackendNtpSync.computeSkewMs(1_000L, 1_200L, 2L);
|
||||
assertEquals(2_000L - 1_100L, skew);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user