Import GingerCal 1.0 source

This commit is contained in:
xocko committed 2026-10-15 16:35:05 +02:00
commit 01f49852ab
116 files changed
+5079

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
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);
}
}
+174
View File
@@ -0,0 +1,174 @@
package org.gingercal;
import java.util.*;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
/** End-to-end test of discovery, sync, create/edit/delete against a local CalDAV server. */
public class CoreTest {
static int fails = 0;
static void check(boolean ok, String what) {
System.out.println((ok ? " ok " : " FAIL ") + what);
if (!ok) fails++;
}
static class MemStore implements Model.Store {
Map<String, Cal> cals = new LinkedHashMap<>();
Map<String, List<Ev>> evs = new HashMap<>();
public Map<String, Cal> calendars() { Map<String, Cal> m = new LinkedHashMap<>(); for (Cal c : cals.values()) { Cal x = new Cal(); x.href=c.href; x.name=c.name; x.enabled=c.enabled; x.ctag=c.ctag; x.synced=c.synced; m.put(c.href, x);} return m; }
public void saveCalendar(Cal c) { cals.put(c.href, c); }
public void deleteCalendar(String h) { cals.remove(h); evs.remove(h); }
public void replaceEvents(String h, List<Ev> e) { evs.put(h, e); }
List<Ev> all() { List<Ev> a = new ArrayList<>(); for (List<Ev> l : evs.values()) a.addAll(l); a.sort(Comparator.comparingLong(e -> e.start)); return a; }
}
static String day(int offset) { return ICal.date(ICal.addDays(System.currentTimeMillis(), offset)); }
public static void main(String[] a) throws Exception {
String base = a.length > 0 ? a[0] : "http://127.0.0.1:5232/";
JdkHttp raw = new JdkHttp();
String auth = "Basic " + B64.encode("xose:secret".getBytes("UTF-8"));
String[] h = {"Authorization", auth};
// --- seed two calendars and some tricky events
for (String[] c : new String[][] {{"work", "Work stuff", "#E9322DFF"}, {"home", "Home", "#0082C9"}}) {
raw.exec("DELETE", base + "xose/" + c[0] + "/", h, null, null);
Http.Resp r = raw.exec("MKCALENDAR", base + "xose/" + c[0] + "/", h,
"<?xml version=\"1.0\"?><C:mkcalendar xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:A=\"http://apple.com/ns/ical/\"><D:set><D:prop><D:displayname>" + c[1] + "</D:displayname><A:calendar-color>" + c[2] + "</A:calendar-color></D:prop></D:set></C:mkcalendar>",
"application/xml");
check(r.code == 201, "MKCALENDAR " + c[0] + " -> " + r.code);
}
String tz = "BEGIN:VTIMEZONE\r\nTZID:Europe/Madrid\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n";
String timed = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n" + tz + "BEGIN:VEVENT\r\nUID:timed-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Madrid:" + day(1) + "T100000\r\nDTEND;TZID=Europe/Madrid:" + day(1) + "T113000\r\nSUMMARY:Reunión\\, equipo ñ\r\nLOCATION:Vigo\\; sala 2\r\nDESCRIPTION:Line one\\nLine two with a very long text that definitely needs folding because it is over seventy-five octets long\r\nATTENDEE;CN=\"Doe, Jane\":mailto:jane@example.com\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:keep me\r\nTRIGGER:-PT30M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
String allday = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nBEGIN:VEVENT\r\nUID:allday-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;VALUE=DATE:" + day(2) + "\r\nDTEND;VALUE=DATE:" + day(4) + "\r\nSUMMARY:Holidays\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
String weekly = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n" + tz + "BEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Madrid:" + day(-14) + "T090000\r\nDTEND;TZID=Europe/Madrid:" + day(-14) + "T093000\r\nRRULE:FREQ=WEEKLY;COUNT=10\r\nEXDATE;TZID=Europe/Madrid:" + day(7) + "T090000\r\nSUMMARY:Standup\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;TZID=Europe/Madrid:" + day(14) + "T090000\r\nDTSTART;TZID=Europe/Madrid:" + day(14) + "T120000\r\nDTEND;TZID=Europe/Madrid:" + day(14) + "T123000\r\nSUMMARY:Standup (moved)\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
check(raw.exec("PUT", base + "xose/work/timed-1.ics", h, timed, "text/calendar").code == 201, "seed timed");
check(raw.exec("PUT", base + "xose/home/allday-1.ics", h, allday, "text/calendar").code == 201, "seed all-day");
check(raw.exec("PUT", base + "xose/work/weekly-1.ics", h, weekly, "text/calendar").code == 201, "seed weekly");
// --- discovery
JdkHttp http = new JdkHttp();
Dav dav = new Dav(http, base.replace("http://", "http://"), "xose", "secret");
String home = dav.calendarHome();
System.out.println(" home = " + home);
check(home.endsWith("/xose/"), "calendar home discovered via fallbacks");
List<Cal> cals = dav.calendars(home);
check(cals.size() == 2, "two calendars listed (" + cals.size() + ")");
for (Cal c : cals) System.out.printf(" cal %s name=%s color=%08X writable=%s ctag=%s%n", c.href, c.name, c.color, c.writable, c.ctag);
Cal work = null;
for (Cal c : cals) if (c.name.equals("Work stuff")) work = c;
check(work != null && work.color == 0xFFE9322D, "displayname + colour parsed");
// --- bad password
try {
new Dav(new JdkHttp(), base, "xose", "wrong").calendarHome();
check(false, "401 detected");
} catch (DavException e) {
check(e.code == 401, "401 detected: " + e.getMessage().substring(0, 30));
}
// --- sync
MemStore st = new MemStore();
long now = System.currentTimeMillis();
Syncer.Result r = Syncer.sync(dav, st, now, 7, 30, false);
System.out.println(" sync: " + r.summary());
List<Ev> all = st.all();
for (Ev e : all) System.out.printf(" %-26s %s -> %s allDay=%s rec=%s%n", e.title, new Date(e.start), new Date(e.end), e.allDay, e.recurring);
Ev t1 = find(all, "Reunión, equipo ñ");
check(t1 != null, "escaped summary decoded");
Calendar exp = Calendar.getInstance(TimeZone.getTimeZone("Europe/Madrid"));
exp.setTimeInMillis(ICal.addDays(ICal.startOfDay(now), 1));
exp.set(Calendar.HOUR_OF_DAY, 10);
check(t1 != null && t1.start == exp.getTimeInMillis() && t1.end - t1.start == 90 * 60000, "TZID time correct");
check(t1 != null && t1.location.equals("Vigo; sala 2") && t1.descr.startsWith("Line one\nLine two"), "location/description unescaped");
Ev ad = find(all, "Holidays");
check(ad != null && ad.allDay && ad.end == ICal.addDays(ad.start, 2), "all-day 2 days");
int standups = 0, moved = 0;
for (Ev e : all) { if (e.title.equals("Standup")) standups++; if (e.title.equals("Standup (moved)")) moved++; }
// window: -7d..+30d; weekly from -14d count 10 -> days -14,-7,0,7(ex),14(moved),21,28,35..: in window -7,0,21,28 + moved
System.out.println(" standups=" + standups + " moved=" + moved);
check(standups == 4 && moved == 1, "recurrence expanded with EXDATE and override");
for (Ev e : all) if (e.title.startsWith("Standup")) check(e.recurring, "instance flagged recurring");
// --- ctag skip
int before = http.requests;
Syncer.Result r2 = Syncer.sync(dav, st, now, 7, 30, false);
check(r2.skipped == 2 && r2.fetched == 0, "second sync skips unchanged calendars (" + r2.summary() + ", " + (http.requests - before) + " requests)");
// --- create
String uid = UUID.randomUUID().toString();
long s = ICal.addDays(ICal.startOfDay(now), 3) + 15 * ICal.HOUR;
dav.create(work.href, uid, ICal.newEvent(uid, "Dentist, 2nd floor", "Rúa do Príncipe", "Bring card\nand ID", s, s + ICal.HOUR, false, 15));
String[] got = dav.get(work.href + uid + ".ics");
ICal.VEvent ve = ICal.parse(got[0]).get(0);
check(ve.summary.equals("Dentist, 2nd floor") && ve.start == s && ve.alarmMin == 15 && ve.description.equals("Bring card\nand ID"), "created event round-trips");
try { dav.create(work.href, uid, ICal.newEvent(uid, "dup", "", "", s, s, false, -1)); check(false, "If-None-Match prevents overwrite"); }
catch (DavException e) { check(e.code == 412, "If-None-Match prevents overwrite"); }
// --- edit the timed event (keeps attendee + its alarm)
String[] t = dav.get(work.href + "timed-1.ics");
check(!ICal.isRecurring(t[0]), "timed is not recurring");
check(ICal.isRecurring(dav.get(work.href + "weekly-1.ics")[0]), "weekly is recurring");
String mod = ICal.modify(t[0], "Reunión movida", "", "nueva", t1.start + ICal.HOUR, t1.end + ICal.HOUR, false, -2);
dav.update(work.href + "timed-1.ics", t[1], mod);
String[] t2 = dav.get(work.href + "timed-1.ics");
ICal.VEvent m = ICal.parse(t2[0]).get(0);
check(m.summary.equals("Reunión movida") && m.start == t1.start + ICal.HOUR && m.location.equals("") && m.sequence == 1, "edit applied");
check(t2[0].contains("ATTENDEE") && m.alarmMin == 30 && t2[0].contains("keep me"), "unrelated props + alarm kept");
try { dav.update(work.href + "timed-1.ics", t[1], mod); check(false, "stale etag rejected"); }
catch (DavException e) { check(e.code == 412, "stale etag rejected"); }
String mod2 = ICal.modify(t2[0], "Reunión movida", "", "nueva", t1.start, t1.end, false, 10);
dav.update(work.href + "timed-1.ics", t2[1], mod2);
ICal.VEvent m2 = ICal.parse(dav.get(work.href + "timed-1.ics")[0]).get(0);
check(m2.alarmMin == 10 && m2.sequence == 2, "alarm replaced");
// --- all-day create
String uid2 = UUID.randomUUID().toString();
long d0 = ICal.addDays(ICal.startOfDay(now), 5);
dav.create(cals.get(0).href, uid2, ICal.newEvent(uid2, "Trip", "", "", d0, ICal.addDays(d0, 1), true, -1));
// --- delete
dav.delete(work.href + uid + ".ics", null);
try { dav.get(work.href + uid + ".ics"); check(false, "deleted"); } catch (DavException e) { check(e.code == 404, "deleted"); }
Syncer.Result r3 = Syncer.sync(dav, st, now, 7, 30, false);
check(find(st.all(), "Trip") != null && find(st.all(), "Trip").allDay, "new all-day event synced (" + r3.summary() + ")");
check(find(st.all(), "Dentist, 2nd floor") == null, "deleted event gone after sync");
// --- disabling a calendar clears its events
st.cals.get(work.href).enabled = false;
Syncer.sync(dav, st, now, 7, 30, false);
check(find(st.all(), "Standup") == null, "disabled calendar emptied");
// --- client-side expansion when a server ignores <C:expand>
final String unexpanded = weekly.replace("&", "&amp;").replace("<", "&lt;");
Http fake = new Http() {
public Resp exec(String m, String u, String[] hh, String b, String ct) {
Resp rr = new Resp(); rr.code = 207;
rr.body = "<d:multistatus xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:response><d:href>/x/w.ics</d:href><d:propstat><d:prop><d:getetag>\"1\"</d:getetag><c:calendar-data>" + unexpanded + "</c:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>";
return rr;
}
public void close() {}
};
Cal fc = new Cal(); fc.href = "http://x/x/";
List<Ev> fe = new Dav(fake, "http://x/", "u", "p").events(fc, ICal.addDays(ICal.startOfDay(now), -7), ICal.addDays(ICal.startOfDay(now), 31));
int fs = 0, fm = 0;
for (Ev e : fe) { if (e.title.equals("Standup")) fs++; if (e.title.equals("Standup (moved)")) fm++; }
check(fs == 4 && fm == 1, "client-side RRULE/EXDATE/override expansion (" + fs + "+" + fm + ")");
// --- folding
String longLine = ICal.fold("DESCRIPTION:" + "ñ".repeat(60));
boolean okFold = true;
for (String l : longLine.split("\r\n")) okFold &= l.getBytes("UTF-8").length <= 75;
check(okFold && ICal.unfold(longLine).get(0).equals("DESCRIPTION:" + "ñ".repeat(60)), "UTF-8 safe folding");
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);
}
static Ev find(List<Ev> l, String title) {
for (Ev e : l) if (e.title.equals(title)) return e;
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
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.time.Duration;
/** Desktop-only transport used to test the CalDAV core against a real server. */
public class JdkHttp implements Http {
final HttpClient c = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(10)).build();
public int requests;
public Resp exec(String method, String url, String[] headers, String body, String contentType)
throws IOException {
requests++;
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 < headers.length; i += 2) b.header(headers[i], headers[i + 1]);
if (contentType != null) b.header("Content-Type", contentType);
try {
HttpResponse<String> r;
try {
r = c.send(b.build(), HttpResponse.BodyHandlers.ofString());
} catch (IOException stale) { // test server drops idle keep-alive sockets
r = c.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
Resp out = new Resp();
out.code = r.statusCode();
out.body = r.body();
out.etag = r.headers().firstValue("ETag").orElse(null);
out.location = r.headers().firstValue("Location").orElse(null);
return out;
} catch (InterruptedException e) {
throw new IOException(e);
}
}
public void close() {}
}
+88
View File
@@ -0,0 +1,88 @@
package org.gingercal;
import java.util.ArrayList;
import java.util.List;
/**
* Forced-HTTPS / HSTS scenarios. Unit part uses a scripted fake server; with an argument it
* also runs live against a phone-profile client (see run in README).
*/
public class RedirectTest {
static int fails;
static void check(boolean ok, String what) {
System.out.println((ok ? " ok " : " FAIL ") + what);
if (!ok) fails++;
}
/** Answers each request from a script of {status, location} pairs; then 207 multistatus. */
static class Scripted implements Http {
final String[][] script;
final List<String> urls = new ArrayList<String>();
Scripted(String[][] script) {
this.script = script;
}
public Resp exec(String m, String url, String[] h, String body, String ct) {
urls.add(url);
Resp r = new Resp();
int i = urls.size() - 1;
if (i < script.length) {
r.code = Integer.parseInt(script[i][0]);
r.location = script[i][1];
} else {
r.code = 207;
r.body = "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php/dav/</d:href><d:propstat>"
+ "<d:prop><d:current-user-principal><d:href>/remote.php/dav/principals/users/x/</d:href>"
+ "</d:current-user-principal></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>"
+ "</d:response></d:multistatus>";
}
return r;
}
public void close() {}
}
static String expectError(String url, String[][] script) {
Scripted s = new Scripted(script);
try {
new Dav(s, url, "u", "p").multistatus("PROPFIND", url + "/remote.php/dav/", "0", "<x/>");
return "no error after " + s.urls;
} catch (DavException e) {
return (e.code == DavException.REDIRECT ? "REDIRECT: " : "code " + e.code + ": ") + e.getMessage();
} catch (Exception e) {
return e.toString();
}
}
public static void main(String[] a) throws Exception {
// 1. LAN http:// straight at a Nextcloud that forces HTTPS
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
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
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");
// 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/>");
check(s.urls.size() == 2, "explicit :443 treated as same server");
// 5. legit same-server redirect still followed
s = new Scripted(new String[][] {{"301", "/remote.php/dav/"}});
new Dav(s, "https://h:8443", "u", "p").multistatus("PROPFIND", "https://h:8443/remote.php/dav", "0", "<x/>");
check(s.urls.size() == 2 && s.urls.get(1).equals("https://h:8443/remote.php/dav/"), "same-server redirect followed");
// 6. discovery stops at a redirect problem instead of trying other paths
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");
} catch (DavException e) {
check(e.code == DavException.REDIRECT && s.urls.size() == 1, "discovery reports forced HTTPS immediately");
}
System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED");
System.exit(fails == 0 ? 0 : 1);
}
}