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

+13
View File
@@ -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);
+13 -5
View File
@@ -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();
+26 -1
View File
@@ -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<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() {}
public static void verify(List<byte[]> 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<byte[]> chainDer, String host) throws IOException {
List<Certificate> chain = new ArrayList<Certificate>();
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). */
public static void evictAll() {
final List<Conn> doomed = new ArrayList<Conn>();
@@ -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); }
+112 -55
View File
@@ -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<Conn> order = new ArrayList<Conn>(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<Conn> tried = new ArrayList<Conn>();
// 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
@@ -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++) {