GingerCal 1.4: bundled TLS 1.2, no bridge needed

CalDAV now goes through Minamp's own HTTP/TLS client (Bouncy Castle TLS 1.2, Mozilla
roots, keep-alive) instead of Apache HttpClient on Android 2.3's TLS 1.0, as the iPod
version does with BearSSL. The phone reaches the normal https:// Nextcloud address at
home and away; a pinned self-signed server or the old bridge still works.

- NetHttp transport; requests run on big-stack threads (the handshake overflows
  AsyncTask/IntentService stacks on Android 2.x)
- http:// -> https:// redirects on the same host are followed; other hosts still refused
- bridge wording replaced in Galician and English; README updated
- NetTest (TLS + CalDAV discovery through the new transport) replaces BridgeTest

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:14:06 +02:00
1 parent 42c3d6da91
commit f7428287eb
145 files changed
+1278 -407

No files matched your search

-177
View File
@@ -1,177 +0,0 @@
package org.gingercal;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.security.GeneralSecurityException;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.conn.scheme.LayeredSocketFactory;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
/**
* Android 2.3 transport. HttpURLConnection refuses PROPFIND/REPORT there, so this uses the
* bundled Apache HttpClient, plus a TLS layer that supports certificate pinning (needed for a
* self-signed legacy-TLS bridge) and SNI where the platform offers it.
*/
public class ApacheHttp implements Http {
final DefaultHttpClient client;
final Tls.PinTrust trust;
public ApacheHttp(String pin, int timeoutMs) throws GeneralSecurityException {
trust = new Tls.PinTrust(pin);
HttpParams p = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(p, timeoutMs);
HttpConnectionParams.setSoTimeout(p, timeoutMs);
HttpConnectionParams.setStaleCheckingEnabled(p, true);
HttpProtocolParams.setUseExpectContinue(p, false);
HttpProtocolParams.setUserAgent(p, "GingerCal/1.0 (Android 2.3)");
p.setBooleanParameter("http.protocol.handle-redirects", false);
SchemeRegistry reg = new SchemeRegistry();
reg.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
reg.register(new Scheme("https", new TlsFactory(trust), 443));
client = new DefaultHttpClient(new ThreadSafeClientConnManager(p, reg), p);
}
static class Req extends HttpEntityEnclosingRequestBase {
final String m;
Req(String m, URI u) {
this.m = m;
setURI(u);
}
@Override
public String getMethod() {
return m;
}
}
public Resp exec(String method, String url, String[] headers, String body, String contentType)
throws IOException {
Req r = new Req(method, URI.create(url));
for (int i = 0; i + 1 < headers.length; i += 2) r.addHeader(headers[i], headers[i + 1]);
if (body != null) {
StringEntity e = new StringEntity(body, "UTF-8");
e.setContentType(contentType);
r.setEntity(e);
}
trust.reset();
HttpResponse res;
try {
res = client.execute(r);
} catch (IOException e) {
throw trust.translate(e);
}
Resp out = new Resp();
out.code = res.getStatusLine().getStatusCode();
Header h = res.getFirstHeader("ETag");
if (h != null) out.etag = h.getValue();
h = res.getFirstHeader("Location");
if (h != null) out.location = h.getValue();
HttpEntity en = res.getEntity();
if (en != null) {
InputStream in = en.getContent();
try {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) bo.write(buf, 0, n);
out.body = new String(bo.toByteArray(), "UTF-8");
} finally {
en.consumeContent();
}
}
return out;
}
public void close() {
client.getConnectionManager().shutdown();
}
// ------------------------------------------------------------------ TLS
static class TlsFactory implements LayeredSocketFactory {
final SSLSocketFactory f;
final Tls.PinTrust trust;
TlsFactory(Tls.PinTrust trust) throws GeneralSecurityException {
this.trust = trust;
SSLContext c = SSLContext.getInstance("TLS");
c.init(null, new TrustManager[] {trust}, null);
f = c.getSocketFactory();
}
public Socket createSocket() throws IOException {
return f.createSocket();
}
public Socket connectSocket(Socket sock, String host, int port, InetAddress local, int localPort,
HttpParams params) throws IOException {
SSLSocket s = (SSLSocket) (sock != null ? sock : createSocket());
if (local != null || localPort > 0) {
s.bind(new InetSocketAddress(local, localPort < 0 ? 0 : localPort));
}
sni(s, host);
s.connect(new InetSocketAddress(host, port), HttpConnectionParams.getConnectionTimeout(params));
s.setSoTimeout(HttpConnectionParams.getSoTimeout(params));
s.startHandshake();
verifyHost(s, host);
return s;
}
public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException {
SSLSocket s = (SSLSocket) f.createSocket(socket, host, port, autoClose);
sni(s, host);
s.startHandshake();
verifyHost(s, host);
return s;
}
public boolean isSecure(Socket sock) {
return true;
}
void verifyHost(SSLSocket s, String host) throws IOException {
if (trust.pinned()) return; // the pin identifies the exact certificate
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(host, s.getSession())) {
s.close();
throw new SSLException("Certificate is not valid for " + host);
}
}
/** Gingerbread's OpenSSLSocketImpl has a hidden setHostname() for SNI. */
static void sni(SSLSocket s, String host) {
if (host.matches("[0-9.]+") || host.indexOf(':') >= 0) return;
try {
Method m = s.getClass().getMethod("setHostname", String.class);
m.invoke(s, host);
} catch (Throwable ignored) {
}
}
}
}
+5 -5
View File
@@ -67,7 +67,7 @@ public final class App {
}
public static Dav dav(String url, String user, String pass, String pin) throws Exception {
return new Dav(new ApacheHttp(pin, 30000), url, user, pass);
return new Dav(new NetHttp(pin, 30000), url, user, pass);
}
public static boolean online(Context c) {
@@ -126,9 +126,9 @@ public final class App {
if (t instanceof UnknownHostException) {
return L.f("Can't resolve %s. No connection, or a typo in the server address.", t.getMessage());
}
if (t instanceof ConnectException) return L.f("Can't connect to the server (%s). Wrong port, or the bridge is down?", t.getMessage());
if (t instanceof ConnectException) return L.f("Can't connect to the server (%s). Wrong address or port, or the server is down?", t.getMessage());
if (t instanceof SocketTimeoutException) return L.t("The server took too long to answer.");
if (t instanceof SSLException) return L.f("TLS error: %s", t.getMessage());
if (t instanceof SSLException || t instanceof com.minamp.bc.tls.TlsException) return L.f("TLS error: %s", t.getMessage());
if (t instanceof IOException) return L.f("Network error: %s", t);
if (t instanceof OutOfMemoryError) return L.t("Out of memory. Lower 'Days ahead' in Settings.");
return L.f("Error: %s", t);
@@ -144,8 +144,8 @@ public final class App {
public static void askTrust(Activity a, final DavException.UntrustedCert u, final Trusted cb) {
String msg = (u.pinMismatch
? L.t("WARNING: the server certificate changed since you trusted it. That happens when you regenerate the bridge certificate - or when someone is intercepting the connection.\n\n")
: L.t("This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, or for Let's Encrypt on Android 2.3).\n\n"))
? L.t("WARNING: the server certificate changed since you trusted it. That happens when a self-signed certificate is regenerated - or when someone is intercepting the connection.\n\n")
: L.t("The server certificate isn't signed by a known authority (normal for a self-signed server or an old TLS 1.0 bridge).\n\n"))
+ L.f("Subject: %s\nIssuer: %s\nExpires: %s\n\nSHA-256:\n%s\n\nOnly trust it if this matches the fingerprint printed on your server.",
u.subject, u.issuer, u.expires, u.fingerprint);
new AlertDialog.Builder(a)
+10 -1
View File
@@ -2,11 +2,20 @@ package org.gingercal;
import android.app.Application;
/** Runs before any screen, service or alarm: sets the interface language (Galician by default). */
/** Runs before any screen, service or alarm: interface language (Galician by default), bundled TLS. */
public class GingerApp extends Application {
@Override
public void onCreate() {
super.onCreate();
L.gl = !"en".equals(getSharedPreferences("gingercal", 0).getString("lang", "gl"));
org.gingercal.net.CertVerifier.setRootStore(new org.gingercal.net.AssetRoots(this));
org.gingercal.net.Http.USER_AGENT = "GingerCal/1.4 (Android 2.3)";
tlsTexts();
}
/** The bundled TLS stack's messages, in the current language. */
static void tlsTexts() {
org.gingercal.net.CertVerifier.msgMismatch = L.t("The server certificate is for a different address (%1$s).");
org.gingercal.net.CertVerifier.msgDates = L.t("The server certificate has expired, or the phone's date is wrong.");
}
}
+78
View File
@@ -0,0 +1,78 @@
package org.gingercal;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import org.gingercal.net.CertVerifier;
/**
* Android 2.3 transport through the bundled TLS 1.2 client (org.gingercal.net, from Minamp):
* the phone's own TLS 1.0 can't reach Nextcloud, so this talks to the normal https:// address
* directly, any method (PROPFIND/REPORT included), with keep-alive. A pinned certificate (a
* self-signed server or an old bridge) is still honoured.
*/
public class NetHttp implements Http {
/** AsyncTask and IntentService threads have Android 2.x's small default stack, which the
* TLS handshake overflows: requests run here, on big-stack threads, and the caller waits. */
private static final ExecutorService NET = org.gingercal.net.Threads.pool("net", 3);
final String pin;
final int timeoutMs;
public NetHttp(String pin, int timeoutMs) {
this.pin = pin == null ? "" : pin;
this.timeoutMs = timeoutMs;
}
public Resp exec(final String method, final String url, final String[] headers, final String body,
final String contentType) throws IOException {
try {
return NET.submit(new Callable<Resp>() {
public Resp call() throws IOException {
return execHere(method, url, headers, body, contentType);
}
}).get();
} catch (ExecutionException e) {
Throwable c = e.getCause();
if (c instanceof IOException) throw (IOException) c;
if (c instanceof RuntimeException) throw (RuntimeException) c;
if (c instanceof Error) throw (Error) c;
throw new IOException(String.valueOf(c));
} catch (InterruptedException e) {
throw new IOException(L.t("Interrupted"));
}
}
Resp execHere(String method, String url, String[] headers, String body, String contentType)
throws IOException {
CertVerifier.setPin(pin);
Map<String, String> h = new LinkedHashMap<String, String>();
for (int i = 0; i + 1 < headers.length; i += 2) h.put(headers[i], headers[i + 1]);
byte[] data = null;
if (body != null) {
data = body.getBytes("UTF-8");
if (contentType != null) h.put("Content-Type", contentType);
}
org.gingercal.net.Http.Response r;
try {
// one hop at a time: Dav decides which redirects are safe to follow
r = org.gingercal.net.Http.requestNoRedirect(method, url, h, data, timeoutMs);
} catch (CertVerifier.Untrusted u) {
throw new DavException.UntrustedCert(u.fingerprint, u.subject, u.issuer, u.expires, u.changed);
}
Resp out = new Resp();
out.code = r.code;
out.etag = r.header("etag");
out.location = r.header("location");
out.body = r.readString();
return out;
}
public void close() {
// pooled connections are shared and expire by themselves
}
}
+4 -3
View File
@@ -31,8 +31,8 @@ public class SettingsActivity extends Activity {
LinearLayout f = App.vbox(this);
int p = App.dp(this, 8);
f.setPadding(p, p, p, p);
f.addView(App.text(this, L.t("Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home Wi-Fi. Use an app password, not your real one."), 12, 0xFFB0BEC5));
url = field(f, L.t("Server address"), "https://cloud.example.com:8443", 1 | 0x10 /* URI */);
f.addView(App.text(this, L.t("Nextcloud server: your normal https:// address. This app brings its own modern TLS, so no bridge is needed, at home or away. Use an app password, not your real one."), 12, 0xFFB0BEC5));
url = field(f, L.t("Server address"), "https://cloud.example.com", 1 | 0x10 /* URI */);
user = field(f, L.t("User name"), "", 1 | 0x80000 /* no suggestions */);
pass = field(f, L.t("App password"), "", 1 | 0x80 /* password */);
pin = field(f, L.t("Pinned certificate SHA-256 (filled in for you)"), L.t("optional"), 1 | 0x80000);
@@ -99,6 +99,7 @@ public class SettingsActivity extends Activity {
save();
App.prefs(SettingsActivity.this).edit().putString("lang", toGl ? "gl" : "en").commit();
L.gl = toGl;
GingerApp.tlsTexts();
finish(); // reopen so every label is redrawn in the new language
startActivity(new Intent(SettingsActivity.this, SettingsActivity.class));
}
@@ -188,7 +189,7 @@ public class SettingsActivity extends Activity {
}
if (Dav.normalize(u).startsWith("http://") && !App.isLan(u)) {
new AlertDialog.Builder(this).setTitle(L.t("Unencrypted connection"))
.setMessage(L.t("http:// sends your password in clear text over the internet. Use the legacy-TLS bridge instead, or only use http:// to a LAN address."))
.setMessage(L.t("http:// sends your password in clear text over the internet. Use the https:// address instead, or only use http:// to a LAN address."))
.setPositiveButton(L.t("Use anyway"), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
save();
+50
View File
@@ -0,0 +1,50 @@
package org.gingercal.net;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import android.content.Context;
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
public final class AssetRoots implements CertVerifier.RootStore {
private final Context ctx;
private Set<String> names;
public AssetRoots(Context c) { ctx = c.getApplicationContext(); }
public synchronized List<byte[]> find(byte[] subjectDer) {
try {
if (names == null) {
names = new HashSet<String>();
String[] l = ctx.getAssets().list("roots");
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
}
String h = CertVerifier.sha1Hex(subjectDer);
List<byte[]> out = null;
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
if (out == null) out = new ArrayList<byte[]>();
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
}
return out;
} catch (IOException e) {
return null;
}
}
static byte[] readAll(InputStream in) throws IOException {
try {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
return b.toByteArray();
} finally {
in.close();
}
}
}
+323
View File
@@ -0,0 +1,323 @@
package org.gingercal.net;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import com.minamp.bc.asn1.ASN1Encoding;
import com.minamp.bc.asn1.ASN1ObjectIdentifier;
import com.minamp.bc.asn1.ASN1OctetString;
import com.minamp.bc.asn1.pkcs.PKCSObjectIdentifiers;
import com.minamp.bc.asn1.x500.RDN;
import com.minamp.bc.asn1.x500.X500Name;
import com.minamp.bc.asn1.x500.style.BCStyle;
import com.minamp.bc.asn1.x500.style.IETFUtils;
import com.minamp.bc.asn1.x509.BasicConstraints;
import com.minamp.bc.asn1.x509.Certificate;
import com.minamp.bc.asn1.x509.Extension;
import com.minamp.bc.asn1.x509.Extensions;
import com.minamp.bc.asn1.x509.GeneralName;
import com.minamp.bc.asn1.x509.GeneralNames;
import com.minamp.bc.asn1.x9.X9ObjectIdentifiers;
import com.minamp.bc.crypto.Digest;
import com.minamp.bc.crypto.digests.SHA1Digest;
import com.minamp.bc.crypto.digests.SHA256Digest;
import com.minamp.bc.crypto.digests.SHA384Digest;
import com.minamp.bc.crypto.digests.SHA512Digest;
import com.minamp.bc.crypto.params.AsymmetricKeyParameter;
import com.minamp.bc.crypto.signers.DSADigestSigner;
import com.minamp.bc.crypto.signers.ECDSASigner;
import com.minamp.bc.crypto.signers.RSADigestSigner;
import com.minamp.bc.crypto.util.PublicKeyFactory;
/**
* Minimal Web PKI path validation for the bundled TLS stack:
* hostname match, validity window, signature chain up to a bundled Mozilla root.
* Optionally one pinned leaf certificate (SHA-256) is trusted as-is, for self-signed servers.
* Shared with Minamp (tools/tlskit in the iOS ports' history); keep the copies in sync.
*/
public final class CertVerifier {
/** Texts shown to the user; apps replace them with their own language. Formats take the host. */
public static String msgMismatch = "The server certificate is for a different address (%1$s).";
public static String msgDates = "The server certificate has expired, or the phone's date is wrong.";
/** Thrown when the certificate is neither CA-trusted nor the pinned one: the app can offer to trust it. */
public static final class Untrusted extends IOException {
public final String fingerprint, subject, issuer, expires;
public final boolean changed; // a pin was set and this is a different certificate
Untrusted(String fp, String subject, String issuer, String expires, boolean changed) {
super("untrusted certificate " + fp);
this.fingerprint = fp;
this.subject = subject;
this.issuer = issuer;
this.expires = expires;
this.changed = changed;
}
}
private static volatile String pin = "";
/** SHA-256 of the one leaf certificate to accept without a CA (hex, separators ignored); "" for none. */
public static void setPin(String p) {
String n = norm(p);
if (!n.equals(pin)) {
pin = n;
Http.forgetSessions();
}
}
public static String norm(String s) {
if (s == null) return "";
return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US);
}
/** "AB:CD:..." SHA-256 of a DER certificate. */
public static String sha256(byte[] der) {
try {
byte[] d = MessageDigest.getInstance("SHA-256").digest(der);
StringBuilder b = new StringBuilder();
for (int i = 0; i < d.length; i++) {
if (i > 0) b.append(':');
String x = Integer.toHexString(d[i] & 0xff).toUpperCase(Locale.US);
if (x.length() == 1) b.append('0');
b.append(x);
}
return b.toString();
} catch (Exception e) {
return "";
}
}
private static Untrusted untrusted(byte[] leafDer, boolean changed) {
String subject = "?", issuer = "?", expires = "?";
try {
Certificate c = Certificate.getInstance(leafDer);
subject = String.valueOf(c.getSubject());
issuer = String.valueOf(c.getIssuer());
expires = new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US).format(c.getEndDate().getDate());
} catch (Exception ignored) {}
return new Untrusted(sha256(leafDer), subject, issuer, expires, changed);
}
/** Lookup of trusted root certificates (DER) by the DER encoding of their subject name. */
public interface RootStore {
List<byte[]> find(byte[] subjectDer);
}
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>();
/** 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");
String p = pin;
if (p.length() > 0) {
// a pinned server: exactly that certificate, whatever its name or issuer
if (norm(sha256(chainDer.get(0))).equals(p)) return;
throw untrusted(chainDer.get(0), true);
}
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(String.format(msgMismatch, 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)));
Certificate leaf = chain.get(0);
if (!hostMatches(leaf, host)) {
throw new IOException(String.format(msgMismatch, host));
}
Date now = new Date();
// An unset clock on an old phone (e.g. 1980 after a battery pull) would make every
// certificate look "not yet valid"; only enforce dates when the clock looks sane.
boolean clockSane = now.getTime() > 1704067200000L; // 2024-01-01
Certificate current = leaf;
for (int depth = 0; depth < 8; depth++) {
if (clockSane) checkDates(current, now);
byte[] issuerDer = current.getIssuer().getEncoded(ASN1Encoding.DER);
// 1. Anchored directly by a trusted root?
List<byte[]> anchors = roots.find(issuerDer);
if (anchors != null) {
for (int i = 0; i < anchors.size(); i++) {
Certificate root = Certificate.getInstance(anchors.get(i));
if (signatureValid(current, root)) return; // trusted
}
}
// 2. Otherwise find the issuing intermediate among what the server sent.
Certificate next = null;
for (int i = 1; i < chain.size(); i++) {
Certificate cand = chain.get(i);
if (cand == current) continue;
if (!java.util.Arrays.equals(cand.getSubject().getEncoded(ASN1Encoding.DER), issuerDer)) continue;
if (!isCa(cand)) continue;
if (signatureValid(current, cand)) { next = cand; break; }
}
if (next == null) throw untrusted(chainDer.get(0), false);
current = next;
}
throw new IOException("Certificate chain too long");
}
private static void checkDates(Certificate c, Date now) throws IOException {
Date from = c.getStartDate().getDate();
Date to = c.getEndDate().getDate();
if (now.before(from) || now.after(to)) {
throw new IOException(msgDates);
}
}
private static boolean isCa(Certificate c) {
Extensions ext = c.getTBSCertificate().getExtensions();
if (ext == null) return c.getTBSCertificate().getVersionNumber() < 3; // v1 roots/intermediates
BasicConstraints bc = BasicConstraints.fromExtensions(ext);
return bc != null && bc.isCA();
}
static boolean signatureValid(Certificate subject, Certificate issuer) {
try {
ASN1ObjectIdentifier alg = subject.getSignatureAlgorithm().getAlgorithm();
byte[] tbs = subject.getTBSCertificate().getEncoded(ASN1Encoding.DER);
byte[] sig = subject.getSignature().getOctets();
AsymmetricKeyParameter key = PublicKeyFactory.createKey(issuer.getSubjectPublicKeyInfo());
if (alg.equals(PKCSObjectIdentifiers.sha256WithRSAEncryption)) return rsa(new SHA256Digest(), key, tbs, sig);
if (alg.equals(PKCSObjectIdentifiers.sha384WithRSAEncryption)) return rsa(new SHA384Digest(), key, tbs, sig);
if (alg.equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) return rsa(new SHA512Digest(), key, tbs, sig);
if (alg.equals(PKCSObjectIdentifiers.sha1WithRSAEncryption)) return rsa(new SHA1Digest(), key, tbs, sig);
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA256)) return ecdsa(new SHA256Digest(), key, tbs, sig);
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA384)) return ecdsa(new SHA384Digest(), key, tbs, sig);
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA512)) return ecdsa(new SHA512Digest(), key, tbs, sig);
return false;
} catch (Exception e) {
return false;
}
}
private static boolean rsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
RSADigestSigner s = new RSADigestSigner(d);
s.init(false, key);
s.update(tbs, 0, tbs.length);
return s.verifySignature(sig);
}
private static boolean ecdsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
DSADigestSigner s = new DSADigestSigner(new ECDSASigner(), d);
s.init(false, key);
s.update(tbs, 0, tbs.length);
return s.verifySignature(sig);
}
// ---- hostname checking ----
static boolean hostMatches(Certificate leaf, String host) {
host = host.toLowerCase(Locale.US);
boolean ip = isIpLiteral(host);
boolean sawDns = false;
Extensions ext = leaf.getTBSCertificate().getExtensions();
if (ext != null) {
GeneralNames names = GeneralNames.fromExtensions(ext, Extension.subjectAlternativeName);
if (names != null) {
GeneralName[] arr = names.getNames();
for (int i = 0; i < arr.length; i++) {
GeneralName n = arr[i];
if (n.getTagNo() == GeneralName.dNSName) {
sawDns = true;
// String.valueOf, not getName().toString(): Dalvik < 4.0 can't call Object methods via an interface
if (!ip && dnsMatch(String.valueOf(n.getName()), host)) return true;
} else if (n.getTagNo() == GeneralName.iPAddress && ip) {
byte[] a = ASN1OctetString.getInstance(n.getName()).getOctets();
if (ipString(a).equals(host)) return true;
}
}
}
}
if (!sawDns && !ip) {
X500Name subject = X500Name.getInstance(leaf.getSubject());
RDN[] cns = subject.getRDNs(BCStyle.CN);
for (int i = 0; i < cns.length; i++) {
String cn = IETFUtils.valueToString(cns[i].getFirst().getValue());
if (dnsMatch(cn, host)) return true;
}
}
return false;
}
static boolean dnsMatch(String pattern, String host) {
pattern = pattern.toLowerCase(Locale.US);
if (pattern.endsWith(".")) pattern = pattern.substring(0, pattern.length() - 1);
if (host.endsWith(".")) host = host.substring(0, host.length() - 1);
if (pattern.startsWith("*.")) {
String suffix = pattern.substring(1); // ".example.com"
if (!host.endsWith(suffix)) return false;
String label = host.substring(0, host.length() - suffix.length());
return label.length() > 0 && label.indexOf('.') < 0;
}
return pattern.equals(host);
}
static boolean isIpLiteral(String host) {
if (host.indexOf(':') >= 0) return true;
for (int i = 0; i < host.length(); i++) {
char c = host.charAt(i);
if (!(c == '.' || (c >= '0' && c <= '9'))) return false;
}
return true;
}
private static String ipString(byte[] a) {
if (a.length == 4) {
return (a[0] & 255) + "." + (a[1] & 255) + "." + (a[2] & 255) + "." + (a[3] & 255);
}
return "";
}
public static String sha1Hex(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] h = md.digest(data);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < h.length; i++) {
String s = Integer.toHexString(h[i] & 255);
if (s.length() == 1) sb.append('0');
sb.append(s);
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package org.gingercal.net;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* In-memory diagnostics log (last few hundred lines): every request with its status and timing,
* sign-in steps, stream attempts. Viewable in the app and savable to the SD card, so a problem
* on a real server can be reported precisely. Tokens are never logged.
*/
public final class DiagLog {
private static final int MAX = 400;
private static final List<String> lines = new ArrayList<String>();
private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US);
private DiagLog() {}
public static void log(String msg) {
String line;
synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; }
synchronized (lines) {
lines.add(line);
if (lines.size() > MAX) lines.remove(0);
}
}
public static String dump() {
StringBuilder sb = new StringBuilder();
synchronized (lines) {
for (int i = 0; i < lines.size(); i++) sb.append(lines.get(i)).append('\n');
}
return sb.toString();
}
public static void clear() {
synchronized (lines) { lines.clear(); }
}
/** URL without the token value, safe to show and share. */
public static String redact(String url) {
if (url == null) return "";
return url.replaceAll("((?:X-Plex-Token|token|T|Passwd)=)[^&]*", "$1***");
}
}
+588
View File
@@ -0,0 +1,588 @@
package org.gingercal.net;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Vector;
import com.minamp.bc.tls.DefaultTlsClient;
import com.minamp.bc.tls.NameType;
import com.minamp.bc.tls.ServerName;
import com.minamp.bc.tls.TlsAuthentication;
import com.minamp.bc.tls.TlsClientProtocol;
import com.minamp.bc.tls.TlsCredentials;
import com.minamp.bc.tls.TlsServerCertificate;
import com.minamp.bc.tls.TlsSession;
import com.minamp.bc.tls.CertificateRequest;
import com.minamp.bc.tls.crypto.TlsCrypto;
import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto;
/**
* Small HTTP/1.1 client with its own TLS 1.2 (Bouncy Castle), keep-alive pooling and
* session resumption. Android 2.x's built-in SSL only speaks TLS 1.0 and lacks today's roots,
* so every request in the app goes through here. From Minamp; keep the copies in sync.
*/
public final class Http {
public static String USER_AGENT = "Minamp/1.0";
/** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */
public static String proxyHost;
public static int proxyPort;
private static TlsCrypto crypto;
private static final Map<String, TlsSession> sessions = new HashMap<String, TlsSession>();
private static final Map<String, List<Conn>> pool = new HashMap<String, List<Conn>>();
private static final long IDLE_MS = 25000;
private Http() {}
private static synchronized TlsCrypto crypto() {
if (crypto == null) crypto = new BcTlsCrypto(new SecureRandom());
return crypto;
}
// ------------------------------------------------------------------ URL
public static final class Url {
public final boolean https;
public final String host;
public final int port;
public final String path; // includes query
public Url(String s) throws IOException {
String lower = s.toLowerCase(Locale.US);
int start;
if (lower.startsWith("https://")) { https = true; start = 8; }
else if (lower.startsWith("http://")) { https = false; start = 7; }
else throw new IOException("Bad URL: " + s);
int slash = s.indexOf('/', start);
String hostPort = slash < 0 ? s.substring(start) : s.substring(start, slash);
path = slash < 0 ? "/" : s.substring(slash);
int colon = hostPort.lastIndexOf(':');
if (colon > 0 && hostPort.indexOf(']') < colon) {
host = hostPort.substring(0, colon);
try { port = Integer.parseInt(hostPort.substring(colon + 1)); }
catch (NumberFormatException e) { throw new IOException("Bad port in " + s); }
} else {
host = hostPort;
port = https ? 443 : 80;
}
}
String key() { return (https ? "s:" : "p:") + host + ":" + port; }
String hostHeader() {
boolean dflt = (https && port == 443) || (!https && port == 80);
return dflt ? host : host + ":" + port;
}
}
// ------------------------------------------------------------------ connection
static final class Conn {
final String key;
Socket socket;
InputStream in;
OutputStream out;
TlsClientProtocol tls;
long idleSince;
Conn(String key) { this.key = key; }
void close() {
try { if (tls != null) tls.close(); } catch (Exception ignored) {}
try { if (socket != null) socket.close(); } catch (Exception ignored) {}
}
}
private static Conn take(String key) {
synchronized (pool) {
List<Conn> list = pool.get(key);
if (list == null) return null;
long now = System.currentTimeMillis();
while (!list.isEmpty()) {
Conn c = list.remove(list.size() - 1);
if (now - c.idleSince < IDLE_MS && !c.socket.isClosed()) return c;
c.close();
}
return null;
}
}
static void release(Conn c) {
c.idleSince = System.currentTimeMillis();
synchronized (pool) {
List<Conn> list = pool.get(c.key);
if (list == null) { list = new ArrayList<Conn>(); pool.put(c.key, list); }
if (list.size() >= 4) { list.remove(0).close(); }
list.add(c);
}
}
/** 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>();
synchronized (pool) {
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next());
pool.clear();
}
if (doomed.isEmpty()) return;
// closing TLS sends an encrypted alert: keep that off the (small-stack) caller thread
Threads.start(new Runnable() {
public void run() { for (int i = 0; i < doomed.size(); i++) doomed.get(i).close(); }
}, "http-evict");
}
private static Conn connect(final Url u, int timeoutMs) throws IOException {
Conn c = new Conn(u.key());
Socket s = new Socket();
boolean ok = false;
try {
if (proxyHost != null) {
s.connect(new InetSocketAddress(proxyHost, proxyPort), timeoutMs);
s.setSoTimeout(timeoutMs);
OutputStream po = s.getOutputStream();
String req = "CONNECT " + u.host + ":" + u.port + " HTTP/1.1\r\nHost: " + u.host + ":" + u.port + "\r\n\r\n";
po.write(req.getBytes("ISO-8859-1"));
po.flush();
InputStream pi = s.getInputStream();
String status = readLine(pi);
if (status == null || status.indexOf(" 200") < 0) throw new IOException("Proxy refused: " + status);
String line;
while ((line = readLine(pi)) != null && line.length() > 0) { /* skip headers */ }
} else {
s.connect(new InetSocketAddress(u.host, u.port), timeoutMs);
s.setSoTimeout(timeoutMs);
}
s.setTcpNoDelay(true);
c.socket = s;
if (u.https) {
TlsClientProtocol proto = new TlsClientProtocol(s.getInputStream(), s.getOutputStream());
proto.connect(new Client(crypto(), u.host, u.key()));
c.tls = proto;
c.in = new BufferedInputStream(proto.getInputStream(), 8192);
c.out = new BufferedOutputStream(proto.getOutputStream(), 4096);
} else {
c.in = new BufferedInputStream(s.getInputStream(), 8192);
c.out = new BufferedOutputStream(s.getOutputStream(), 4096);
}
ok = true;
return c;
} finally {
if (!ok) { try { s.close(); } catch (Exception ignored) {} }
}
}
private static final class Client extends DefaultTlsClient {
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;
this.sessionKey = sessionKey;
}
@Override
protected Vector getSNIServerNames() {
if (CertVerifier.isIpLiteral(host)) return null;
Vector v = new Vector();
try {
v.addElement(new ServerName(NameType.host_name, host.getBytes("US-ASCII")));
} catch (java.io.UnsupportedEncodingException e) {
return null;
}
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); }
}
@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); }
}
}
public TlsAuthentication getAuthentication() throws IOException {
return new TlsAuthentication() {
public void notifyServerCertificate(TlsServerCertificate sc) throws IOException {
com.minamp.bc.tls.Certificate cert = sc.getCertificate();
List<byte[]> chain = new ArrayList<byte[]>();
for (int i = 0; i < cert.getLength(); i++) chain.add(cert.getCertificateAt(i).getEncoded());
CertVerifier.verify(chain, host);
}
public TlsCredentials getClientCredentials(CertificateRequest req) {
return null;
}
};
}
}
// ------------------------------------------------------------------ request / response
public static final class Response {
public final int code;
public final String status;
public final Map<String, String> headers; // lower-case names
public final InputStream body;
public final long contentLength; // -1 if unknown
public final boolean chunked;
Response(int code, String status, Map<String, String> headers, InputStream body, long len, boolean chunked) {
this.code = code; this.status = status; this.headers = headers; this.body = body;
this.contentLength = len; this.chunked = chunked;
}
public String header(String name) { return headers.get(name.toLowerCase(Locale.US)); }
public void close() {
try { body.close(); } catch (IOException ignored) {}
}
public byte[] readAll() throws IOException {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream(contentLength > 0 && contentLength < 4000000 ? (int) contentLength : 8192);
byte[] buf = new byte[8192];
int n;
while ((n = body.read(buf)) > 0) bos.write(buf, 0, n);
return bos.toByteArray();
} finally {
close();
}
}
public String readString() throws IOException {
return new String(readAll(), "UTF-8");
}
}
public static Response get(String url, Map<String, String> headers, int timeoutMs) throws IOException {
return request("GET", url, headers, null, timeoutMs);
}
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
return request(method, url, headers, body, timeoutMs, true);
}
/** A single request; 3xx responses are returned to the caller. */
public static Response requestNoRedirect(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
return request(method, url, headers, body, timeoutMs, false);
}
private static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs, boolean follow) throws IOException {
for (int redirects = 0; ; redirects++) {
long t0 = System.currentTimeMillis();
Response r;
try {
r = requestOnce(method, url, headers, body, timeoutMs);
} catch (IOException e) {
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e));
throw e;
} catch (RuntimeException e) {
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e));
throw e;
}
if (!url.contains("/photo/:/transcode") && !url.contains("/cover")) { // artwork would drown out everything else
DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)");
}
if (follow && (r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4
&& ("GET".equals(method) || "HEAD".equals(method))) {
String loc = r.header("location");
r.close();
if (loc == null) throw new IOException("Redirect without location");
if (loc.startsWith("/")) {
Url u = new Url(url);
loc = (u.https ? "https://" : "http://") + u.hostHeader() + loc;
}
url = loc;
continue;
}
return r;
}
}
private static Response requestOnce(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
Url u = new Url(url);
// A pooled connection may have been closed by the server; retry once on a fresh one.
for (int attempt = 0; attempt < 2; attempt++) {
Conn c = take(u.key());
boolean pooled = c != null;
if (c == null) c = connect(u, timeoutMs);
try {
c.socket.setSoTimeout(timeoutMs);
writeRequest(c, u, method, headers, body);
return readResponse(c, method);
} catch (IOException e) {
c.close();
if (!pooled) throw e;
}
}
throw new IOException("Request failed");
}
private static void writeRequest(Conn c, Url u, String method, Map<String, String> headers, byte[] body) throws IOException {
StringBuilder sb = new StringBuilder(512);
sb.append(method).append(' ').append(u.path).append(" HTTP/1.1\r\n");
sb.append("Host: ").append(u.hostHeader()).append("\r\n");
boolean hasUa = false;
if (headers != null) {
for (Map.Entry<String, String> e : headers.entrySet()) {
if (e.getValue() == null) continue;
if ("user-agent".equalsIgnoreCase(e.getKey())) hasUa = true;
sb.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n");
}
}
if (!hasUa) sb.append("User-Agent: ").append(USER_AGENT).append("\r\n");
sb.append("Connection: keep-alive\r\n");
if (body != null || "POST".equals(method) || "PUT".equals(method)) {
sb.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n");
}
sb.append("\r\n");
c.out.write(sb.toString().getBytes("UTF-8"));
if (body != null) c.out.write(body);
c.out.flush();
}
private static Response readResponse(Conn c, String method) throws IOException {
String statusLine = readLine(c.in);
if (statusLine == null) throw new IOException("Connection closed");
// HTTP/1.1 200 OK
int sp = statusLine.indexOf(' ');
if (sp < 0) throw new IOException("Bad status line");
int sp2 = statusLine.indexOf(' ', sp + 1);
int code;
try {
code = Integer.parseInt(sp2 < 0 ? statusLine.substring(sp + 1).trim() : statusLine.substring(sp + 1, sp2));
} catch (NumberFormatException e) {
throw new IOException("Bad status line");
}
boolean http10 = statusLine.startsWith("HTTP/1.0");
Map<String, String> h = new LinkedHashMap<String, String>();
String line;
while ((line = readLine(c.in)) != null && line.length() > 0) {
int colon = line.indexOf(':');
if (colon <= 0) continue;
String name = line.substring(0, colon).trim().toLowerCase(Locale.US);
String val = line.substring(colon + 1).trim();
String prev = h.get(name);
h.put(name, prev == null ? val : prev + ", " + val);
}
String te = h.get("transfer-encoding");
String cl = h.get("content-length");
String conn = h.get("connection");
boolean keepAlive = !http10 && (conn == null || conn.toLowerCase(Locale.US).indexOf("close") < 0);
boolean chunked = te != null && te.toLowerCase(Locale.US).indexOf("chunked") >= 0;
long len = -1;
if (!chunked && cl != null) {
try { len = Long.parseLong(cl.trim()); } catch (NumberFormatException ignored) {}
}
InputStream body;
boolean noBody = "HEAD".equals(method) || code == 204 || code == 304 || (code >= 100 && code < 200);
if (noBody) {
body = new FixedStream(c, 0, keepAlive);
len = "HEAD".equals(method) ? len : 0;
} else if (chunked) {
body = new ChunkedStream(c, keepAlive);
} else if (len >= 0) {
body = new FixedStream(c, len, keepAlive);
} else {
body = new EofStream(c);
}
return new Response(code, statusLine, h, body, len, chunked);
}
static String readLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder(64);
int b;
while ((b = in.read()) >= 0) {
if (b == '\n') {
int n = sb.length();
if (n > 0 && sb.charAt(n - 1) == '\r') sb.setLength(n - 1);
return sb.toString();
}
sb.append((char) b);
if (sb.length() > 16384) throw new IOException("Header line too long");
}
return sb.length() == 0 ? null : sb.toString();
}
// ------------------------------------------------------------------ body streams
private static final class FixedStream extends InputStream {
private final Conn c; private long left; private final boolean keepAlive; private boolean closed;
FixedStream(Conn c, long len, boolean keepAlive) {
this.c = c; this.left = len; this.keepAlive = keepAlive;
if (len == 0) finish(true);
}
private void finish(boolean complete) {
if (closed) return;
closed = true;
if (complete && keepAlive) release(c); else c.close();
}
@Override public int read() throws IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
return n <= 0 ? -1 : one[0] & 255;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
if (left <= 0) { finish(true); return -1; }
if (closed) return -1;
int n = c.in.read(b, off, (int) Math.min(len, left));
if (n < 0) { finish(false); throw new IOException("Unexpected end of stream"); }
left -= n;
if (left == 0) finish(true);
return n;
}
@Override public int available() throws IOException {
if (closed) return 0;
return (int) Math.min(c.in.available(), left);
}
@Override public void close() { finish(left == 0); }
}
private static final class ChunkedStream extends InputStream {
private final Conn c; private final boolean keepAlive;
private long chunkLeft = 0; private boolean eof; private boolean closed;
ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; }
private void finish(boolean complete) {
if (closed) return;
closed = true;
if (complete && keepAlive) release(c); else c.close();
}
private boolean nextChunk() throws IOException {
if (eof) return false;
String line = readLine(c.in);
if (line == null) throw new IOException("Unexpected end of chunked stream");
if (line.length() == 0) { line = readLine(c.in); if (line == null) throw new IOException("Bad chunk"); }
int semi = line.indexOf(';');
if (semi >= 0) line = line.substring(0, semi);
try { chunkLeft = Long.parseLong(line.trim(), 16); }
catch (NumberFormatException e) { throw new IOException("Bad chunk size"); }
if (chunkLeft == 0) {
String t;
while ((t = readLine(c.in)) != null && t.length() > 0) { /* trailers */ }
eof = true;
finish(true);
return false;
}
return true;
}
@Override public int read() throws IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
return n <= 0 ? -1 : one[0] & 255;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
if (closed) return -1;
if (chunkLeft == 0) {
if (!nextChunk()) return -1;
}
int n = c.in.read(b, off, (int) Math.min(len, chunkLeft));
if (n < 0) { finish(false); throw new IOException("Unexpected end of chunk"); }
chunkLeft -= n;
if (chunkLeft == 0) {
// consume CRLF after chunk data
String crlf = readLine(c.in);
if (crlf == null) { finish(false); }
else if (crlf.length() != 0) { finish(false); throw new IOException("Bad chunk terminator"); }
}
return n;
}
@Override public int available() throws IOException {
if (closed) return 0;
return (int) Math.min(c.in.available(), chunkLeft);
}
@Override public void close() { finish(eof); }
}
private static final class EofStream extends InputStream {
private final Conn c; private boolean closed;
EofStream(Conn c) { this.c = c; }
@Override public int read() throws IOException {
if (closed) return -1;
int b = c.in.read();
if (b < 0) close();
return b;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
if (closed) return -1;
int n = c.in.read(b, off, len);
if (n < 0) close();
return n;
}
@Override public void close() {
if (!closed) { closed = true; c.close(); }
}
}
/** Close one in-flight response's socket from another thread (used to abort streams). */
public static void abort(Response r) {
if (r == null) return;
InputStream b = r.body;
Conn c = null;
if (b instanceof FixedStream) c = ((FixedStream) b).c;
else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c;
else if (b instanceof EofStream) c = ((EofStream) b).c;
if (c != null) {
try { c.socket.close(); } catch (Exception ignored) {}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package org.gingercal.net;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
/**
* Background threads with big stacks. Android 2.x gives new threads ~16 KB of stack, and the
* bundled TLS handshake + certificate parsing needs far more; overflowing it kills the app.
* 256 KB is the most Dalvik allows.
*/
public final class Threads {
public static final long STACK = 256 * 1024;
private Threads() {}
public static Thread create(Runnable r, String name) {
Thread t = new Thread(null, r, name, STACK);
t.setDaemon(true);
return t;
}
public static void start(Runnable r, String name) {
create(r, name).start();
}
public static ExecutorService pool(final String name, int n) {
ThreadFactory f = new ThreadFactory() {
private int count;
public synchronized Thread newThread(Runnable r) { return create(r, name + "-" + (++count)); }
};
return n == 1 ? Executors.newSingleThreadExecutor(f) : Executors.newFixedThreadPool(n, f);
}
/** Readable one-liner for any failure, including Errors such as StackOverflowError. */
public static String describe(Throwable t) {
String m = t.getMessage();
String cls = t.getClass().getSimpleName();
if (m == null || m.length() == 0) return cls;
if (t instanceof java.io.IOException) return m;
return cls + ": " + m;
}
}