mirror of
git://f0xx.org/android_cast
synced 2026-07-29 08:19:00 +03:00
BE sync
This commit is contained in:
@@ -131,6 +131,8 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
|
||||
implementation 'com.github.mwiede:jsch:0.2.21'
|
||||
|
||||
if (project.findProject(':tunnel') != null) {
|
||||
implementation project(':tunnel')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2>Android Cast</h2>
|
||||
<h2>Android Cast (application)</h2>
|
||||
<p>Copyright © Android Cast contributors.</p>
|
||||
<p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
|
||||
<ol>
|
||||
@@ -8,25 +8,29 @@
|
||||
</ol>
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
|
||||
<h2>AndroidX AppCompat</h2>
|
||||
<p>Licensed under the <b>Apache License, Version 2.0</b>.</p>
|
||||
<p>Copyright The Android Open Source Project.</p>
|
||||
|
||||
<h2>Google Material Components for Android</h2>
|
||||
<p>Licensed under the <b>Apache License, Version 2.0</b>.</p>
|
||||
|
||||
<h2>AndroidX ConstraintLayout</h2>
|
||||
<p>Licensed under the <b>Apache License, Version 2.0</b>.</p>
|
||||
|
||||
<h2>Google Play services Cronet</h2>
|
||||
<p>Licensed under the <b>BSD 3-Clause License</b> (Chromium Cronet).</p>
|
||||
<h2>Android Open Source components (Gradle)</h2>
|
||||
<ul>
|
||||
<li><b>AndroidX AppCompat</b> — Apache License 2.0</li>
|
||||
<li><b>AndroidX ConstraintLayout</b> — Apache License 2.0</li>
|
||||
<li><b>AndroidX RecyclerView</b> — Apache License 2.0</li>
|
||||
<li><b>Google Material Components for Android</b> — Apache License 2.0</li>
|
||||
<li><b>Google Play services Cronet</b> — BSD 3-Clause (Chromium Cronet)</li>
|
||||
<li><b>Google Play Billing Library</b> — Google Play SDK terms (proprietary; see Google Play Console / developer policies)</li>
|
||||
<li><b>Google Play In-App Review</b> — Google Play SDK terms</li>
|
||||
<li><b>Eclipse Paho MQTT client</b> — Eclipse Public License 1.0 and Eclipse Distribution License 1.0 (dual-licensed)</li>
|
||||
<li><b>JSch (mwiede fork)</b> — BSD 3-Clause License (reverse SSH remote access)</li>
|
||||
</ul>
|
||||
|
||||
<h2>JUnit 4</h2>
|
||||
<p>Licensed under the <b>Eclipse Public License 1.0</b> (test dependency only).</p>
|
||||
<p>Licensed under the <b>Eclipse Public License 1.0</b> (test dependency only; not shipped in release APK).</p>
|
||||
|
||||
<h2>Future components (not yet bundled)</h2>
|
||||
<h2>Native codecs (optional; linked when built)</h2>
|
||||
<p>Built from git submodules under <code>third-party/</code> via <code>scripts/build-native-codecs.sh</code>. CMake sets <code>ANDROIDCAST_HAVE_*</code> when <code>build/native/<abi>/lib*.a</code> exists for each ABI in the APK. JNI encode/decode sinks may still be partial while libraries are linked.</p>
|
||||
<ul>
|
||||
<li><b>libvpx</b> — BSD-style license (VP8/VP9 software codec, planned).</li>
|
||||
<li><b>Speex</b> — revised BSD license (speech codec, planned).</li>
|
||||
<li><b>Opus</b> — BSD-style license (speech codec with in-band FEC, planned).</li>
|
||||
<li><b>libvpx</b> (VP8/VP9) — BSD-style license (Xiph.org). See <code>third-party/libvpx/LICENSE</code>.</li>
|
||||
<li><b>Opus</b> — BSD-style license (Xiph.org et al.). See <code>third-party/opus/COPYING</code>.</li>
|
||||
<li><b>Speex</b> — Revised BSD license (Xiph.org Foundation). See <code>third-party/speex/COPYING</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>WireGuard tunnel module (optional Gradle module)</h2>
|
||||
<p>When <code>third-party/wireguard-android</code> is initialized and the <code>:tunnel</code> module is included, the app may link the WireGuard Android tunnel library (Apache License 2.0; see upstream <code>wireguard-android</code>). Remote access still uses Android <code>VpnService</code> (system VPN permission).</p>
|
||||
|
||||
@@ -36,6 +36,7 @@ public final class AppPreferences {
|
||||
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
|
||||
private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay";
|
||||
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
|
||||
private static final String KEY_DEV_STREAM_DUMP = "dev_stream_dump";
|
||||
private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs";
|
||||
private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores";
|
||||
private static final String KEY_DEV_RECEIVER_VIDEO_PRESET = "dev_receiver_video_preset";
|
||||
@@ -160,6 +161,15 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_GRAB_SESSION_STATS, grab).apply();
|
||||
}
|
||||
|
||||
/** Developer: record elementary cast streams to zip (FR 0.1; default off). */
|
||||
public static boolean isDevStreamDumpEnabled(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_STREAM_DUMP, false);
|
||||
}
|
||||
|
||||
public static void setDevStreamDumpEnabled(Context context, boolean enabled) {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_STREAM_DUMP, enabled).apply();
|
||||
}
|
||||
|
||||
/** Send anonymous crash reports via the standalone crash watcher process (default on). */
|
||||
public static boolean isSendAnonymousCrashLogs(Context context) {
|
||||
return prefs(context).getBoolean(KEY_SEND_ANONYMOUS_CRASH_LOGS, true);
|
||||
|
||||
@@ -83,10 +83,11 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
|
||||
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
|
||||
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
|
||||
CheckBox streamDump = findViewById(R.id.check_dev_stream_dump);
|
||||
CheckBox adaptiveDisplay = findViewById(R.id.check_receiver_adaptive_display);
|
||||
CheckBox backendNtpSync = findViewById(R.id.check_dev_backend_ntp_sync);
|
||||
|
||||
refreshFromPreferences(debugOverlay, codecScores, sessionStats, adaptiveDisplay, backendNtpSync);
|
||||
refreshFromPreferences(debugOverlay, codecScores, sessionStats, streamDump, adaptiveDisplay, backendNtpSync);
|
||||
|
||||
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
|
||||
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
||||
@@ -144,6 +145,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
findViewById(R.id.check_receiver_debug_overlay),
|
||||
findViewById(R.id.check_show_codec_priority_scores),
|
||||
findViewById(R.id.check_grab_session_stats),
|
||||
findViewById(R.id.check_dev_stream_dump),
|
||||
findViewById(R.id.check_receiver_adaptive_display),
|
||||
findViewById(R.id.check_dev_backend_ntp_sync));
|
||||
bindDiagPingSection();
|
||||
@@ -167,7 +169,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
String[] labels = new String[]{
|
||||
getString(R.string.dev_remote_access_disabled),
|
||||
getString(R.string.dev_remote_access_wireguard),
|
||||
getString(R.string.dev_remote_access_rssh_unavailable),
|
||||
getString(R.string.dev_remote_access_rssh),
|
||||
};
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||
android.R.layout.simple_spinner_item, labels);
|
||||
@@ -186,15 +188,6 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
RemoteAccessMode picked = modes[position];
|
||||
if (picked == RemoteAccessMode.RSSH) {
|
||||
Toast.makeText(DeveloperSettingsActivity.this,
|
||||
R.string.dev_remote_access_rssh_toast, Toast.LENGTH_SHORT).show();
|
||||
suppressRemoteAccessSpinnerEvents = true;
|
||||
remoteAccessSpinner.setSelection(
|
||||
AppPreferences.getDevRemoteAccessMode(DeveloperSettingsActivity.this).ordinal(), false);
|
||||
suppressRemoteAccessSpinnerEvents = false;
|
||||
return;
|
||||
}
|
||||
applyRemoteAccessMode(picked);
|
||||
}
|
||||
|
||||
@@ -518,13 +511,15 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats,
|
||||
CheckBox adaptiveDisplay, CheckBox backendNtpSync) {
|
||||
CheckBox streamDump, CheckBox adaptiveDisplay, CheckBox backendNtpSync) {
|
||||
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
|
||||
AppPreferences::setShowReceiverDebugOverlay);
|
||||
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
|
||||
AppPreferences::setShowCodecPriorityScores);
|
||||
bindDeveloperCheckbox(sessionStats, AppPreferences.isGrabSessionStats(this),
|
||||
AppPreferences::setGrabSessionStats);
|
||||
bindDeveloperCheckbox(streamDump, AppPreferences.isDevStreamDumpEnabled(this),
|
||||
AppPreferences::setDevStreamDumpEnabled);
|
||||
bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this),
|
||||
AppPreferences::setDevReceiverAdaptiveDisplay);
|
||||
bindDeveloperCheckbox(backendNtpSync, AppPreferences.isDevBackendNtpSyncEnabled(this),
|
||||
|
||||
@@ -14,6 +14,7 @@ package com.foxx.androidcast.crash;
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.network.BackendEndpoints;
|
||||
import com.foxx.androidcast.settings.AppSettingsJson;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -39,7 +40,7 @@ public final class CrashSettingsStore {
|
||||
}
|
||||
String url = crash.optString("upload_url", "").trim();
|
||||
if (!url.isEmpty()) {
|
||||
uploadUrl = url;
|
||||
uploadUrl = BackendEndpoints.normalizeCrashesUrl(url);
|
||||
}
|
||||
if (crash.has("prune_after_upload")) {
|
||||
prune = parseBool(crash.optString("prune_after_upload", "true"), prune);
|
||||
|
||||
@@ -26,7 +26,8 @@ public final class DevDiagnosticsController {
|
||||
AUDIO(R.id.dev_diag_audio, R.string.dev_diag_audio_title),
|
||||
AUDIO_CODECS(R.id.dev_diag_audio_codecs, R.string.dev_diag_audio_codecs_title),
|
||||
NETWORK(R.id.dev_diag_network, R.string.dev_diag_network_title),
|
||||
CASTING(R.id.dev_diag_casting, R.string.dev_diag_casting_title);
|
||||
CASTING(R.id.dev_diag_casting, R.string.dev_diag_casting_title),
|
||||
ADVERTISEMENT(R.id.dev_diag_advertisement, R.string.dev_diag_advertisement_title);
|
||||
|
||||
final int includeId;
|
||||
final int titleRes;
|
||||
@@ -107,6 +108,8 @@ public final class DevDiagnosticsController {
|
||||
DevDiagnosticsProbe.probeNetwork(activity, cancel), sectionText);
|
||||
runSection(Section.CASTING, () ->
|
||||
DevDiagnosticsProbe.probeCastingExtras(activity), sectionText);
|
||||
runSection(Section.ADVERTISEMENT, () ->
|
||||
DevDiagnosticsProbe.probeFullAdvertisementInfo(activity), sectionText);
|
||||
} finally {
|
||||
if (!cancel.get()) {
|
||||
lastReport = new DevDiagnosticsReport(System.currentTimeMillis(), sectionText);
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
import com.foxx.androidcast.media.CodecTier;
|
||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||
import com.foxx.androidcast.network.CastCodecFlags;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionCapability;
|
||||
import com.foxx.androidcast.sender.CodecCatalog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -53,9 +54,9 @@ public final class DevDiagnosticsProbe {
|
||||
sb.append("API ").append(Build.VERSION.SDK_INT).append('\n');
|
||||
sb.append("Native JNI probes:\n");
|
||||
sb.append(" libvpx: ").append(NativeCodecBridge.isLibvpxAvailable() ? "yes" : "no").append('\n');
|
||||
sb.append(" Opus: ").append(NativeCodecBridge.isOpusAvailable() ? "yes (native)" : "no (stub→AAC)")
|
||||
sb.append(" Opus: ").append(NativeCodecBridge.isOpusAvailable() ? "yes (lib linked)" : "no (stub→AAC)")
|
||||
.append('\n');
|
||||
sb.append(" Speex: ").append(NativeCodecBridge.isSpeexAvailable() ? "yes (native)" : "no (stub→AAC)")
|
||||
sb.append(" Speex: ").append(NativeCodecBridge.isSpeexAvailable() ? "yes (lib linked)" : "no (stub→AAC)")
|
||||
.append('\n');
|
||||
sb.append('\n').append("Video encoders (MediaCodec):\n");
|
||||
appendVideoCodecLines(sb, true, settings);
|
||||
@@ -173,6 +174,27 @@ public final class DevDiagnosticsProbe {
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/** Wire advertisement summary (last card on diagnostics tab). */
|
||||
public static String probeFullAdvertisementInfo(Context context) {
|
||||
CastSettings settings = AppPreferences.loadSenderDefaults(context);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("[audio adv] ").append(join(CodecCatalog.localAdvertisedAudioCodecsInOrder(settings)))
|
||||
.append(" · mask 0x")
|
||||
.append(Integer.toHexString(CastCodecFlags.localAudioMask()))
|
||||
.append('\n');
|
||||
List<String> videoMimes = CodecCatalog.localAdvertisedVideoMimesInOrder();
|
||||
sb.append("[video adv] ").append(join(videoMimes));
|
||||
List<String> videoCodecs = CodecCatalog.localAdvertisedVideoCodecsInOrder(settings);
|
||||
if (!videoCodecs.isEmpty()) {
|
||||
sb.append(" (").append(join(videoCodecs)).append(')');
|
||||
}
|
||||
sb.append('\n');
|
||||
sb.append("[protection adv] ").append(join(StreamProtectionCapability.advertisedProtectionInOrder()))
|
||||
.append('\n');
|
||||
sb.append("-----");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String probeCastingExtras(Context context) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
PackageManager pm = context.getPackageManager();
|
||||
|
||||
@@ -12,8 +12,10 @@ import android.os.PowerManager;
|
||||
import android.telephony.TelephonyManager;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.BuildConfig;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||
import com.foxx.androidcast.discovery.CastLanPeerProbe;
|
||||
import com.foxx.androidcast.network.BackendEndpoints;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
@@ -51,7 +53,7 @@ public final class DevNetworkSelfTestProbe {
|
||||
probeOverall(context, caps, cancel, report);
|
||||
probeLink(context, caps, lp, report);
|
||||
probeCaptive(caps, cancel, report);
|
||||
probeIntraLan(lp, cancel, report);
|
||||
probeIntraLan(context, lp, cancel, report);
|
||||
probeBackend(context, cancel, report);
|
||||
probeNtp(context, cancel, report);
|
||||
probeNatExternal(caps, context, cancel, report);
|
||||
@@ -186,7 +188,8 @@ public final class DevNetworkSelfTestProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private static void probeIntraLan(LinkProperties lp, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
|
||||
private static void probeIntraLan(Context context, LinkProperties lp, AtomicBoolean cancel,
|
||||
DevNetworkSelfTestReport r) {
|
||||
r.section("LAN", "Intra-network");
|
||||
String ip = firstIpv4Host(lp);
|
||||
if (ip == null) {
|
||||
@@ -203,62 +206,36 @@ public final class DevNetworkSelfTestProbe {
|
||||
return;
|
||||
}
|
||||
String prefix = parts[0] + "." + parts[1] + "." + parts[2] + ".";
|
||||
int self = Integer.parseInt(parts[3]);
|
||||
List<Long> rtts = new ArrayList<>();
|
||||
int peers = 0;
|
||||
long t0 = System.currentTimeMillis();
|
||||
for (int d = -8; d <= 8; d++) {
|
||||
if (cancel != null && cancel.get()) {
|
||||
break;
|
||||
}
|
||||
int host = self + d;
|
||||
if (host <= 1 || host >= 255 || host == self) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(prefix + host);
|
||||
long p0 = System.currentTimeMillis();
|
||||
if (addr.isReachable(180)) {
|
||||
peers++;
|
||||
rtts.add(System.currentTimeMillis() - p0);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
long elapsed = System.currentTimeMillis() - t0;
|
||||
r.itemPass("subnet", prefix + "x (self " + ip + ")");
|
||||
if (peers == 0) {
|
||||
r.itemWarn("peers", "0 reachable (+/-8 quick scan)");
|
||||
r.itemNa("avg-rtt", "n/a");
|
||||
CastLanPeerProbe.Result scan = CastLanPeerProbe.scan(context, cancel);
|
||||
r.itemPass("scan-ms", Long.toString(scan.elapsedMs));
|
||||
if (scan.peers == 0) {
|
||||
r.itemWarn("cast-peers",
|
||||
"0 on LAN (UDP :" + CastConfig.DISCOVERY_PORT + ", need peer listening/Receive)");
|
||||
} else {
|
||||
long sum = 0;
|
||||
for (Long v : rtts) {
|
||||
sum += v;
|
||||
}
|
||||
double avg = (double) sum / peers;
|
||||
r.itemPass("peers", peers + " reachable");
|
||||
r.itemPass("avg-rtt", String.format(Locale.US, "%.0f ms", avg));
|
||||
r.itemPass("cast-peers", scan.peers + " · " + scan.summary);
|
||||
}
|
||||
r.itemPass("scan-ms", Long.toString(elapsed));
|
||||
}
|
||||
|
||||
private static void probeBackend(Context context, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
|
||||
r.section("BACKEND", "Backend reachability");
|
||||
String url = resolveBackendUrl(context);
|
||||
if (url.isEmpty()) {
|
||||
r.itemWarn("url", "not configured");
|
||||
return;
|
||||
String heartbeat = BackendEndpoints.HEARTBEAT_URL;
|
||||
r.itemPass("url", shortenUrl(heartbeat));
|
||||
String configured = BackendEndpoints.normalizeCrashesUrl(
|
||||
CrashSettingsStore.load(context).uploadUrl);
|
||||
if (!configured.isEmpty() && !configured.equals(BackendEndpoints.UPLOAD_URL)) {
|
||||
r.itemWarn("configured", shortenUrl(configured) + " (legacy host; probe uses apps.f0xx.org)");
|
||||
}
|
||||
r.itemPass("url", shortenUrl(url));
|
||||
if (cancel != null && cancel.get()) {
|
||||
r.itemWarn("probe", "stopped");
|
||||
return;
|
||||
}
|
||||
HttpResult head = httpHead(url);
|
||||
HttpResult head = httpHead(BackendEndpoints.CRASHES_BASE + "/");
|
||||
reportHttp(r, "head", head.code, head.summary);
|
||||
HttpReadResult read = httpRead(url, 32 * 1024);
|
||||
HttpReadResult read = httpRead(BackendEndpoints.CRASHES_BASE + "/", 32 * 1024);
|
||||
reportHttpRead(r, "downlink", read.code, read.summary);
|
||||
HttpResult uplink = httpPostJson(url, "{\"ping\":\"bandwidth\"}");
|
||||
HttpResult uplink = httpPostJson(BackendEndpoints.HEARTBEAT_URL,
|
||||
"{\"heartbeat\":{\"type\":\"ping\"}}");
|
||||
reportHttp(r, "uplink", uplink.code, uplink.summary);
|
||||
}
|
||||
|
||||
@@ -273,12 +250,7 @@ public final class DevNetworkSelfTestProbe {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
String backend = resolveBackendUrl(context);
|
||||
if (backend.isEmpty()) {
|
||||
r.itemWarn("be-ntp", "backend URL missing");
|
||||
return;
|
||||
}
|
||||
String heartbeatUrl = toHeartbeatUrl(backend);
|
||||
String backend = BackendEndpoints.HEARTBEAT_URL;
|
||||
if (cancel != null && cancel.get()) {
|
||||
r.itemWarn("be-ntp", "stopped");
|
||||
return;
|
||||
@@ -286,7 +258,7 @@ public final class DevNetworkSelfTestProbe {
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
String tz = new java.text.SimpleDateFormat("Z", Locale.US).format(new java.util.Date());
|
||||
String body = "{\"heartbeat\":{\"type\":\"ntp\",\"date\":" + now + ",\"tz\":\"" + tz + "\"}}";
|
||||
HttpTextResult text = httpPostJsonText(heartbeatUrl, body);
|
||||
HttpTextResult text = httpPostJsonText(backend, body);
|
||||
if (httpSuccess(text.code)) {
|
||||
r.itemPass("be-ntp", text.summary);
|
||||
if (text.body != null && !text.body.isEmpty()) {
|
||||
@@ -319,29 +291,17 @@ public final class DevNetworkSelfTestProbe {
|
||||
r.itemWarn("external-ip", "stopped");
|
||||
return;
|
||||
}
|
||||
String backend = resolveBackendUrl(context);
|
||||
if (!backend.isEmpty()) {
|
||||
HttpTextResult viaBackend = httpPostJsonText(toHeartbeatUrl(backend),
|
||||
"{\"heartbeat\":{\"type\":\"ip\"}}");
|
||||
if (viaBackend.code < 0) {
|
||||
r.itemFail("external-ip", viaBackend.summary);
|
||||
} else if (!httpSuccess(viaBackend.code)) {
|
||||
r.itemFail("external-ip", viaBackend.summary);
|
||||
} else {
|
||||
r.itemPass("external-ip", viaBackend.summary);
|
||||
if (viaBackend.body != null && !viaBackend.body.isEmpty()) {
|
||||
r.itemPass("be-ip-body", truncate(viaBackend.body, 80));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
HttpTextResult web = httpGetText("https://api.ipify.org?format=json");
|
||||
if (web.code < 0) {
|
||||
r.itemFail("external-ip", web.summary);
|
||||
} else if (!httpSuccess(web.code)) {
|
||||
r.itemFail("external-ip", web.summary);
|
||||
HttpTextResult viaBackend = httpPostJsonText(BackendEndpoints.HEARTBEAT_URL,
|
||||
"{\"heartbeat\":{\"type\":\"ip\"}}");
|
||||
if (viaBackend.code < 0) {
|
||||
r.itemFail("external-ip", viaBackend.summary);
|
||||
} else if (!httpSuccess(viaBackend.code)) {
|
||||
r.itemFail("external-ip", viaBackend.summary);
|
||||
} else {
|
||||
r.itemPass("external-ip", truncate(web.summary, 100));
|
||||
r.itemPass("external-ip", viaBackend.summary);
|
||||
if (viaBackend.body != null && !viaBackend.body.isEmpty()) {
|
||||
r.itemPass("be-ip-body", truncate(viaBackend.body, 80));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,31 +417,6 @@ public final class DevNetworkSelfTestProbe {
|
||||
return oneLine.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
private static String toHeartbeatUrl(String backend) {
|
||||
if (backend.contains("/api/upload.php")) {
|
||||
return backend.replace("/api/upload.php", "/api/heartbeat.php");
|
||||
}
|
||||
if (backend.endsWith("/")) {
|
||||
return backend + "api/heartbeat.php";
|
||||
}
|
||||
return backend + "/api/heartbeat.php";
|
||||
}
|
||||
|
||||
private static String resolveBackendUrl(Context context) {
|
||||
String crash = CrashSettingsStore.load(context).uploadUrl;
|
||||
if (crash != null && !crash.trim().isEmpty()) {
|
||||
return crash.trim();
|
||||
}
|
||||
String ota = AppPreferences.getOtaChannelUrl(context);
|
||||
if (ota != null && !ota.trim().isEmpty()) {
|
||||
return ota.trim();
|
||||
}
|
||||
if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null && !BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim().isEmpty()) {
|
||||
return BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static boolean hasTransport(NetworkCapabilities caps, int t) {
|
||||
return caps != null && caps.hasTransport(t);
|
||||
}
|
||||
|
||||
245
app/src/main/java/com/foxx/androidcast/dev/StreamDumpWriter.java
Normal file
245
app/src/main/java/com/foxx/androidcast/dev/StreamDumpWriter.java
Normal file
@@ -0,0 +1,245 @@
|
||||
package com.foxx.androidcast.dev;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* FR 0.1 — length-prefixed elementary stream capture while casting (dev-only).
|
||||
*/
|
||||
public final class StreamDumpWriter {
|
||||
private static final String TAG = "StreamDumpWriter";
|
||||
private static final int SCHEMA_VERSION = 1;
|
||||
private static final long MAX_BYTES = 100L * 1024L * 1024L;
|
||||
private static final long MAX_DURATION_MS = 5L * 60L * 1000L;
|
||||
|
||||
private static final Object LOCK = new Object();
|
||||
private static StreamDumpWriter active;
|
||||
|
||||
private final String sessionId;
|
||||
private final String datePrefix;
|
||||
private final long startedAtMs;
|
||||
private final File audioFile;
|
||||
private final File videoFile;
|
||||
private final File workDir;
|
||||
private BufferedOutputStream audioOut;
|
||||
private BufferedOutputStream videoOut;
|
||||
private final AtomicInteger audioFrames = new AtomicInteger();
|
||||
private final AtomicInteger videoFrames = new AtomicInteger();
|
||||
private final AtomicLong bytesWritten = new AtomicLong();
|
||||
private volatile boolean closed;
|
||||
|
||||
private StreamDumpWriter(Context context) throws IOException {
|
||||
startedAtMs = System.currentTimeMillis();
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
|
||||
sessionId = fmt.format(new Date(startedAtMs));
|
||||
datePrefix = sessionId.substring(0, 8);
|
||||
workDir = new File(context.getFilesDir(), "stream_dumps");
|
||||
if (!workDir.exists() && !workDir.mkdirs()) {
|
||||
throw new IOException("cannot create stream_dumps dir");
|
||||
}
|
||||
audioFile = new File(workDir, datePrefix + "_audio_stream.raw");
|
||||
videoFile = new File(workDir, datePrefix + "_video_stream.raw");
|
||||
audioOut = new BufferedOutputStream(new FileOutputStream(audioFile));
|
||||
videoOut = new BufferedOutputStream(new FileOutputStream(videoFile));
|
||||
}
|
||||
|
||||
public static void startIfEnabled(Context context) {
|
||||
if (!com.foxx.androidcast.AppPreferences.isDevStreamDumpEnabled(context)) {
|
||||
return;
|
||||
}
|
||||
synchronized (LOCK) {
|
||||
if (active != null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
active = new StreamDumpWriter(context.getApplicationContext());
|
||||
Log.i(TAG, "recording " + active.sessionId);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "start failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void finalizeIfActive(Context context) {
|
||||
StreamDumpWriter w;
|
||||
synchronized (LOCK) {
|
||||
w = active;
|
||||
active = null;
|
||||
}
|
||||
if (w != null) {
|
||||
w.closeAndZip(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
|
||||
public static void recordFrame(byte type, byte[] payload) {
|
||||
StreamDumpWriter w = active;
|
||||
if (w == null || w.closed || payload == null) {
|
||||
return;
|
||||
}
|
||||
w.append(type, payload);
|
||||
}
|
||||
|
||||
private void append(byte type, byte[] payload) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (bytesWritten.get() > MAX_BYTES
|
||||
|| System.currentTimeMillis() - startedAtMs > MAX_DURATION_MS) {
|
||||
closed = true;
|
||||
return;
|
||||
}
|
||||
boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
|
||||
boolean video = type == CastProtocol.MSG_VIDEO_FRAME;
|
||||
if (!audio && !video) {
|
||||
return;
|
||||
}
|
||||
byte[] frame = lengthPrefix(payload);
|
||||
synchronized (LOCK) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (audio) {
|
||||
audioOut.write(frame);
|
||||
audioFrames.incrementAndGet();
|
||||
} else {
|
||||
videoOut.write(frame);
|
||||
videoFrames.incrementAndGet();
|
||||
}
|
||||
bytesWritten.addAndGet(frame.length);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "write failed: " + e.getMessage());
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] lengthPrefix(byte[] payload) {
|
||||
ByteBuffer buf = ByteBuffer.allocate(4 + payload.length);
|
||||
buf.putInt(payload.length);
|
||||
buf.put(payload);
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
private void closeAndZip(Context context) {
|
||||
synchronized (LOCK) {
|
||||
closed = true;
|
||||
closeQuietly(audioOut);
|
||||
closeQuietly(videoOut);
|
||||
audioOut = null;
|
||||
videoOut = null;
|
||||
}
|
||||
File zip = new File(workDir, sessionId + "_sess_record.zip");
|
||||
try {
|
||||
writeZip(zip);
|
||||
if (audioFile.exists()) {
|
||||
audioFile.delete();
|
||||
}
|
||||
if (videoFile.exists()) {
|
||||
videoFile.delete();
|
||||
}
|
||||
Log.i(TAG, "wrote " + zip.getAbsolutePath());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "zip failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void writeZip(File zip) throws IOException {
|
||||
JSONObject meta = new JSONObject();
|
||||
try {
|
||||
meta.put("schema_version", SCHEMA_VERSION);
|
||||
meta.put("session_id", sessionId);
|
||||
meta.put("role", "sender");
|
||||
meta.put("record_mode", "raw_elementary");
|
||||
meta.put("started_at_epoch_ms", startedAtMs);
|
||||
meta.put("ended_at_epoch_ms", System.currentTimeMillis());
|
||||
JSONObject audio = new JSONObject();
|
||||
audio.put("file", audioFile.getName());
|
||||
audio.put("format", "length_prefixed");
|
||||
audio.put("frame_count", audioFrames.get());
|
||||
meta.put("audio", audio);
|
||||
JSONObject video = new JSONObject();
|
||||
video.put("file", videoFile.getName());
|
||||
video.put("format", "length_prefixed");
|
||||
video.put("frame_count", videoFrames.get());
|
||||
meta.put("video", video);
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
byte[] metaBytes;
|
||||
try {
|
||||
metaBytes = meta.toString(2).getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) {
|
||||
putEntry(zos, "meta.json", metaBytes);
|
||||
if (audioFrames.get() > 0 && audioFile.isFile()) {
|
||||
putFileEntry(zos, audioFile.getName(), audioFile);
|
||||
}
|
||||
if (videoFrames.get() > 0 && videoFile.isFile()) {
|
||||
putFileEntry(zos, videoFile.getName(), videoFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void putEntry(ZipOutputStream zos, String name, byte[] data) throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(name));
|
||||
zos.write(data);
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
private static void putFileEntry(ZipOutputStream zos, String name, File file) throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(name));
|
||||
byte[] buf = new byte[8192];
|
||||
try (java.io.FileInputStream in = new java.io.FileInputStream(file)) {
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
zos.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
private static void closeQuietly(BufferedOutputStream out) {
|
||||
if (out == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
out.flush();
|
||||
out.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/** For unit tests. */
|
||||
static byte[] encodeLengthPrefixedFrame(byte[] payload) {
|
||||
return lengthPrefix(payload);
|
||||
}
|
||||
|
||||
/** Exposed for tests — parse length-prefixed payload round-trip. */
|
||||
static int readLengthPrefix(byte[] framed) {
|
||||
if (framed.length < 4) {
|
||||
return -1;
|
||||
}
|
||||
return ByteBuffer.wrap(framed, 0, 4).getInt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.foxx.androidcast.discovery;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiManager;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.network.CastProtoHeader;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Quick UDP cast discovery scan for developer network self-test. */
|
||||
public final class CastLanPeerProbe {
|
||||
private static final int SCAN_MS = 2_500;
|
||||
private static final int PROBE_INTERVAL_MS = 700;
|
||||
|
||||
public static final class Result {
|
||||
public final int peers;
|
||||
public final String summary;
|
||||
public final long elapsedMs;
|
||||
|
||||
Result(int peers, String summary, long elapsedMs) {
|
||||
this.peers = peers;
|
||||
this.summary = summary;
|
||||
this.elapsedMs = elapsedMs;
|
||||
}
|
||||
}
|
||||
|
||||
private CastLanPeerProbe() {}
|
||||
|
||||
public static Result scan(Context context, AtomicBoolean cancel) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
Map<String, String> peers = new LinkedHashMap<>();
|
||||
WifiManager.MulticastLock lock = acquireMulticastLock(context);
|
||||
try (DatagramSocket sock = openSocket()) {
|
||||
if (sock == null) {
|
||||
return new Result(0, "socket bind failed", System.currentTimeMillis() - t0);
|
||||
}
|
||||
sock.setSoTimeout(250);
|
||||
byte[] buf = new byte[2048];
|
||||
long deadline = System.currentTimeMillis() + SCAN_MS;
|
||||
long nextProbe = System.currentTimeMillis();
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (cancel != null && cancel.get()) {
|
||||
break;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= nextProbe) {
|
||||
sendDiscoverProbe(sock);
|
||||
nextProbe = now + PROBE_INTERVAL_MS;
|
||||
}
|
||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||
try {
|
||||
sock.receive(packet);
|
||||
recordPeer(context, packet, peers);
|
||||
} catch (java.net.SocketTimeoutException ignored) {
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
long ms = System.currentTimeMillis() - t0;
|
||||
return new Result(0, e.getClass().getSimpleName(), ms);
|
||||
} finally {
|
||||
releaseMulticastLock(lock);
|
||||
}
|
||||
long ms = System.currentTimeMillis() - t0;
|
||||
if (peers.isEmpty()) {
|
||||
return new Result(0, "UDP :" + CastConfig.DISCOVERY_PORT + " · " + ms + " ms", ms);
|
||||
}
|
||||
StringBuilder names = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : peers.entrySet()) {
|
||||
if (names.length() > 0) {
|
||||
names.append(", ");
|
||||
}
|
||||
names.append(e.getValue()).append('@').append(e.getKey());
|
||||
if (names.length() > 96) {
|
||||
names.append("…");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new Result(peers.size(), names.toString(), ms);
|
||||
}
|
||||
|
||||
private static DatagramSocket openSocket() {
|
||||
try {
|
||||
DatagramSocket sock = new DatagramSocket(null);
|
||||
sock.setReuseAddress(true);
|
||||
sock.setBroadcast(true);
|
||||
try {
|
||||
sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
|
||||
} catch (Exception ignored) {
|
||||
sock.bind(null);
|
||||
}
|
||||
return sock;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendDiscoverProbe(DatagramSocket sock) {
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||
json.put("discover", true);
|
||||
byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||
sock.send(packet);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void recordPeer(Context context, DatagramPacket packet, Map<String, String> peers) {
|
||||
try {
|
||||
JSONObject json = parseAnnounceJson(packet);
|
||||
if (json == null) {
|
||||
return;
|
||||
}
|
||||
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
|
||||
return;
|
||||
}
|
||||
if (json.optBoolean("discover", false)) {
|
||||
return;
|
||||
}
|
||||
String host = packet.getAddress().getHostAddress();
|
||||
String remoteUuid = json.optString("device_uuid", "");
|
||||
if (DiscoverySelfFilter.isSelf(context, host, remoteUuid)) {
|
||||
return;
|
||||
}
|
||||
String name = json.optString("name", "Unknown");
|
||||
int port = json.optInt("port", CastConfig.CAST_PORT);
|
||||
boolean listening = json.optBoolean("listening", true);
|
||||
if (!listening) {
|
||||
return;
|
||||
}
|
||||
peers.put(host + ":" + port, name);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static JSONObject parseAnnounceJson(DatagramPacket packet) {
|
||||
try {
|
||||
byte[] raw = packet.getData();
|
||||
int off = packet.getOffset();
|
||||
int len = packet.getLength();
|
||||
String text;
|
||||
if (len >= CastProtoHeader.HEADER_SIZE_V1 && readMagicLe(raw, off) == CastProtoHeader.MAGIC) {
|
||||
CastProtoHeader hdr = CastProtoHeader.decode(raw, off, len);
|
||||
int bodyOff = off + hdr.headerSize;
|
||||
int bodyLen = len - hdr.headerSize;
|
||||
if (bodyLen > 5) {
|
||||
byte type = raw[bodyOff];
|
||||
int plen = readIntBe(raw, bodyOff + 1);
|
||||
if (plen >= 0 && bodyOff + 5 + plen <= off + len) {
|
||||
text = new String(raw, bodyOff + 5, plen, StandardCharsets.UTF_8);
|
||||
} else {
|
||||
text = new String(raw, bodyOff, bodyLen, StandardCharsets.UTF_8);
|
||||
}
|
||||
} else {
|
||||
text = new String(raw, bodyOff, bodyLen, StandardCharsets.UTF_8);
|
||||
}
|
||||
} else {
|
||||
text = new String(raw, off, len, StandardCharsets.UTF_8);
|
||||
}
|
||||
return new JSONObject(text);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int readMagicLe(byte[] b, int off) {
|
||||
return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8)
|
||||
| ((b[off + 2] & 0xff) << 16) | ((b[off + 3] & 0xff) << 24);
|
||||
}
|
||||
|
||||
private static int readIntBe(byte[] b, int off) {
|
||||
return ((b[off] & 0xff) << 24) | ((b[off + 1] & 0xff) << 16)
|
||||
| ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff);
|
||||
}
|
||||
|
||||
private static WifiManager.MulticastLock acquireMulticastLock(Context context) {
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
WifiManager wifi = context.getApplicationContext().getSystemService(WifiManager.class);
|
||||
if (wifi == null) {
|
||||
return null;
|
||||
}
|
||||
WifiManager.MulticastLock lock = wifi.createMulticastLock("androidcast-lan-probe");
|
||||
lock.setReferenceCounted(false);
|
||||
lock.acquire();
|
||||
return lock;
|
||||
}
|
||||
|
||||
private static void releaseMulticastLock(WifiManager.MulticastLock lock) {
|
||||
if (lock == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lock.isHeld()) {
|
||||
lock.release();
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
/** Canonical production BE URLs on {@code apps.f0xx.org} (network self-test + defaults). */
|
||||
public final class BackendEndpoints {
|
||||
public static final String APPS_HOST = "apps.f0xx.org";
|
||||
public static final String CRASHES_BASE =
|
||||
"https://" + APPS_HOST + "/app/androidcast_project/crashes";
|
||||
public static final String HEARTBEAT_URL = CRASHES_BASE + "/api/heartbeat.php";
|
||||
public static final String UPLOAD_URL = CRASHES_BASE + "/api/upload.php";
|
||||
|
||||
private BackendEndpoints() {}
|
||||
|
||||
/** Maps legacy {@code f0xx.org}/{@code foxx.org} crash paths to {@link #APPS_HOST}. */
|
||||
public static String normalizeCrashesUrl(String url) {
|
||||
if (url == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = url.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.contains("://f0xx.org/app/androidcast_project/crashes")
|
||||
|| trimmed.contains("://foxx.org/app/androidcast_project/crashes")) {
|
||||
return trimmed.replace("://f0xx.org/", "://" + APPS_HOST + "/")
|
||||
.replace("://foxx.org/", "://" + APPS_HOST + "/");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ package com.foxx.androidcast.network.transport;
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Runtime FEC/NACK implementations on this device (not a user setting). */
|
||||
public final class StreamProtectionCapability {
|
||||
@@ -54,4 +56,14 @@ public final class StreamProtectionCapability {
|
||||
}
|
||||
return EnumSet.of(CastSettings.StreamProtection.NONE);
|
||||
}
|
||||
|
||||
/** Modes this device advertises on the wire (handshake uses AUTO → full set). */
|
||||
public static List<String> advertisedProtectionInOrder() {
|
||||
List<String> out = new ArrayList<>();
|
||||
out.add("AUTO (handshake)");
|
||||
for (CastSettings.StreamProtection mode : receiverImplementations()) {
|
||||
out.add(mode.name());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public final class RemoteAccessCoordinator {
|
||||
AppPreferences.clearDevRemoteAccessSession(app);
|
||||
return;
|
||||
}
|
||||
if (mode == RemoteAccessMode.WIREGUARD) {
|
||||
if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH) {
|
||||
RemoteAccessService.start(app, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public enum RemoteAccessMode {
|
||||
DISABLED,
|
||||
/** WireGuard outbound tunnel (v1). */
|
||||
WIREGUARD,
|
||||
/** Reverse SSH — reserved; not selectable in UI until implemented. */
|
||||
/** Reverse SSH — alpha essential; selectable when implemented. */
|
||||
RSSH;
|
||||
|
||||
public static RemoteAccessMode fromStored(String name) {
|
||||
@@ -39,6 +39,6 @@ public enum RemoteAccessMode {
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return this == WIREGUARD;
|
||||
return this == WIREGUARD || this == RSSH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public final class RemoteAccessService extends Service {
|
||||
long exp = AppPreferences.getDevRemoteAccessExpiresAt(this);
|
||||
if (exp > 0 && exp < System.currentTimeMillis() / 1000L) {
|
||||
Log.i(TAG, "session expired — tearing down tunnel");
|
||||
WireGuardTunnelBridge.tearDown(this);
|
||||
RemoteAccessTunnelHelper.tearDownAll(this);
|
||||
AppPreferences.clearDevRemoteAccessSession(this);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public final class RemoteAccessService extends Service {
|
||||
String action = intent != null ? intent.getAction() : ACTION_POLL;
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
cancelPoll(this);
|
||||
WireGuardTunnelBridge.tearDown(this);
|
||||
RemoteAccessTunnelHelper.tearDownAll(this);
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
@@ -135,7 +135,12 @@ public final class RemoteAccessService extends Service {
|
||||
hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME);
|
||||
hb.put("tunnel_mode", mode.toApiValue());
|
||||
JSONArray caps = new JSONArray();
|
||||
caps.put("wireguard");
|
||||
if (mode == RemoteAccessMode.WIREGUARD) {
|
||||
caps.put("wireguard");
|
||||
} else if (mode == RemoteAccessMode.RSSH) {
|
||||
caps.put("rssh");
|
||||
caps.put("ssh_reverse");
|
||||
}
|
||||
caps.put("files_app_home");
|
||||
hb.put("capabilities", caps);
|
||||
|
||||
@@ -147,7 +152,7 @@ public final class RemoteAccessService extends Service {
|
||||
if ("connect".equals(raAction)) {
|
||||
handleConnect(resp);
|
||||
} else if ("disabled".equals(raAction) || "deny".equals(raAction)) {
|
||||
WireGuardTunnelBridge.tearDown(this);
|
||||
RemoteAccessTunnelHelper.tearDownAll(this);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "poll failed: " + e.getMessage());
|
||||
@@ -156,7 +161,7 @@ public final class RemoteAccessService extends Service {
|
||||
|
||||
private void postDisableHeartbeat() {
|
||||
try {
|
||||
WireGuardTunnelBridge.tearDown(this);
|
||||
RemoteAccessTunnelHelper.tearDownAll(this);
|
||||
AppPreferences.clearDevRemoteAccessSession(this);
|
||||
JSONObject hb = new JSONObject();
|
||||
hb.put("type", "ra");
|
||||
@@ -170,26 +175,34 @@ public final class RemoteAccessService extends Service {
|
||||
}
|
||||
|
||||
private void handleConnect(JSONObject resp) {
|
||||
if (!"wireguard".equals(resp.optString("tunnel", ""))) {
|
||||
Log.w(TAG, "unsupported tunnel in connect");
|
||||
return;
|
||||
}
|
||||
String tunnel = resp.optString("tunnel", "");
|
||||
String sessionId = resp.optString("session_id", "");
|
||||
long expiresAt = resp.optLong("expires_at", 0L);
|
||||
if (!sessionId.isEmpty()) {
|
||||
AppPreferences.setDevRemoteAccessSession(this, sessionId, expiresAt);
|
||||
}
|
||||
JSONObject creds = resp.optJSONObject("credentials");
|
||||
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||
if (wgConfig.isEmpty()) {
|
||||
if (creds == null) {
|
||||
return;
|
||||
}
|
||||
if (WireGuardTunnelBridge.applyConfig(this, wgConfig)) {
|
||||
updateNotificationConnected(sessionId);
|
||||
boolean ok = false;
|
||||
if ("wireguard".equals(tunnel)) {
|
||||
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||
if (!wgConfig.isEmpty()) {
|
||||
ok = WireGuardTunnelBridge.applyConfig(this, wgConfig);
|
||||
}
|
||||
} else if ("ssh_reverse".equals(tunnel)) {
|
||||
ok = ReverseSshTunnelBridge.applyConfig(this, creds);
|
||||
} else {
|
||||
Log.w(TAG, "unsupported tunnel in connect: " + tunnel);
|
||||
return;
|
||||
}
|
||||
if (ok) {
|
||||
updateNotificationConnected(sessionId, tunnel);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNotificationConnected(String sessionId) {
|
||||
private void updateNotificationConnected(String sessionId, String tunnel) {
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
if (nm == null) {
|
||||
return;
|
||||
@@ -197,6 +210,9 @@ public final class RemoteAccessService extends Service {
|
||||
String text = sessionId.isEmpty()
|
||||
? getString(R.string.dev_remote_access_notification_connected)
|
||||
: getString(R.string.dev_remote_access_notification_session, sessionId);
|
||||
if ("ssh_reverse".equals(tunnel)) {
|
||||
text = text + " (RSSH)";
|
||||
}
|
||||
nm.notify(NOTIF_ID, new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(getString(R.string.dev_remote_access_notification_title))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.foxx.androidcast.remoteaccess;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/** Tear down all remote-access transports (WireGuard + RSSH). */
|
||||
public final class RemoteAccessTunnelHelper {
|
||||
private RemoteAccessTunnelHelper() {}
|
||||
|
||||
public static void tearDownAll(Context context) {
|
||||
WireGuardTunnelBridge.tearDown(context);
|
||||
ReverseSshTunnelBridge.tearDown(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.foxx.androidcast.remoteaccess;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Outbound reverse SSH (-R) to bastion. Alpha path — no {@link android.net.VpnService}.
|
||||
*/
|
||||
public final class ReverseSshTunnelBridge {
|
||||
private static final String TAG = "RsshTunnelBridge";
|
||||
private static final int CONNECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
private static volatile Thread worker;
|
||||
private static volatile Session session;
|
||||
private static final AtomicBoolean connected = new AtomicBoolean(false);
|
||||
|
||||
private ReverseSshTunnelBridge() {}
|
||||
|
||||
public static boolean applyConfig(Context context, JSONObject creds) {
|
||||
if (creds == null) {
|
||||
return false;
|
||||
}
|
||||
tearDown(context);
|
||||
String host = creds.optString("bastion_host", "");
|
||||
int port = creds.optInt("bastion_port", 443);
|
||||
String user = creds.optString("username", "");
|
||||
String pass = creds.optString("password", "");
|
||||
int remotePort = creds.optInt("remote_bind_port", 0);
|
||||
String localHost = creds.optString("local_forward_host", "127.0.0.1");
|
||||
int localPort = creds.optInt("local_forward_port", 8022);
|
||||
if (host.isEmpty() || user.isEmpty() || pass.isEmpty() || remotePort <= 0) {
|
||||
Log.w(TAG, "incomplete RSSH credentials");
|
||||
return false;
|
||||
}
|
||||
CountDownLatch connectedLatch = new CountDownLatch(1);
|
||||
AtomicBoolean ok = new AtomicBoolean(false);
|
||||
worker = new Thread(() -> {
|
||||
try {
|
||||
JSch jsch = new JSch();
|
||||
Session s = jsch.getSession(user, host, port);
|
||||
s.setPassword(pass);
|
||||
Properties cfg = new Properties();
|
||||
cfg.put("StrictHostKeyChecking", "no");
|
||||
s.setConfig(cfg);
|
||||
s.setServerAliveInterval(30_000);
|
||||
s.connect(CONNECT_TIMEOUT_MS);
|
||||
s.setPortForwardingR(remotePort, localHost, localPort);
|
||||
session = s;
|
||||
connected.set(true);
|
||||
ok.set(true);
|
||||
connectedLatch.countDown();
|
||||
Log.i(TAG, "RSSH -R " + remotePort + " -> " + localHost + ":" + localPort);
|
||||
while (s.isConnected() && !Thread.currentThread().isInterrupted()) {
|
||||
Thread.sleep(5_000);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "RSSH tunnel failed: " + e.getMessage());
|
||||
connectedLatch.countDown();
|
||||
} finally {
|
||||
connected.set(false);
|
||||
Session s = session;
|
||||
session = null;
|
||||
if (s != null) {
|
||||
try {
|
||||
s.disconnect();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "androidcast-rssh");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
try {
|
||||
if (!connectedLatch.await(CONNECT_TIMEOUT_MS + 5_000L, TimeUnit.MILLISECONDS)) {
|
||||
Log.w(TAG, "RSSH connect timed out");
|
||||
return false;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
return ok.get();
|
||||
}
|
||||
|
||||
public static void tearDown(Context context) {
|
||||
Thread w = worker;
|
||||
worker = null;
|
||||
Session s = session;
|
||||
session = null;
|
||||
connected.set(false);
|
||||
if (s != null) {
|
||||
try {
|
||||
s.disconnect();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (w != null) {
|
||||
w.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isConnected() {
|
||||
Session s = session;
|
||||
return s != null && s.isConnected();
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ public final class CastSendPump {
|
||||
}
|
||||
byte[] payload = item.buildPayload();
|
||||
session.send(item.type, payload);
|
||||
com.foxx.androidcast.dev.StreamDumpWriter.recordFrame(item.type, payload);
|
||||
int payloadSize = payload != null ? payload.length : 0;
|
||||
SessionStatsContext.recordOutboundMessage(item.type, item.keyFrame, payloadSize);
|
||||
if (statsPlane != null) {
|
||||
|
||||
@@ -118,6 +118,35 @@ public final class CodecCatalog {
|
||||
return localAdvertisedAudioEncoderLines(settings);
|
||||
}
|
||||
|
||||
/** Audio codec names advertised on wire (priority order; excludes AUTO). */
|
||||
public static List<String> localAdvertisedAudioCodecsInOrder(CastSettings settings) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (AudioCodecChoice c : audioCodecChoicesForUi(settings)) {
|
||||
if (c.codec == CastSettings.AudioCodec.AUTO) {
|
||||
continue;
|
||||
}
|
||||
out.add(c.codec.name());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Video MIME types advertised on wire (encoder catalog order). */
|
||||
public static List<String> localAdvertisedVideoMimesInOrder() {
|
||||
return localEncoderMimes();
|
||||
}
|
||||
|
||||
/** Video codec labels for diagnostics (priority order; excludes AUTO). */
|
||||
public static List<String> localAdvertisedVideoCodecsInOrder(CastSettings settings) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (VideoCodecChoice c : videoCodecChoicesForUi(settings)) {
|
||||
if (c.codec == CastSettings.VideoCodec.AUTO) {
|
||||
continue;
|
||||
}
|
||||
out.add(c.codec.name());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String audioAdvertisedLine(CastSettings.AudioCodec codec, String mime, CastSettings settings) {
|
||||
CodecTier tier = com.foxx.androidcast.media.CodecPriorityCatalog.tierForAudioPreference(codec);
|
||||
int score = com.foxx.androidcast.media.CodecPriorityCatalog.priorityScore(codec, settings);
|
||||
|
||||
@@ -294,6 +294,7 @@ public class ScreenCastService extends Service implements
|
||||
sendPump = new CastSendPump();
|
||||
sendPump.start(session, () -> stopping.set(true), plane);
|
||||
}
|
||||
com.foxx.androidcast.dev.StreamDumpWriter.startIfEnabled(this);
|
||||
casting.set(true);
|
||||
CastActiveState.setSenderCasting(true);
|
||||
CastTrayNotifier.refresh(this);
|
||||
@@ -989,6 +990,7 @@ public class ScreenCastService extends Service implements
|
||||
sendPump.stop();
|
||||
sendPump = null;
|
||||
}
|
||||
com.foxx.androidcast.dev.StreamDumpWriter.finalizeIfActive(this);
|
||||
if (fanoutPump != null) {
|
||||
fanoutPump.stop();
|
||||
fanoutPump = null;
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<include
|
||||
android:id="@+id/dev_diag_casting"
|
||||
layout="@layout/view_dev_diag_card" />
|
||||
|
||||
<include
|
||||
android:id="@+id/dev_diag_advertisement"
|
||||
layout="@layout/view_dev_diag_card" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_grab_session_stats" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_dev_stream_dump"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_stream_dump" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/dev_stream_dump_hint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_dev_diag_ping_pong"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
<string name="dev_diag_audio_codecs_title">Аудиокодеки (сжатие)</string>
|
||||
<string name="dev_diag_network_title">Сеть</string>
|
||||
<string name="dev_diag_casting_title">Другой вывод / трансляция</string>
|
||||
<string name="dev_diag_advertisement_title">Полная реклама возможностей</string>
|
||||
<string name="dev_sender_resolution_ui_section">Разрешение отправителя (тест)</string>
|
||||
<string name="dev_sender_resolution_ui_mode">Режим списка разрешений</string>
|
||||
<string name="dev_sender_resolution_ui_separated">Раздельный (Оригинал / 720p / 480p / Авто)</string>
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
<string name="dev_diag_audio_codecs_title">Audio codecs (compression)</string>
|
||||
<string name="dev_diag_network_title">Network</string>
|
||||
<string name="dev_diag_casting_title">Other casting / display</string>
|
||||
<string name="dev_diag_advertisement_title">Full advertisement info</string>
|
||||
<string name="dev_sender_resolution_ui_section">Sender resolution UI (test)</string>
|
||||
<string name="dev_sender_resolution_ui_mode">Resolution preset mode</string>
|
||||
<string name="dev_sender_resolution_ui_separated">Separated (Original / 720p / 480p / Auto)</string>
|
||||
@@ -254,6 +255,8 @@
|
||||
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
||||
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
|
||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
||||
<string name="dev_stream_dump">Record stream dump while casting (FR 0.1)</string>
|
||||
<string name="dev_stream_dump_hint">Writes zip under files/stream_dumps/ (max 5 min or 100 MB). Pull via adb.</string>
|
||||
<string name="dev_diag_ping_pong">Diagnostic cast ping-pong (~1/s)</string>
|
||||
<string name="dev_diag_ping_response_mode">Ping response mode (this device)</string>
|
||||
<string name="dev_diag_ping_response_mirror">Mirror as-is (dummy pong)</string>
|
||||
@@ -265,8 +268,9 @@
|
||||
<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>
|
||||
<string name="dev_remote_access_rssh_unavailable">RSSH (reverse SSH — not available yet)</string>
|
||||
<string name="dev_remote_access_hint">When enabled, device polls BE every 1–7 min (type: ra). Disabled sends teardown + BE notification. RSSH is reserved for a future release.</string>
|
||||
<string name="dev_remote_access_rssh">RSSH (reverse SSH)</string>
|
||||
<string name="dev_remote_access_rssh_unavailable">RSSH (reverse SSH — alpha; not available yet)</string>
|
||||
<string name="dev_remote_access_hint">When enabled, device polls BE every 1–7 min (type:ra). WireGuard needs Android VPN permission once. RSSH uses outbound SSH — no VPN prompt.</string>
|
||||
<string name="dev_remote_access_rssh_toast">Reverse SSH (RSSH) is not implemented yet.</string>
|
||||
<string name="dev_remote_access_applied">Remote access: %1$s</string>
|
||||
<string name="dev_remote_access_vpn_denied">VPN permission required for WireGuard remote access.</string>
|
||||
@@ -277,7 +281,7 @@
|
||||
<string name="dev_remote_access_notification_session">Connected · session %1$s</string>
|
||||
<string name="dev_tab_network_self_test">Network self-test</string>
|
||||
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
|
||||
<string name="dev_network_selftest_intro">Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, peer reachability, backend RTT/throughput, NAT and NTP correction.</string>
|
||||
<string name="dev_network_selftest_intro">Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, cast LAN peers (UDP discovery), backend RTT/throughput (apps.f0xx.org), NAT and NTP correction.</string>
|
||||
<string name="dev_network_selftest_run">Run self-test</string>
|
||||
<string name="dev_network_selftest_force">Force re-check</string>
|
||||
<string name="dev_network_selftest_pending">Waiting for self-test run…</string>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.foxx.androidcast.dev;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class StreamDumpWriterTest {
|
||||
|
||||
@Test
|
||||
public void lengthPrefixRoundTrip() {
|
||||
byte[] payload = new byte[] {1, 2, 3, 4, 5};
|
||||
byte[] framed = StreamDumpWriter.encodeLengthPrefixedFrame(payload);
|
||||
assertEquals(payload.length, StreamDumpWriter.readLengthPrefix(framed));
|
||||
byte[] body = new byte[payload.length];
|
||||
System.arraycopy(framed, 4, body, 0, payload.length);
|
||||
assertArrayEquals(payload, body);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lengthPrefixBigEndian() {
|
||||
byte[] framed = StreamDumpWriter.encodeLengthPrefixedFrame(new byte[10]);
|
||||
assertEquals(10, ByteBuffer.wrap(framed, 0, 4).getInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class BackendEndpointsTest {
|
||||
@Test
|
||||
public void canonicalUrlsUseAppsHost() {
|
||||
assertTrue(BackendEndpoints.HEARTBEAT_URL.startsWith("https://apps.f0xx.org/"));
|
||||
assertTrue(BackendEndpoints.UPLOAD_URL.contains("/api/upload.php"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalizeCrashesUrlRewritesLegacyHost() {
|
||||
String legacy = "https://f0xx.org/app/androidcast_project/crashes/api/upload.php";
|
||||
assertEquals(BackendEndpoints.UPLOAD_URL, BackendEndpoints.normalizeCrashesUrl(legacy));
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,14 @@ public class RemoteAccessModeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isActive_onlyWireGuard() {
|
||||
public void toApiValue_rssh() {
|
||||
assertEquals("rssh", RemoteAccessMode.RSSH.toApiValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isActive_wireguardAndRssh() {
|
||||
assertFalse(RemoteAccessMode.DISABLED.isActive());
|
||||
assertTrue(RemoteAccessMode.WIREGUARD.isActive());
|
||||
assertFalse(RemoteAccessMode.RSSH.isActive());
|
||||
assertTrue(RemoteAccessMode.RSSH.isActive());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user