MiniABS 1.7: bundled TLS 1.2, no legacy HTTPS port needed

All requests (API, covers, downloads, the streaming proxy) go through Minamp's own
HTTP/TLS client (Bouncy Castle TLS 1.2, Mozilla roots, keep-alive, session
resumption) via NetConnection, a drop-in HttpURLConnection, as the iPod version does
with BearSSL. The phone reaches the normal https:// address directly; nothing changes
on the server.

- Trust: Mozilla roots + the user's pinned CAs/certificates feed the new verifier; the
  trust prompt, renew-safe CA pinning and forged-chain protection behave as before
- PATCH is a normal request now (no raw-socket path)
- Job, covers, player, downloads and proxy threads get big stacks for the handshake
- embedded Roots.java and gen_roots.py replaced by assets/roots
- tests: desktop shims read assets; all 104 checks pass (HTTP and HTTPS)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-10-17 19:20:53 +02:00
1 parent 6cbe3596e4
commit 06a5c7078f
145 files changed
+1400 -549

No files matched your search

+19 -59
View File
@@ -26,7 +26,7 @@ import android.os.Build;
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api {
static final String VERSION = "1.6";
static final String VERSION = "1.7";
static class ApiException extends IOException {
final int code;
@@ -57,12 +57,12 @@ final class Api {
// ------------------------------------------------------------------ plumbing
static HttpURLConnection open(Context c, String url, String method) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
Trust.install(c);
HttpURLConnection conn = new NetConnection(new URL(url));
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true);
if (conn instanceof HttpsURLConnection) Trust.apply(c, (HttpsURLConnection) conn);
setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn;
@@ -73,71 +73,31 @@ final class Api {
}
/**
* PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT,
* and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session.
* PATCH: Audiobookshelf uses it for "mark as played" and for saving progress without a session.
* (Android 2.x's own HttpURLConnection can't send it; the bundled client can.)
*/
private static Object rawPatch(Context c, String path, JSONObject body, boolean mayRefresh) throws IOException {
Prefs p = Prefs.get(c);
if (mayRefresh) ensureFresh(c, 120);
String used = p.token();
URL u = new URL(p.server() + path);
boolean https = "https".equalsIgnoreCase(u.getProtocol());
int port = u.getPort() > 0 ? u.getPort() : (https ? 443 : 80);
Socket sock = new Socket();
HttpURLConnection conn = open(c, p.server() + path, "PATCH");
conn.setInstanceFollowRedirects(false);
conn.setRequestProperty("Authorization", "Bearer " + used);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
conn.setDoOutput(true);
int code;
try {
sock.connect(new InetSocketAddress(u.getHost(), port), 15000);
sock.setSoTimeout(30000);
Socket io = sock;
if (https) {
SSLSocket ss = (SSLSocket) Trust.factory(c).createSocket(sock, u.getHost(), port, true);
try { // send the host name (SNI) where the phone supports it (Android 2.3+)
ss.getClass().getMethod("setHostname", String.class).invoke(ss, u.getHost());
} catch (Exception ignored) {
}
ss.startHandshake();
java.security.cert.Certificate[] peer = ss.getSession().getPeerCertificates();
if (peer.length == 0 || !(peer[0] instanceof java.security.cert.X509Certificate)
|| !Trust.hostMatches((java.security.cert.X509Certificate) peer[0], u.getHost())) {
ss.close();
throw new SSLException("O certificado non corresponde a " + u.getHost());
}
io = ss;
OutputStream os = conn.getOutputStream();
os.write((body == null ? "{}" : body.toString()).getBytes("UTF-8"));
code = conn.getResponseCode();
InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
if (in != null) {
byte[] rest = new byte[1024];
while (in.read(rest) > 0) { } // read the reply so the connection can be reused
}
byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8");
String host = u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
String head = "PATCH " + u.getFile() + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "Authorization: Bearer " + used + "\r\n"
+ "Content-Type: application/json\r\n"
+ "Accept: application/json\r\n"
+ "User-Agent: MiniABS/" + VERSION + "\r\n"
+ "Content-Length: " + b.length + "\r\n"
+ "Connection: close\r\n\r\n";
OutputStream os = io.getOutputStream();
os.write(head.getBytes("UTF-8"));
os.write(b);
os.flush();
InputStream in = io.getInputStream();
StringBuilder line = new StringBuilder();
int ch;
while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch);
String[] parts = line.toString().trim().split(" ");
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Resposta inesperada do servidor");
try {
code = Integer.parseInt(parts[1]);
} catch (NumberFormatException e) {
throw new IOException("Resposta inesperada do servidor");
}
byte[] rest = new byte[1024];
int total = 0, n;
while (total < 16384 && (n = in.read(rest)) > 0) total += n; // read the reply politely
io.close();
} finally {
try {
sock.close();
} catch (IOException ignored) {
}
conn.disconnect();
}
if (code == 401) {
if (mayRefresh && p.canRefresh()) {