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

-99
View File
@@ -1,99 +0,0 @@
package org.gingercal;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.UUID;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManager;
import org.gingercal.Model.Cal;
/** Talks to the legacy bridge the way Android 2.3 does: TLS 1.0, AES128-SHA, pinned cert. */
public class BridgeTest {
static int fails;
static void check(boolean ok, String what) {
System.out.println((ok ? " ok " : " FAIL ") + what);
if (!ok) fails++;
}
static class GingerbreadHttp implements Http {
final Tls.PinTrust trust;
final HttpClient c;
GingerbreadHttp(String pin) throws Exception {
trust = new Tls.PinTrust(pin);
SSLContext ctx = SSLContext.getInstance("TLSv1");
ctx.init(null, new TrustManager[] {trust}, null);
SSLParameters p = new SSLParameters(new String[] {"TLS_RSA_WITH_AES_128_CBC_SHA"}, new String[] {"TLSv1"});
c = HttpClient.newBuilder().sslContext(ctx).sslParameters(p).build();
}
public Resp exec(String method, String url, String[] h, String body, String ct) throws IOException {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).method(method,
body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
for (int i = 0; i + 1 < h.length; i += 2) b.header(h[i], h[i + 1]);
if (ct != null) b.header("Content-Type", ct);
trust.reset();
try {
HttpResponse<String> r = c.send(b.build(), HttpResponse.BodyHandlers.ofString());
Resp o = new Resp();
o.code = r.statusCode();
o.body = r.body();
o.etag = r.headers().firstValue("ETag").orElse(null);
o.location = r.headers().firstValue("Location").orElse(null);
return o;
} catch (IOException e) {
throw trust.translate(e);
} catch (InterruptedException e) {
throw new IOException(e);
}
}
public void close() {}
}
public static void main(String[] a) throws Exception {
String url = "https://127.0.0.1:8443";
String expected = a[0];
// 1. first contact: not trusted -> app shows fingerprint
try {
new Dav(new GingerbreadHttp(""), url, "xose", "secret").calendarHome();
check(false, "untrusted cert reported");
} catch (DavException.UntrustedCert u) {
check(u.fingerprint.equals(expected) && !u.pinMismatch, "untrusted cert reported with fingerprint " + u.fingerprint.substring(0, 11) + "...");
}
// 2. wrong pin -> mismatch warning
try {
new Dav(new GingerbreadHttp(expected.replace('1', '2')), url, "xose", "secret").calendarHome();
check(false, "pin mismatch reported");
} catch (DavException.UntrustedCert u) {
check(u.pinMismatch, "pin mismatch reported");
}
// 3. pinned: full flow through the bridge
Dav dav = new Dav(new GingerbreadHttp(expected), url, "xose", "secret");
String home = dav.calendarHome();
check(home.equals(url + "/remote.php/dav/xose/"), "discovered home via bridge: " + home);
List<Cal> cals = dav.calendars(home);
check(cals.size() == 2, "calendars via bridge: " + cals.size());
CoreTest.MemStore st = new CoreTest.MemStore();
Syncer.Result r = Syncer.sync(dav, st, System.currentTimeMillis(), 1, 60, false);
check(r.events > 0, "sync via bridge: " + r.summary());
String uid = UUID.randomUUID().toString();
long s = ICal.startOfDay(System.currentTimeMillis()) + 2 * ICal.DAY + 9 * ICal.HOUR;
Cal w = cals.get(0);
dav.create(w.href, uid, ICal.newEvent(uid, "Via bridge", "", "", s, s + ICal.HOUR, false, 10));
String[] got = dav.get(w.href + uid + ".ics");
check(ICal.parse(got[0]).get(0).summary.equals("Via bridge"), "create+get via bridge");
dav.delete(w.href + uid + ".ics", got[1]);
check(true, "delete via bridge");
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ public class I18nTest {
Dav.checkRedirect("http://192.168.1.20/remote.php/dav/", "https://nxc.anosanube.ovh/remote.php/dav/",
new java.util.ArrayList<String>(), 301);
} catch (DavException e) {
check(e.getMessage().substring(0, 58), "O servidor redirixiu http:// a https://nxc.anosanube.ovh (");
check(e.getMessage().substring(0, 44), "O servidor redirixiu a https://nxc.anosanube");
}
check(L.t("not in the table"), "not in the table"); // unknown text falls back to English
+55
View File
@@ -0,0 +1,55 @@
package org.gingercal;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import org.gingercal.net.CertVerifier;
/**
* The phone's transport (NetHttp: bundled TLS 1.2 client, any method, keep-alive) on a desktop JVM:
* CalDAV discovery against a local Radicale, and certificate checks against real HTTPS servers.
* java -cp out org.gingercal.NetTest assets/roots [http://127.0.0.1:5232 user pass]
*/
public class NetTest {
static int fails;
static void check(boolean ok, String what) {
System.out.println((ok ? " ok " : " FAIL ") + what);
if (!ok) fails++;
}
public static void main(String[] a) throws Exception {
final File roots = new File(a[0]);
CertVerifier.setRootStore(new CertVerifier.RootStore() {
public List<byte[]> find(byte[] subject) {
File f = new File(roots, CertVerifier.sha1Hex(subject) + "-0.der");
try {
return f.exists() ? Collections.singletonList(java.nio.file.Files.readAllBytes(f.toPath())) : null;
} catch (IOException e) {
return null;
}
}
});
NetHttp h = new NetHttp("", 20000);
Http.Resp r = h.execHere("GET", "https://nxc.anosanube.ovh/status.php", new String[0], null, null);
check(r.code == 200 && r.body.contains("installed"), "Nextcloud over TLS 1.2 with Mozilla roots");
try {
h.execHere("GET", "https://self-signed.badssl.com/", new String[0], null, null);
check(false, "self-signed refused");
} catch (DavException.UntrustedCert u) {
check(!u.pinMismatch && u.fingerprint.length() == 95, "self-signed -> trust prompt data");
Http.Resp p = new NetHttp(u.fingerprint, 20000).execHere("GET", "https://self-signed.badssl.com/", new String[0], null, null);
check(p.code == 200, "pinned self-signed accepted");
}
if (a.length >= 4) {
Dav d = new Dav(new NetHttp("", 20000), a[1], a[2], a[3]);
String home = d.calendarHome();
List<Model.Cal> cals = d.calendars(home);
check(home != null && !cals.isEmpty(), "CalDAV discovery: " + cals.size() + " calendars at " + home);
}
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);
}
}
+12 -8
View File
@@ -57,15 +57,19 @@ public class RedirectTest {
}
public static void main(String[] a) throws Exception {
// 1. LAN http:// straight at a Nextcloud that forces HTTPS
// 1. LAN http:// address, Nextcloud sends its public https:// one: another server, not followed
String m = expectError("http://192.168.1.20", new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}});
check(m.startsWith("REDIRECT") && m.contains("forces HTTPS") && m.contains("https://cloud.example.com"), "http->https upgrade explained: " + m.substring(0, 70));
// 2. bridge passes Nextcloud's absolute public URL through
check(m.startsWith("REDIRECT") && m.contains("Put that address") && m.contains("https://cloud.example.com"), "LAN -> public redirect explained: " + m.substring(0, 70));
// 1b. http:// -> https:// on the same host is followed now (the app has its own TLS 1.2)
Scripted up = new Scripted(new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}});
new Dav(up, "http://cloud.example.com", "u", "p").multistatus("PROPFIND", "http://cloud.example.com/remote.php/dav/", "0", "<x/>");
check(up.urls.size() == 2 && up.urls.get(1).startsWith("https://cloud.example.com/"), "http -> https upgrade followed");
// 2. another server's absolute URL
m = expectError("https://vps.example.com:8443", new String[][] {{"302", "https://cloud.example.com/remote.php/dav/"}});
check(m.startsWith("REDIRECT") && m.contains("proxy_redirect"), "cross-host redirect explained");
// 3. bridge with http upstream to a forced-HTTPS Nextcloud: same URL again
check(m.startsWith("REDIRECT") && m.contains("Put that address"), "cross-host redirect explained");
// 3. the same URL again
m = expectError("https://vps.example.com:8443", new String[][] {{"301", "/remote.php/dav/"}, {"301", "/remote.php/dav/"}});
check(m.startsWith("REDIRECT") && m.contains("loop") && m.contains("proxy_pass https://"), "loop explained after 1 retry");
check(m.startsWith("REDIRECT") && m.contains("loop"), "loop explained after 1 retry");
// 4. default port written out is the same server
Scripted s = new Scripted(new String[][] {{"301", "https://vps.example.com:443/remote.php/dav/"}});
new Dav(s, "https://vps.example.com", "u", "p").multistatus("PROPFIND", "https://vps.example.com/remote.php/dav/", "0", "<x/>");
@@ -78,9 +82,9 @@ public class RedirectTest {
s = new Scripted(new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}});
try {
new Dav(s, "http://192.168.1.20", "u", "p").calendarHome();
check(false, "discovery reports forced HTTPS");
check(false, "discovery reports the redirect");
} catch (DavException e) {
check(e.code == DavException.REDIRECT && s.urls.size() == 1, "discovery reports forced HTTPS immediately");
check(e.code == DavException.REDIRECT && s.urls.size() == 1, "discovery reports a cross-host redirect immediately");
}
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);