From 6fd17bc9c59eb42bdc1933d177f8a44010f7752c Mon Sep 17 00:00:00 2001 From: xocko Date: Wed, 14 Oct 2026 21:16:09 +0200 Subject: [PATCH] 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 --- AndroidManifest.xml | 4 +- README.md | 13 ++- src/com/minamp/App.java | 13 +++ src/com/minamp/BaseActivity.java | 18 ++- src/com/minamp/net/CertVerifier.java | 27 ++++- src/com/minamp/net/Http.java | 21 ++++ src/com/minamp/plex/Server.java | 167 ++++++++++++++++++--------- test/HandshakeProfile.java | 49 ++++++++ test/PlexTest.java | 39 +++++++ 9 files changed, 284 insertions(+), 67 deletions(-) create mode 100644 test/HandshakeProfile.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index c51a895..6bfaa9f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ diff --git a/README.md b/README.md index 5cd34a2..bf688e7 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,15 @@ working one, re-checking whenever the phone switches network: | Order | Connection | When it's used | |---|---|---| -| 1 | Home network, HTTPS (`*.plex.direct`) | On your Wi-Fi | -| 2 | Home network, plain HTTP | Same, if your router blocks plex.direct DNS (rebind protection) | -| 3 | Remote, HTTPS | Away, if Remote Access is set up on the server | -| 4 | Plex Relay | Away, when the server isn't reachable directly (bandwidth-capped; Minamp caps itself at 192 kbps) | +| 1 | Whatever worked last time | Tried alone first, so a normal start costs one request | +| 2 | Home network, plain HTTP | On your Wi-Fi (fast: no encryption work for the phone) | +| 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 | 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. Without it, Relay still works. diff --git a/src/com/minamp/App.java b/src/com/minamp/App.java index 30cfc92..e22be2f 100644 --- a/src/com/minamp/App.java +++ b/src/com/minamp/App.java @@ -67,6 +67,19 @@ public class App extends Application { CertVerifier.setRootStore(new AssetRoots(this)); Library.preferredVariant = Math.max(0, Math.min(Library.VARIANTS - 1, prefs.getInt("tcVariant", 0))); 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(); PlayQueue.get().restore(this); diff --git a/src/com/minamp/BaseActivity.java b/src/com/minamp/BaseActivity.java index 206d85e..72be811 100644 --- a/src/com/minamp/BaseActivity.java +++ b/src/com/minamp/BaseActivity.java @@ -394,6 +394,7 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis "Quality at home: " + qualityLabel(app.qualityHome()), "Quality away / mobile data: " + qualityLabel(app.qualityAway()), "Download tracks before playing: " + (app.downloadFirst() ? "On" : "Off"), + "Encrypt at home too (slower): " + (Server.preferSecureAtHome ? "On" : "Off"), "Library: " + app.sectionTitle(), "Server: " + (s == null ? "-" : s.name), "Connection: " + conn, @@ -411,9 +412,16 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis app.prefs.edit().putBoolean("downloadFirst", !app.downloadFirst()).commit(); toast("Download before playing: " + (app.downloadFirst() ? "on" : "off")); break; - case 3: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_LIBRARY)); break; - case 4: startActivity(new Intent(BaseActivity.this, SetupActivity.class).putExtra(SetupActivity.EXTRA_MODE, SetupActivity.MODE_SERVER)); break; - case 5: + case 3: + Server.preferSecureAtHome = !Server.preferSecureAtHome; + 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) { app.server.invalidate(); toast("Reconnecting…"); @@ -425,8 +433,8 @@ public abstract class BaseActivity extends Activity implements PlayerService.Lis }); } break; - case 6: showDiagnostics(); break; - case 7: confirmSignOut(); break; + case 7: showDiagnostics(); break; + case 8: confirmSignOut(); break; } } }).show(); diff --git a/src/com/minamp/net/CertVerifier.java b/src/com/minamp/net/CertVerifier.java index 9f5c9d3..5baa5e7 100644 --- a/src/com/minamp/net/CertVerifier.java +++ b/src/com/minamp/net/CertVerifier.java @@ -45,14 +45,39 @@ public final class CertVerifier { } 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 verifiedChains = new java.util.HashSet(); - 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() {} + public static void verify(List chainDer, String host) throws IOException { if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate"); 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 chainDer, String host) throws IOException { List chain = new ArrayList(); for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i))); diff --git a/src/com/minamp/net/Http.java b/src/com/minamp/net/Http.java index 6f0e5fc..eeaa32e 100644 --- a/src/com/minamp/net/Http.java +++ b/src/com/minamp/net/Http.java @@ -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). */ public static void evictAll() { final List doomed = new ArrayList(); @@ -191,6 +197,8 @@ public final class Http { private final String host; private final String sessionKey; + private final long started = System.currentTimeMillis(); + Client(TlsCrypto crypto, String host, String sessionKey) { super(crypto); this.host = host; @@ -209,6 +217,16 @@ public final class Http { 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 public TlsSession getSessionToResume() { synchronized (sessions) { return sessions.get(sessionKey); } @@ -217,6 +235,9 @@ public final class Http { @Override public void notifyHandshakeComplete() throws IOException { 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(); if (s != null && s.isResumable()) { synchronized (sessions) { sessions.put(sessionKey, s); } diff --git a/src/com/minamp/plex/Server.java b/src/com/minamp/plex/Server.java index 9ceca66..8b749b4 100644 --- a/src/com/minamp/plex/Server.java +++ b/src/com/minamp/plex/Server.java @@ -123,68 +123,124 @@ public final class Server { 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. - * Returns as soon as the best remaining candidate has answered. + * Finds a working connection, cheapest first, never running more than one TLS handshake at a + * 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 { if (active != null) return active; if (connections.isEmpty()) throw new IOException("No connections for " + name); - final int n = connections.size(); - final int[] state = new int[n]; // 0 pending, 1 ok, 2 failed - final String[] errors = new String[n]; - final Object lock = new Object(); - for (int i = 0; i < n; 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; } + List order = new ArrayList(connections); + if (preferSecureAtHome) { + // home HTTPS ahead of home HTTP + for (int i = 0; i < order.size(); i++) { + if (order.get(i).kind == LOCAL) { order.add(0, order.remove(i)); } } } - StringBuilder sb = new StringBuilder("Can't reach " + (name.length() > 0 ? name : "server")); - for (int i = 0; i < n; i++) { - if (errors[i] != null) { sb.append("\n").append(kindLabel(connections.get(i).kind)).append(": ").append(errors[i]); break; } + String firstError = null; + List tried = new ArrayList(); + + // 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 plain = new ArrayList(); + 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 @@ -266,7 +322,7 @@ public final class Server { public Json.Obj toJson() { 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(); for (int i = 0; i < connections.size(); i++) { Conn c = connections.get(i); @@ -282,6 +338,7 @@ public final class Server { s.machineId = o.optString("id"); s.token = o.optString("token"); s.owned = o.optBool("owned"); + s.lastGoodUri = o.optString("last", ""); Json.Arr a = o.optArr("conns"); if (a != null) { for (int i = 0; i < a.size(); i++) { diff --git a/test/HandshakeProfile.java b/test/HandshakeProfile.java new file mode 100644 index 0000000..d558022 --- /dev/null +++ b/test/HandshakeProfile.java @@ -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 buckets = new TreeMap(); + 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> l = new ArrayList>(buckets.entrySet()); + Collections.sort(l, new Comparator>() { public int compare(Map.Entry x, Map.Entry y) { return y.getValue() - x.getValue(); } }); + for (Map.Entry e : l) System.out.printf(" %5.1f%% %s%n", 100.0 * e.getValue() / total, e.getKey()); + } +} diff --git a/test/PlexTest.java b/test/PlexTest.java index cbdc1d8..e4840e0 100644 --- a/test/PlexTest.java +++ b/test/PlexTest.java @@ -196,6 +196,45 @@ public class PlexTest { Server.Conn c3 = s3.pick(); 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) Server lan = new Server(); lan.name = "manual"; lan.token = "";