Compare commits
2
Commits
42c3d6da91
..
v1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eeaf0f519 | ||
|
|
f7428287eb |
No files matched your search
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.gingercal"
|
||||
android:versionCode="4"
|
||||
android:versionName="1.3">
|
||||
android:versionCode="5"
|
||||
android:versionName="1.4">
|
||||
|
||||
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
A small native calendar client for Gingerbread (API 9/10). Built for a Samsung Galaxy Mini
|
||||
(GT-S5570, 240×320, Android 2.3.5) talking to Nextcloud, reached through Pangolin.
|
||||
|
||||
**Since 1.4 it brings its own TLS 1.2** (Bouncy Castle, the same client as Minamp, with Mozilla's
|
||||
root certificates and keep-alive connections), like the iPod version does with BearSSL. Put the
|
||||
normal `https://` Nextcloud address in Settings, at home or away: no bridge is needed. The bridge
|
||||
sections below are kept for reference; a bridge address (pinned certificate) still works.
|
||||
|
||||
**What it does**
|
||||
|
||||
- Month view: a 6-week grid with a coloured mark per event (calendar colour), today outlined,
|
||||
@@ -19,11 +24,12 @@ A small native calendar client for Gingerbread (API 9/10). Built for a Samsung G
|
||||
they can be deleted on the phone but edited only in Nextcloud
|
||||
- Pick which calendars to show and which one new events go to
|
||||
- Background sync every 1–24 h, plus phone notifications N minutes before timed events
|
||||
- Certificate pinning, so a self-signed "legacy" endpoint is still safe against interception
|
||||
- Its own TLS 1.2 stack: connects to modern `https://` servers directly
|
||||
- Certificate pinning, so a self-signed server (or the old legacy-TLS bridge) is still safe against interception
|
||||
|
||||
It does not feed the stock Samsung calendar: 2.3 has no public calendar-provider API for that.
|
||||
|
||||
## Why HTTPS breaks on 2.3, and the fix
|
||||
## Why HTTPS broke on 2.3, and the old fix (not needed since 1.4)
|
||||
|
||||
Three separate problems, all fatal for a normal Pangolin/Traefik URL:
|
||||
|
||||
@@ -161,7 +167,9 @@ Tests (desktop JVM, need a CalDAV server such as Radicale on :5232 with user `xo
|
||||
```sh
|
||||
javac -d out src/org/gingercal/*.java test/org/gingercal/*.java
|
||||
java -cp out org.gingercal.CoreTest # discovery, sync, recurrence, create/edit/delete
|
||||
java -cp out org.gingercal.RedirectTest # forced-HTTPS / redirect-loop handling (no server needed)
|
||||
java -cp out org.gingercal.RedirectTest # redirect handling (no server needed)
|
||||
# the bundled TLS transport (needs the android.jar and Bouncy Castle sources to compile):
|
||||
# java -cp out org.gingercal.NetTest assets/roots http://127.0.0.1:5232 xose secret
|
||||
java -cp out org.gingercal.I18nTest # Galician dates, titles and messages
|
||||
```
|
||||
|
||||
@@ -172,7 +180,8 @@ declarations.
|
||||
## Layout
|
||||
|
||||
- `src/` CalDAV core, platform-neutral: discovery, PROPFIND/REPORT, iCalendar, sync, pinning
|
||||
- `android/` activities, sync service, alarms, SQLite cache, Apache HttpClient transport
|
||||
- `android/` activities, sync service, alarms, SQLite cache, `NetHttp` transport
|
||||
- `android/org/gingercal/net/` the bundled TLS 1.2 HTTP client from Minamp; `assets/roots/` Mozilla roots
|
||||
- `stubs/` compile-only Android API 10 signatures (never packaged)
|
||||
- `bridge/` legacy-TLS endpoint: standalone nginx (config, compose file), `npm/http.conf` for
|
||||
Nginx Proxy Manager, certificate script
|
||||
@@ -181,6 +190,9 @@ declarations.
|
||||
|
||||
## Security notes
|
||||
|
||||
With the bundled TLS 1.2 the connection to Nextcloud is checked like a browser would: host name,
|
||||
dates, and a chain up to a Mozilla root. The notes below are about the old bridge.
|
||||
|
||||
TLS 1.0 with AES-CBC is old but, combined with a pinned certificate, still keeps the connection
|
||||
private and authenticated against anyone without the bridge's private key. The bridge exposes
|
||||
only the DAV endpoint. Use an app password so you can revoke the phone from Nextcloud. If the
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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
|
||||
public final List<byte[]> chain; // as the server sent it, leaf first
|
||||
|
||||
Untrusted(String fp, String subject, String issuer, String expires, boolean changed, List<byte[]> chain) {
|
||||
super("untrusted certificate " + fp);
|
||||
this.chain = chain;
|
||||
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(List<byte[]> chainDer, boolean changed) {
|
||||
byte[] leafDer = chainDer.get(0);
|
||||
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, chainDer);
|
||||
}
|
||||
|
||||
/** 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, 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 {
|
||||
final List<byte[]> chain0 = chainDer;
|
||||
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) {
|
||||
byte[] currentDer = current.getEncoded(ASN1Encoding.DER);
|
||||
for (int i = 0; i < anchors.size(); i++) {
|
||||
// the very certificate the user chose to trust (e.g. self-signed)
|
||||
if (java.util.Arrays.equals(anchors.get(i), currentDer)) return;
|
||||
Certificate root = Certificate.getInstance(anchors.get(i));
|
||||
// anything that signs must be a CA: a trusted site's own certificate can't vouch for another
|
||||
if (isCa(root) && 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(chain0, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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***");
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user