Files
XoséandClaude Opus 5.5 748f6add54 Calendar Lite 1.5: GingerCal is now Calendar Lite, new icon and reminder icon
Renames the app (label, texts, User-Agent, PRODID, APK) and uses the new launcher icon pack
(ldpi to xhdpi, master in art/) instead of drawing one at build time. Reminders show a white
calendar glyph in the status bar (stat_notify, 18-48 px) instead of the system's colour
alarm clock, which didn't fit the Gingerbread bar. The package name, database and signing key
stay, so it installs over GingerCal and keeps its data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:48 +02:00

589 lines
24 KiB
Java

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 Plexamp Lite; keep the copies in sync.
*/
public final class Http {
public static String USER_AGENT = "PlexampLite/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) {}
}
}
}