Private
Public Access
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:
1 parent
58d21b6d68
commit
6fd17bc9c5
9 files changed
+284
-67
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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
@@ -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++) {
|
||||
|
||||
Reference in new issue
Block a user