1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 08:57:44 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-11 18:37:48 +02:00
parent 0431bafd74
commit 9d9a6d66d9
87 changed files with 4443 additions and 1190 deletions

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

@@ -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) {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 WiFi 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 WiFi keeper</string>
<string name="dev_adb_wifi_notification_channel_desc">Shows adb connect line while debugging on flaky WiFi.</string>
<string name="dev_adb_wifi_notification_title">ADB WiFi 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>

View File

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