Minamp 1.4: faster connecting

- Remember the connection that worked and try it alone first next start
- Prefer plain HTTP on the home network (each TLS handshake costs seconds on a
  600 MHz phone); new setting "Encrypt at home too" to keep HTTPS
- Try secure addresses one at a time instead of in parallel, so they don't
  all time out together
- TLS 1.2 with session resumption: reconnects skip the full handshake
- Cache certificate chains already verified; changing the trusted roots
  clears that cache and all TLS sessions
- README: updated connection order; test: session resumption check and
  HandshakeProfile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 21:16:09 +02:00
1 parent 58d21b6d68
commit 6fd17bc9c5
9 files changed
+284 -67

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.minamp" package="com.minamp"
android:versionCode="4" android:versionCode="5"
android:versionName="1.3" android:versionName="1.4"
android:installLocation="auto"> android:installLocation="auto">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+9 -4
View File
@@ -25,10 +25,15 @@ working one, re-checking whenever the phone switches network:
| Order | Connection | When it's used | | Order | Connection | When it's used |
|---|---|---| |---|---|---|
| 1 | Home network, HTTPS (`*.plex.direct`) | On your Wi-Fi | | 1 | Whatever worked last time | Tried alone first, so a normal start costs one request |
| 2 | Home network, plain HTTP | Same, if your router blocks plex.direct DNS (rebind protection) | | 2 | Home network, plain HTTP | On your Wi-Fi (fast: no encryption work for the phone) |
| 3 | Remote, HTTPS | Away, if Remote Access is set up on the server | | 3 | Home network, HTTPS (`*.plex.direct`) | On your Wi-Fi if plain HTTP is refused, or if *Settings → Encrypt at home too* is on |
| 4 | Plex Relay | Away, when the server isn't reachable directly (bandwidth-capped; Minamp caps itself at 192 kbps) | | 4 | Remote, HTTPS | Away, if Remote Access is set up on the server |
| 5 | Plex Relay | Away, when the server isn't reachable directly (bandwidth-capped; Minamp caps itself at 192 kbps) |
Secure addresses are tried one at a time: on a 600 MHz phone a TLS handshake takes seconds, and
several at once make each of them time out. Minamp uses TLS 1.2 with session resumption, so only
the first connection to each server pays the full cost; later ones resume in a fraction of it.
For the best remote experience enable **Settings → Remote Access** on your Plex Media Server. For the best remote experience enable **Settings → Remote Access** on your Plex Media Server.
Without it, Relay still works. Without it, Relay still works.
+13
View File
@@ -67,6 +67,19 @@ public class App extends Application {
CertVerifier.setRootStore(new AssetRoots(this)); CertVerifier.setRootStore(new AssetRoots(this));
Library.preferredVariant = Math.max(0, Math.min(Library.VARIANTS - 1, prefs.getInt("tcVariant", 0))); Library.preferredVariant = Math.max(0, Math.min(Library.VARIANTS - 1, prefs.getInt("tcVariant", 0)));
images = new ImageLoader(this); images = new ImageLoader(this);
Server.preferSecureAtHome = prefs.getBoolean("secureHome", false);
Server.saver = new Runnable() {
public void run() {
// remember which connection worked, so next start tries it first
final Server s = server;
if (s == null) return;
handler.post(new Runnable() {
public void run() {
if (server == s) prefs.edit().putString("server", s.toJson().toString()).commit();
}
});
}
};
loadServer(); loadServer();
PlayQueue.get().restore(this); PlayQueue.get().restore(this);
+13 -5
View File
@@ -394,6 +394,7 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
"Quality at home: " + qualityLabel(app.qualityHome()), "Quality at home: " + qualityLabel(app.qualityHome()),
"Quality away / mobile data: " + qualityLabel(app.qualityAway()), "Quality away / mobile data: " + qualityLabel(app.qualityAway()),
"Download tracks before playing: " + (app.downloadFirst() ? "On" : "Off"), "Download tracks before playing: " + (app.downloadFirst() ? "On" : "Off"),
"Encrypt at home too (slower): " + (Server.preferSecureAtHome ? "On" : "Off"),
"Library: " + app.sectionTitle(), "Library: " + app.sectionTitle(),
"Server: " + (s == null ? "-" : s.name), "Server: " + (s == null ? "-" : s.name),
"Connection: " + conn, "Connection: " + conn,
@@ -411,9 +412,16 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit(); app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit();
toast("Download before playing: " + (app.downloadFirst() ? "on" : "off")); toast("Download before playing: " + (app.downloadFirst() ? "on" : "off"));
break; break;
case 3: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break; case 3:
case 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break; Server.preferSecureAtHome = !Server.preferSecureAtHome;
case 5: app.prefs.edit().putBoolean("secureHome", Server.preferSecureAtHome).commit();
if (app.server != null) app.server.invalidate();
toast(Server.preferSecureAtHome ? "Using HTTPS at home (each new connection is slower on this phone)"
: "Using plain HTTP at home (faster)");
break;
case 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break;
case 5: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break;
case 6:
if (app.server != null) { if (app.server != null) {
app.server.invalidate(); app.server.invalidate();
toast("Reconnecting…"); toast("Reconnecting…");
@@ -425,8 +433,8 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis
}); });
} }
break; break;
case 6: showDiagnostics(); break; case 7: showDiagnostics(); break;
case 7: confirmSignOut(); break; case 8: confirmSignOut(); break;
} }
} }
}).show(); }).show();
+26 -1
View File
@@ -45,14 +45,39 @@ public final class CertVerifier {
} }
private static RootStore roots; private static RootStore roots;
/** Chains already proven to lead to a trusted root (keyed by a hash of the chain), so a
* reconnect only re-checks the host name and dates, not every signature again. */
private static final java.util.Set<String> verifiedChains = new java.util.HashSet<String>();
public static void setRootStore(RootStore store) { roots = store; } /** Changing what's trusted invalidates everything trusted so far: cached chains and TLS sessions. */
public static void setRootStore(RootStore store) {
roots = store;
synchronized (verifiedChains) { verifiedChains.clear(); }
Http.forgetSessions();
}
private CertVerifier() {} private CertVerifier() {}
public static void verify(List<byte[]> chainDer, String host) throws IOException { public static void verify(List<byte[]> chainDer, String host) throws IOException {
if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate"); if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate");
if (roots == null) throw new IOException("No trust store configured"); if (roots == null) throw new IOException("No trust store configured");
java.io.ByteArrayOutputStream all = new java.io.ByteArrayOutputStream();
for (int i = 0; i < chainDer.size(); i++) all.write(chainDer.get(i), 0, chainDer.get(i).length);
String chainKey = sha1Hex(all.toByteArray());
boolean known;
synchronized (verifiedChains) { known = verifiedChains.contains(chainKey); }
if (known) {
Certificate leaf = Certificate.getInstance(chainDer.get(0));
if (!hostMatches(leaf, host)) throw new IOException("Certificate does not match " + host);
if (new Date().getTime() > 1704067200000L) checkDates(leaf, new Date());
return;
}
verifyFully(chainDer, host);
synchronized (verifiedChains) { verifiedChains.add(chainKey); }
}
private static void verifyFully(List<byte[]> chainDer, String host) throws IOException {
List<Certificate> chain = new ArrayList<Certificate>(); List<Certificate> chain = new ArrayList<Certificate>();
for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i))); for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i)));
+21
View File
@@ -133,6 +133,12 @@ public final class Http {
} }
} }
/** Forget resumable TLS sessions (next connection to each host does a full, verified handshake). */
static void forgetSessions() {
synchronized (sessions) { sessions.clear(); }
evictAll();
}
/** Drop all pooled connections (e.g. after the network changed). */ /** Drop all pooled connections (e.g. after the network changed). */
public static void evictAll() { public static void evictAll() {
final List<Conn> doomed = new ArrayList<Conn>(); final List<Conn> doomed = new ArrayList<Conn>();
@@ -191,6 +197,8 @@ public final class Http {
private final String host; private final String host;
private final String sessionKey; private final String sessionKey;
private final long started = System.currentTimeMillis();
Client(TlsCrypto crypto, String host, String sessionKey) { Client(TlsCrypto crypto, String host, String sessionKey) {
super(crypto); super(crypto);
this.host = host; this.host = host;
@@ -209,6 +217,16 @@ public final class Http {
return v; return v;
} }
/**
* TLS 1.2 only: this TLS stack can resume 1.2 sessions but not 1.3 ones, and on a 600 MHz
* phone a resumed handshake (no key exchange, no signature checks) is the difference
* between ~10 s and well under a second for every new connection.
*/
@Override
protected com.minamp.bc.tls.ProtocolVersion[] getSupportedVersions() {
return com.minamp.bc.tls.ProtocolVersion.TLSv12.only();
}
@Override @Override
public TlsSession getSessionToResume() { public TlsSession getSessionToResume() {
synchronized (sessions) { return sessions.get(sessionKey); } synchronized (sessions) { return sessions.get(sessionKey); }
@@ -217,6 +235,9 @@ public final class Http {
@Override @Override
public void notifyHandshakeComplete() throws IOException { public void notifyHandshakeComplete() throws IOException {
super.notifyHandshakeComplete(); super.notifyHandshakeComplete();
boolean resumed = context.getSecurityParametersConnection().isResumedSession();
DiagLog.log("TLS " + (resumed ? "resumed" : "full handshake") + " with " + host
+ " in " + (System.currentTimeMillis() - started) + "ms");
TlsSession s = context.getSession(); TlsSession s = context.getSession();
if (s != null && s.isResumable()) { if (s != null && s.isResumable()) {
synchronized (sessions) { sessions.put(sessionKey, s); } synchronized (sessions) { sessions.put(sessionKey, s); }
+112 -55
View File
@@ -123,68 +123,124 @@ public final class Server {
return c.uri; return c.uri;
} }
/** When false (default), plain HTTP on the home network is preferred over HTTPS: on a slow
* phone every TLS handshake costs seconds, and on the LAN nothing leaves the house. */
public static volatile boolean preferSecureAtHome = false;
/** Called after a successful pick so the app can save {@link #lastGoodUri}. */
public static volatile Runnable saver;
/** The connection that worked last time; tried first next time. */
public volatile String lastGoodUri = "";
private String probe(Conn c) {
long t0 = System.currentTimeMillis();
try {
boolean lan = c.kind == LOCAL || c.kind == LOCAL_HTTP || (c.kind == MANUAL && isPrivateAddress(c.uri));
Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, lan ? 5000 : 15000);
Json.Obj mc = o.optObj("MediaContainer");
String id = mc == null ? "" : mc.optString("machineIdentifier");
if (machineId.length() > 0 && !machineId.equals(id)) return "a different server answered";
com.minamp.net.DiagLog.log("connection ok: " + kindLabel(c.kind) + " " + c.uri + " (" + (System.currentTimeMillis() - t0) + "ms)");
return null;
} catch (Throwable e) {
String m = com.minamp.net.Threads.describe(e);
com.minamp.net.DiagLog.log("connection failed: " + kindLabel(c.kind) + " " + c.uri + ": " + m);
return m;
}
}
private static boolean isPlainLan(Conn c) {
return c.uri.startsWith("http://") && (c.kind == LOCAL_HTTP || (c.kind == MANUAL && isPrivateAddress(c.uri)));
}
private Conn choose(Conn c) {
active = c;
lastGoodUri = c.uri;
Runnable sv = saver;
if (sv != null) sv.run();
Listener l = listener;
if (l != null) l.onConnectionChanged(this);
return c;
}
/** /**
* Probes every connection in parallel and keeps the most preferred one that answers. * Finds a working connection, cheapest first, never running more than one TLS handshake at a
* Returns as soon as the best remaining candidate has answered. * time (parallel handshakes on a single slow core make every one of them time out):
* 1. whatever worked last time;
* 2. plain-HTTP home-network addresses, in parallel (fast, no TLS);
* 3. the rest one by one: home HTTPS, remote, relay.
*/ */
public synchronized Conn pick() throws IOException { public synchronized Conn pick() throws IOException {
if (active != null) return active; if (active != null) return active;
if (connections.isEmpty()) throw new IOException("No connections for " + name); if (connections.isEmpty()) throw new IOException("No connections for " + name);
final int n = connections.size(); List<Conn> order = new ArrayList<Conn>(connections);
final int[] state = new int[n]; // 0 pending, 1 ok, 2 failed if (preferSecureAtHome) {
final String[] errors = new String[n]; // home HTTPS ahead of home HTTP
final Object lock = new Object(); for (int i = 0; i < order.size(); i++) {
for (int i = 0; i < n; i++) { if (order.get(i).kind == LOCAL) { order.add(0, order.remove(i)); }
final int idx = i;
final Conn c = connections.get(i);
Runnable probe = new Runnable() {
public void run() {
boolean ok = false;
try {
// Give LAN addresses a short timeout, remote/relay a longer one.
int timeout = c.kind <= LOCAL_HTTP ? 3500 : 9000;
Json.Obj o = Plex.fetchJson("GET", c.uri + "/identity", token, timeout);
Json.Obj mc = o.optObj("MediaContainer");
String id = mc == null ? "" : mc.optString("machineIdentifier");
ok = machineId.length() == 0 || machineId.equals(id);
if (!ok) errors[idx] = "different server answered";
} catch (Throwable e) {
errors[idx] = com.minamp.net.Threads.describe(e);
}
synchronized (lock) {
state[idx] = ok ? 1 : 2;
lock.notifyAll();
}
}
};
com.minamp.net.Threads.start(probe, "probe-" + i);
}
long deadline = System.currentTimeMillis() + 12000;
synchronized (lock) {
while (true) {
// the first non-failed candidate in preference order decides
for (int i = 0; i < n; i++) {
if (state[i] == 2) continue;
if (state[i] == 1) {
active = connections.get(i);
Listener l = listener;
if (l != null) l.onConnectionChanged(this);
return active;
}
break; // still waiting on a better candidate
}
boolean allFailed = true;
for (int i = 0; i < n; i++) if (state[i] != 2) allFailed = false;
long left = deadline - System.currentTimeMillis();
if (allFailed || left <= 0) break;
try { lock.wait(left); } catch (InterruptedException e) { break; }
} }
} }
StringBuilder sb = new StringBuilder("Can't reach " + (name.length() > 0 ? name : "server")); String firstError = null;
for (int i = 0; i < n; i++) { List<Conn> tried = new ArrayList<Conn>();
if (errors[i] != null) { sb.append("\n").append(kindLabel(connections.get(i).kind)).append(": ").append(errors[i]); break; }
// 1. last known good
for (int i = 0; i < order.size(); i++) {
Conn c = order.get(i);
if (c.uri.equals(lastGoodUri)) {
tried.add(c);
String err = probe(c);
if (err == null) return choose(c);
firstError = kindLabel(c.kind) + ": " + err;
break;
}
} }
throw new IOException(sb.toString());
// 2. plain LAN addresses together
final List<Conn> plain = new ArrayList<Conn>();
if (!preferSecureAtHome) {
for (int i = 0; i < order.size(); i++) if (isPlainLan(order.get(i)) && !tried.contains(order.get(i))) plain.add(order.get(i));
}
if (!plain.isEmpty()) {
final String[] errs = new String[plain.size()];
final int[] state = new int[plain.size()];
final Object lock = new Object();
for (int i = 0; i < plain.size(); i++) {
final int idx = i;
com.minamp.net.Threads.start(new Runnable() {
public void run() {
String e = probe(plain.get(idx));
synchronized (lock) { errs[idx] = e; state[idx] = e == null ? 1 : 2; lock.notifyAll(); }
}
}, "probe-" + i);
}
long deadline = System.currentTimeMillis() + 7000;
synchronized (lock) {
while (true) {
boolean pending = false;
for (int i = 0; i < plain.size(); i++) {
if (state[i] == 1) return choose(plain.get(i));
if (state[i] == 0) pending = true;
}
long left = deadline - System.currentTimeMillis();
if (!pending || left <= 0) break;
try { lock.wait(left); } catch (InterruptedException e) { break; }
}
}
for (int i = 0; i < plain.size(); i++) {
tried.add(plain.get(i));
if (firstError == null && errs[i] != null) firstError = kindLabel(plain.get(i).kind) + ": " + errs[i];
}
}
// 3. everything else, one at a time, in preference order
for (int i = 0; i < order.size(); i++) {
Conn c = order.get(i);
if (tried.contains(c)) continue;
String err = probe(c);
if (err == null) return choose(c);
if (firstError == null) firstError = kindLabel(c.kind) + ": " + err;
}
throw new IOException("Can't reach " + (name.length() > 0 ? name : "server") + (firstError != null ? "\n" + firstError : ""));
} }
// ------------------------------------------------------------------ requests // ------------------------------------------------------------------ requests
@@ -266,7 +322,7 @@ public final class Server {
public Json.Obj toJson() { public Json.Obj toJson() {
Json.Obj o = new Json.Obj(); Json.Obj o = new Json.Obj();
o.put("name", name).put("id", machineId).put("token", token).put("owned", Boolean.valueOf(owned)); o.put("name", name).put("id", machineId).put("token", token).put("owned", Boolean.valueOf(owned)).put("last", lastGoodUri);
Json.Arr a = new Json.Arr(); Json.Arr a = new Json.Arr();
for (int i = 0; i < connections.size(); i++) { for (int i = 0; i < connections.size(); i++) {
Conn c = connections.get(i); Conn c = connections.get(i);
@@ -282,6 +338,7 @@ public final class Server {
s.machineId = o.optString("id"); s.machineId = o.optString("id");
s.token = o.optString("token"); s.token = o.optString("token");
s.owned = o.optBool("owned"); s.owned = o.optBool("owned");
s.lastGoodUri = o.optString("last", "");
Json.Arr a = o.optArr("conns"); Json.Arr a = o.optArr("conns");
if (a != null) { if (a != null) {
for (int i = 0; i < a.size(); i++) { for (int i = 0; i < a.size(); i++) {
+49
View File
@@ -0,0 +1,49 @@
import java.io.File;
import java.util.*;
import com.minamp.net.*;
/** Samples the handshake thread's stack to see where TLS time goes (run with -Xint). */
public class HandshakeProfile {
public static void main(String[] a) throws Exception {
CertVerifier.setRootStore(new PlexTest.RootDir(new File(a[0])));
final String url = a[1];
final int rounds = Integer.parseInt(a[2]);
final Map<String, Integer> buckets = new TreeMap<String, Integer>();
final long[] times = new long[rounds];
final Thread[] worker = new Thread[1];
worker[0] = Threads.create(new Runnable() { public void run() {
for (int i = 0; i < rounds; i++) {
Http.evictAll();
long t0 = System.nanoTime();
try { Http.get(url, null, 60000).readAll(); } catch (Exception e) { System.out.println(e); }
times[i] = (System.nanoTime() - t0) / 1000000;
}
}}, "hs");
worker[0].start();
while (worker[0].isAlive()) {
StackTraceElement[] st = worker[0].getStackTrace();
String b = "other";
for (StackTraceElement e : st) {
String c = e.getClassName() + "." + e.getMethodName();
if (c.startsWith("com.minamp.net.CertVerifier.signatureValid")) { b = "our chain check: signature math"; break; }
if (c.startsWith("com.minamp.net.CertVerifier")) { b = "our chain check: other"; break; }
if (c.contains("Ed25519") || c.contains("X25519") || c.contains("rfc7748")) { b = "X25519 key exchange"; break; }
if (c.contains("ECDSASigner") || c.contains("DSADigestSigner")) { b = "ECDSA verify (server signature)"; break; }
if (c.contains("RSA")) { b = "RSA"; break; }
if (c.contains("math.ec")) { b = "EC math (other)"; break; }
if (c.contains("asn1")) { b = "ASN.1 parsing"; break; }
if (c.contains("ChaCha") || c.contains("Poly1305") || c.contains("AES") || c.contains("GCM")) { b = "symmetric cipher"; break; }
if (c.contains("Digest") || c.contains("HMAC") || c.contains("HKDF")) { b = "hashing/HKDF"; break; }
if (c.contains("SecureRandom") || c.contains("Random")) { b = "random"; break; }
if (c.contains("java.net") || c.contains("SocketInputStream") || c.contains("NioSocket") || c.contains("read0")) { b = "network wait"; break; }
}
Integer n = buckets.get(b); buckets.put(b, n == null ? 1 : n + 1);
Thread.sleep(3);
}
int total = 0; for (int v : buckets.values()) total += v;
System.out.println("handshake ms: " + Arrays.toString(times));
List<Map.Entry<String, Integer>> l = new ArrayList<Map.Entry<String, Integer>>(buckets.entrySet());
Collections.sort(l, new Comparator<Map.Entry<String, Integer>>() { public int compare(Map.Entry<String, Integer> x, Map.Entry<String, Integer> y) { return y.getValue() - x.getValue(); } });
for (Map.Entry<String, Integer> e : l) System.out.printf(" %5.1f%% %s%n", 100.0 * e.getValue() / total, e.getKey());
}
}
+39
View File
@@ -196,6 +196,45 @@ public class PlexTest {
Server.Conn c3 = s3.pick(); Server.Conn c3 = s3.pick();
check(c3.kind == Server.LOCAL_HTTP, "falls back when TLS name check fails -> " + c3.uri); check(c3.kind == Server.LOCAL_HTTP, "falls back when TLS name check fails -> " + c3.uri);
// --- TLS 1.2 session resumption: the second connection must skip the full handshake
com.minamp.net.DiagLog.clear();
Http.evictAll();
Thread.sleep(300);
Http.get("https://127-0-0-1.abc123.plex.direct:32443/identity", null, 10000).readAll();
Http.evictAll();
Thread.sleep(300);
long tr = System.currentTimeMillis();
Http.get("https://127-0-0-1.abc123.plex.direct:32443/identity", null, 10000).readAll();
tr = System.currentTimeMillis() - tr;
String dl = com.minamp.net.DiagLog.dump();
check(dl.indexOf("TLS resumed") >= 0, "second TLS connection resumed the session (" + tr + "ms)");
// --- picking: last good first, and a dead preferred address doesn't block a working one
Server s5 = new Server();
s5.machineId = "machine-abc123"; s5.token = s.token; s5.name = "x";
s5.add("https://10-255-255-1.abc123.plex.direct:9", Server.LOCAL); // unreachable
s5.add("http://127.0.0.1:32400", Server.LOCAL_HTTP);
s5.add("https://127-0-0-1.abc123.plex.direct:32443", Server.REMOTE);
s5.sortConnections();
long tp = System.currentTimeMillis();
Server.Conn c5 = s5.pick();
tp = System.currentTimeMillis() - tp;
check(c5.kind == Server.LOCAL_HTTP && tp < 3000, "plain home address chosen straight away (" + tp + "ms), not stuck behind HTTPS");
Server s6 = Server.fromJson(Json.parseObj(s5.toJson().toString()));
check(s6.lastGoodUri.equals("http://127.0.0.1:32400"), "working address remembered across restarts");
s6.lastGoodUri = "https://127-0-0-1.abc123.plex.direct:32443";
com.minamp.net.DiagLog.clear();
Server.Conn c6 = s6.pick();
check(c6.kind == Server.REMOTE && com.minamp.net.DiagLog.dump().indexOf("127.0.0.1:32400") < 0, "last good connection tried first, nothing else probed");
Server s7 = new Server();
s7.machineId = "machine-abc123"; s7.token = s.token; s7.name = "x";
s7.add("http://10.255.255.1:32400", Server.LOCAL_HTTP); // away from home: LAN dead
s7.add("https://127-0-0-1.abc123.plex.direct:32443", Server.REMOTE);
s7.add("https://127-0-0-1.abc123.plex.direct:32443/relay", Server.RELAY);
s7.sortConnections();
Server.Conn c7 = s7.pick();
check(c7.kind == Server.REMOTE, "away from home: falls through to remote (" + Server.kindLabel(c7.kind) + ")");
// --- manual LAN address without a token (server allows this network without sign-in) // --- manual LAN address without a token (server allows this network without sign-in)
Server lan = new Server(); Server lan = new Server();
lan.name = "manual"; lan.token = ""; lan.name = "manual"; lan.token = "";