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

+22
View File
@@ -0,0 +1,22 @@
package org.gingercal;
/** Base64 encoder (android.util.Base64 exists on 2.2+, but this keeps the core portable). */
public final class B64 {
private static final char[] T =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
private B64() {}
public static String encode(byte[] in) {
StringBuilder b = new StringBuilder((in.length + 2) / 3 * 4);
for (int i = 0; i < in.length; i += 3) {
int n = (in[i] & 0xff) << 16;
if (i + 1 < in.length) n |= (in[i + 1] & 0xff) << 8;
if (i + 2 < in.length) n |= in[i + 2] & 0xff;
b.append(T[(n >> 18) & 63]).append(T[(n >> 12) & 63]);
b.append(i + 1 < in.length ? T[(n >> 6) & 63] : '=');
b.append(i + 2 < in.length ? T[n & 63] : '=');
}
return b.toString();
}
}
+386
View File
@@ -0,0 +1,386 @@
package org.gingercal;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
import org.gingercal.Xml.Node;
/** CalDAV client: discovery, calendar listing, event query and simple writes. */
public class Dav {
public static final String D = "DAV:";
public static final String C = "urn:ietf:params:xml:ns:caldav";
public static final String CS = "http://calendarserver.org/ns/";
public static final String A = "http://apple.com/ns/ical/";
final Http http;
final String url;
final String auth;
public Dav(Http http, String serverUrl, String user, String password) {
this.http = http;
this.url = normalize(serverUrl);
try {
this.auth = "Basic " + B64.encode((user + ":" + password).getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
public static String normalize(String u) {
u = u.trim();
if (!u.toLowerCase(Locale.US).startsWith("http://") && !u.toLowerCase(Locale.US).startsWith("https://")) {
u = "https://" + u;
}
while (u.endsWith("/") && u.indexOf('/', 8) < u.length() - 1) u = u.substring(0, u.length() - 1);
return u;
}
static String origin(String u) {
URI x = URI.create(u);
return x.getScheme() + "://" + x.getRawAuthority();
}
/** scheme://host:port with default ports filled in, for comparing redirect targets. */
static String originKey(String u) {
URI x = URI.create(u);
String s = x.getScheme() == null ? "" : x.getScheme().toLowerCase(Locale.US);
int port = x.getPort();
if (port < 0) port = s.equals("https") ? 443 : 80;
String host = x.getHost() == null ? "" : x.getHost().toLowerCase(Locale.US);
return s + "://" + host + ":" + port;
}
public static String abs(String base, String href) {
if (href.startsWith("http://") || href.startsWith("https://")) return href;
return URI.create(base).resolve(href).toString();
}
// -------------------------------------------------------------- requests
public static class Response {
public String href;
public final Map<String, Node> props = new HashMap<String, Node>();
public Node prop(String ns, String name) {
return props.get(ns + name);
}
public String text(String ns, String name) {
Node n = prop(ns, name);
return n == null ? null : n.text();
}
}
/**
* Sends a request, following redirects on the same server (Apache won't for PROPFIND/REPORT).
* Redirects elsewhere, typically Nextcloud forcing its public https:// address, can't work
* on Android 2.3, so they fail with an explanation instead of a doomed TLS 1.2 attempt.
*/
Http.Resp send(String method, String target, String depth, String body, String[] extra)
throws IOException {
String u = target;
List<String> visited = new ArrayList<String>();
for (int hop = 0; hop < 6; hop++) {
visited.add(u);
List<String> h = new ArrayList<String>();
h.add("Authorization");
h.add(auth);
if (depth != null) {
h.add("Depth");
h.add(depth);
}
if (extra != null) for (int i = 0; i < extra.length; i++) h.add(extra[i]);
String ct = body == null ? null
: method.equals("PUT") ? "text/calendar; charset=utf-8" : "application/xml; charset=utf-8";
Http.Resp r = http.exec(method, u, h.toArray(new String[h.size()]), body, ct);
if ((r.code == 301 || r.code == 302 || r.code == 303 || r.code == 307 || r.code == 308)
&& r.location != null) {
String next = abs(u, r.location);
checkRedirect(u, next, visited, r.code);
u = next;
continue;
}
if (r.code == 401) {
throw new DavException(401, "Login refused (401). Check the user name and use a "
+ "Nextcloud app password (Settings > Security > Devices & sessions).");
}
if (r.code == 403 && body != null && method.equals("PUT")) {
throw new DavException(403, "Not allowed to write to this calendar (403). It may be read-only/shared.");
}
return r;
}
throw new DavException(DavException.REDIRECT, "Too many redirects from the server.");
}
static void checkRedirect(String from, String to, List<String> visited, int code) throws DavException {
String a = originKey(from), b = originKey(to);
if (a.startsWith("http:") && b.startsWith("https:")) {
throw new DavException(DavException.REDIRECT, "The server redirected http:// to "
+ origin(to) + " (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: "
+ "use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').");
}
if (!a.equals(b)) {
throw new DavException(DavException.REDIRECT, "The server redirected to " + origin(to)
+ ", not the address set in the app. Nextcloud is sending its public URL; check the "
+ "bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').");
}
if (visited.contains(to)) {
throw new DavException(DavException.REDIRECT, "Redirect loop (HTTP " + code + " back to the same "
+ "address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the "
+ "bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS').");
}
}
List<Response> multistatus(String method, String target, String depth, String body) throws IOException {
Http.Resp r = send(method, target, depth, body, null);
if (r.code != 207) {
throw new DavException(r.code, method + " " + URI.create(target).getPath() + " failed: HTTP " + r.code
+ (r.code == 404 ? " (not found)" : r.code == 405 ? " (not a WebDAV URL?)" : ""));
}
Node ms = Xml.parse(r.body);
List<Response> out = new ArrayList<Response>();
for (Node rn : ms.children(D, "response")) {
Response res = new Response();
Node href = rn.child(D, "href");
res.href = href == null ? "" : href.text();
for (Node ps : rn.children(D, "propstat")) {
Node st = ps.child(D, "status");
if (st != null && st.text().indexOf(" 200") < 0) continue;
Node prop = ps.child(D, "prop");
if (prop == null) continue;
for (Node p : prop.kids) res.props.put(p.ns + p.name, p);
}
out.add(res);
}
return out;
}
static String propfind(String props) {
return "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"" + C + "\" xmlns:cs=\"" + CS + "\" xmlns:a=\"" + A + "\">"
+ "<d:prop>" + props + "</d:prop></d:propfind>";
}
static String hrefIn(Response r, String ns, String name) {
Node n = r.prop(ns, name);
if (n == null) return null;
Node h = n.find(D, "href");
return h == null ? null : h.text();
}
// ------------------------------------------------------------- discovery
/** Finds the calendar-home-set URL for the logged-in user. */
public String calendarHome() throws IOException {
List<String> candidates = new ArrayList<String>();
String path = URI.create(url).getPath();
String o = origin(url);
if (path == null || path.length() <= 1) {
candidates.add(o + "/remote.php/dav/");
candidates.add(o + "/.well-known/caldav");
candidates.add(o + "/");
} else {
candidates.add(url.endsWith("/") ? url : url + "/");
}
String principal = null;
IOException last = null;
for (String c : candidates) {
try {
List<Response> rs = multistatus("PROPFIND", c, "0", propfind("<d:current-user-principal/>"));
for (Response r : rs) {
String h = hrefIn(r, D, "current-user-principal");
if (h != null) principal = abs(c, h);
}
if (principal != null) break;
} catch (DavException e) {
if (e.code == 401 || e.code == DavException.REDIRECT) throw e;
last = e;
}
}
if (principal == null) {
throw last != null ? last : new DavException(0, "Server did not report a CalDAV principal");
}
List<Response> rs = multistatus("PROPFIND", principal, "0", propfind("<c:calendar-home-set/>"));
for (Response r : rs) {
String h = hrefIn(r, C, "calendar-home-set");
if (h != null) return abs(principal, h);
}
return principal; // some servers keep calendars directly under the principal
}
public List<Cal> calendars(String home) throws IOException {
List<Response> rs = multistatus("PROPFIND", home, "1", propfind(
"<d:resourcetype/><d:displayname/><a:calendar-color/><cs:getctag/>"
+ "<c:supported-calendar-component-set/><d:current-user-privilege-set/>"));
List<Cal> out = new ArrayList<Cal>();
for (Response r : rs) {
Node rt = r.prop(D, "resourcetype");
if (rt == null || rt.child(C, "calendar") == null) continue;
Node comps = r.prop(C, "supported-calendar-component-set");
if (comps != null && !comps.kids.isEmpty()) {
boolean ev = false;
for (Node k : comps.kids) ev |= k.name.equals("comp") && k.attr("name").equalsIgnoreCase("VEVENT");
if (!ev) continue;
}
Cal c = new Cal();
c.href = abs(home, r.href);
String dn = r.text(D, "displayname");
c.name = dn == null || dn.length() == 0 ? lastSegment(c.href) : dn;
c.color = parseColor(r.text(A, "calendar-color"), c.color);
String ctag = r.text(CS, "getctag");
c.ctag = ctag == null ? "" : ctag;
Node priv = r.prop(D, "current-user-privilege-set");
if (priv != null) {
c.writable = priv.find(D, "write") != null || priv.find(D, "write-content") != null
|| priv.find(D, "all") != null;
}
out.add(c);
}
return out;
}
static String lastSegment(String href) {
String h = href.endsWith("/") ? href.substring(0, href.length() - 1) : href;
return Uri.decode(h.substring(h.lastIndexOf('/') + 1));
}
static int parseColor(String s, int def) {
if (s == null) return def;
s = s.trim();
if (!s.startsWith("#")) return def;
try {
if (s.length() == 7) return 0xFF000000 | Integer.parseInt(s.substring(1), 16);
if (s.length() == 9) return 0xFF000000 | Integer.parseInt(s.substring(1, 7), 16);
} catch (NumberFormatException ignored) {
}
return def;
}
// ------------------------------------------------------------------ events
public List<Ev> events(Cal cal, long from, long to) throws IOException {
String f = ICal.utc(from), t = ICal.utc(to);
String body = "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"" + C + "\">"
+ "<d:prop><d:getetag/><c:calendar-data><c:expand start=\"" + f + "\" end=\"" + t + "\"/>"
+ "</c:calendar-data></d:prop>"
+ "<c:filter><c:comp-filter name=\"VCALENDAR\"><c:comp-filter name=\"VEVENT\">"
+ "<c:time-range start=\"" + f + "\" end=\"" + t + "\"/>"
+ "</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>";
List<Response> rs = multistatus("REPORT", cal.href, "1", body);
List<Ev> out = new ArrayList<Ev>();
for (Response r : rs) {
String data = r.text(C, "calendar-data");
if (data == null || data.length() == 0) continue;
String etag = r.text(D, "getetag");
String href = abs(cal.href, r.href);
List<ICal.VEvent> vs = ICal.parse(data);
boolean recurring = vs.size() > 1;
for (ICal.VEvent v : vs) if (v.rrule != null || v.recurrenceId != null || v.hasRdate) recurring = true;
// Overridden instances (when the server didn't expand) replace generated ones.
Map<Long, ICal.VEvent> overrides = new HashMap<Long, ICal.VEvent>();
for (ICal.VEvent v : vs) {
if (v.recurrenceId != null && v.rrule == null && hasMasterWithRule(vs)) {
try {
overrides.put(ICal.parseTime(v.recurrenceId, null, null)[0], v);
} catch (RuntimeException ignored) {
}
}
}
for (ICal.VEvent v : vs) {
if (overrides.containsValue(v)) continue;
for (long[] occ : ICal.occurrences(v, from, to)) {
ICal.VEvent src = overrides.containsKey(occ[0]) ? overrides.get(occ[0]) : v;
Ev e = new Ev();
e.cal = cal.href;
e.href = href;
e.etag = etag == null ? "" : etag;
e.uid = src.uid;
e.title = src.summary;
e.location = src.location;
e.descr = src.description;
e.start = src == v ? occ[0] : src.start;
e.end = src == v ? occ[1] : src.end;
e.allDay = src.allDay;
e.recurring = recurring;
out.add(e);
}
}
}
return out;
}
static boolean hasMasterWithRule(List<ICal.VEvent> vs) {
for (ICal.VEvent v : vs) if (v.rrule != null) return true;
return false;
}
/** GET a single object. Returns {ics, etag}. */
public String[] get(String href) throws IOException {
Http.Resp r = send("GET", href, null, null, null);
if (r.code != 200) throw new DavException(r.code, "Could not load event: HTTP " + r.code);
return new String[] {r.body, r.etag};
}
public void create(String calHref, String uid, String ics) throws IOException {
String href = abs(calHref.endsWith("/") ? calHref : calHref + "/", uid + ".ics");
Http.Resp r = send("PUT", href, null, ics, new String[] {"If-None-Match", "*"});
if (r.code != 201 && r.code != 204 && r.code != 200) {
throw new DavException(r.code, "Saving failed: HTTP " + r.code);
}
}
public void update(String href, String etag, String ics) throws IOException {
String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag};
Http.Resp r = send("PUT", href, null, ics, h);
if (r.code == 412) throw new DavException(412, "Event was changed on the server meanwhile. Sync and try again.");
if (r.code != 201 && r.code != 204 && r.code != 200) {
throw new DavException(r.code, "Saving failed: HTTP " + r.code);
}
}
public void delete(String href, String etag) throws IOException {
String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag};
Http.Resp r = send("DELETE", href, null, null, h);
if (r.code == 412) throw new DavException(412, "Event was changed on the server meanwhile. Sync and try again.");
if (r.code != 204 && r.code != 200 && r.code != 404) {
throw new DavException(r.code, "Delete failed: HTTP " + r.code);
}
}
public void close() {
http.close();
}
/** Minimal percent-decoder (java.net.URLDecoder would turn '+' into spaces). */
static final class Uri {
static String decode(String s) {
try {
byte[] buf = new byte[s.length()];
int n = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '%' && i + 2 < s.length()) {
buf[n++] = (byte) Integer.parseInt(s.substring(i + 1, i + 3), 16);
i += 2;
} else if (c < 128) {
buf[n++] = (byte) c;
} else {
return s;
}
}
return new String(buf, 0, n, "UTF-8");
} catch (Exception e) {
return s;
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package org.gingercal;
import java.io.IOException;
/** A server/protocol problem with a message that can be shown to the user as-is. */
public class DavException extends IOException {
public final int code;
/** code for redirect problems (forced HTTPS, loops); retrying another URL won't help. */
public static final int REDIRECT = -2;
public DavException(int code, String msg) {
super(msg);
this.code = code;
}
/** Thrown when the TLS certificate isn't trusted (or doesn't match the pin). */
public static class UntrustedCert extends IOException {
public final String fingerprint, subject, issuer, expires;
public final boolean pinMismatch;
public UntrustedCert(String fingerprint, String subject, String issuer, String expires,
boolean pinMismatch) {
super(pinMismatch ? "Server certificate changed (does not match the pinned fingerprint)"
: "Server certificate is not trusted by this phone");
this.fingerprint = fingerprint;
this.subject = subject;
this.issuer = issuer;
this.expires = expires;
this.pinMismatch = pinMismatch;
}
}
}
+20
View File
@@ -0,0 +1,20 @@
package org.gingercal;
import java.io.IOException;
/** Minimal HTTP transport so the CalDAV logic doesn't depend on Android classes. */
public interface Http {
/** headers: alternating name, value. body/contentType may be null. */
Resp exec(String method, String url, String[] headers, String body, String contentType)
throws IOException;
void close();
class Resp {
public int code;
public String body = "";
public String etag;
public String location;
}
}
+552
View File
@@ -0,0 +1,552 @@
package org.gingercal;
import java.io.UnsupportedEncodingException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
/** Just enough iCalendar (RFC 5545) to read, create and edit simple VEVENTs. */
public class ICal {
public static final long MIN = 60000L, HOUR = 60 * MIN, DAY = 24 * HOUR;
static final TimeZone UTC = TimeZone.getTimeZone("UTC");
public static class Prop {
public String name = "";
public final Map<String, String> params = new HashMap<String, String>();
public String value = "";
public String param(String k) {
return params.get(k);
}
}
public static class VEvent {
public String uid = "", summary = "", location = "", description = "";
public String rrule, recurrenceId;
public long start, end;
public boolean allDay;
public TimeZone tz = TimeZone.getDefault();
public final List<Long> exdates = new ArrayList<Long>();
public int sequence;
/** Minutes before start of the first display alarm, or -1 when there is none. */
public int alarmMin = -1;
boolean hasEnd;
long duration = -1;
boolean hasRdate;
}
// ---------------------------------------------------------------- parsing
public static List<String> unfold(String s) {
List<String> out = new ArrayList<String>();
String[] raw = s.split("\r\n|\n|\r");
for (int i = 0; i < raw.length; i++) {
String l = raw[i];
if ((l.startsWith(" ") || l.startsWith("\t")) && !out.isEmpty()) {
int last = out.size() - 1;
out.set(last, out.get(last) + l.substring(1));
} else if (l.length() > 0) {
out.add(l);
}
}
return out;
}
public static Prop prop(String line) {
Prop p = new Prop();
boolean q = false;
int colon = -1;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"') q = !q;
else if (c == ':' && !q) {
colon = i;
break;
}
}
String head = colon < 0 ? line : line.substring(0, colon);
p.value = colon < 0 ? "" : line.substring(colon + 1);
List<String> parts = new ArrayList<String>();
q = false;
int st = 0;
for (int i = 0; i < head.length(); i++) {
char c = head.charAt(i);
if (c == '"') q = !q;
else if (c == ';' && !q) {
parts.add(head.substring(st, i));
st = i + 1;
}
}
parts.add(head.substring(st));
p.name = parts.get(0).toUpperCase(Locale.US);
for (int i = 1; i < parts.size(); i++) {
String kv = parts.get(i);
int eq = kv.indexOf('=');
if (eq < 0) continue;
String v = kv.substring(eq + 1);
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1);
p.params.put(kv.substring(0, eq).toUpperCase(Locale.US), v);
}
return p;
}
public static String unescape(String s) {
if (s.indexOf('\\') < 0) return s;
StringBuilder b = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\' && i + 1 < s.length()) {
char n = s.charAt(++i);
if (n == 'n' || n == 'N') b.append('\n');
else b.append(n);
} else {
b.append(c);
}
}
return b.toString();
}
public static String escape(String s) {
StringBuilder b = new StringBuilder(s.length() + 8);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\' || c == ';' || c == ',') b.append('\\').append(c);
else if (c == '\n') b.append("\\n");
else if (c != '\r') b.append(c);
}
return b.toString();
}
public static TimeZone zone(String tzid) {
if (tzid == null || tzid.length() == 0) return TimeZone.getDefault();
String id = tzid.startsWith("/") ? tzid.substring(1) : tzid;
TimeZone tz = TimeZone.getTimeZone(id);
// Unknown ids (e.g. Outlook's "Romance Standard Time") come back as GMT.
if ("GMT".equals(tz.getID()) && !id.toUpperCase(Locale.US).contains("GMT")
&& !id.toUpperCase(Locale.US).contains("UTC")) {
return TimeZone.getDefault();
}
return tz;
}
/** Parses DATE / DATE-TIME values. Returns {millis, allDay(0/1)}. */
public static long[] parseTime(String v, String tzid, String valueType) {
v = v.trim();
int comma = v.indexOf(',');
if (comma > 0) v = v.substring(0, comma);
boolean date = "DATE".equalsIgnoreCase(valueType) || v.length() == 8;
int y = Integer.parseInt(v.substring(0, 4));
int mo = Integer.parseInt(v.substring(4, 6)) - 1;
int d = Integer.parseInt(v.substring(6, 8));
if (date) {
Calendar c = Calendar.getInstance(TimeZone.getDefault());
c.clear();
c.set(y, mo, d, 0, 0, 0);
return new long[] {c.getTimeInMillis(), 1};
}
int h = Integer.parseInt(v.substring(9, 11));
int mi = Integer.parseInt(v.substring(11, 13));
int s = v.length() >= 15 ? Integer.parseInt(v.substring(13, 15)) : 0;
TimeZone tz = v.endsWith("Z") ? UTC : zone(tzid);
Calendar c = Calendar.getInstance(tz);
c.clear();
c.set(y, mo, d, h, mi, s);
return new long[] {c.getTimeInMillis(), 0};
}
/** RFC 5545 DURATION, e.g. P1D, PT1H30M, -PT15M, P2W. */
public static long parseDuration(String v) {
v = v.trim().toUpperCase(Locale.US);
int sign = 1;
int i = 0;
if (v.startsWith("-")) {
sign = -1;
i = 1;
} else if (v.startsWith("+")) {
i = 1;
}
long total = 0;
long num = 0;
boolean time = false;
for (; i < v.length(); i++) {
char c = v.charAt(i);
if (c >= '0' && c <= '9') num = num * 10 + (c - '0');
else if (c == 'T') time = true;
else if (c == 'W') { total += num * 7 * DAY; num = 0; }
else if (c == 'D') { total += num * DAY; num = 0; }
else if (c == 'H') { total += num * HOUR; num = 0; }
else if (c == 'M' && time) { total += num * MIN; num = 0; }
else if (c == 'S') { total += num * 1000L; num = 0; }
}
return sign * total;
}
/** Parses all top-level VEVENTs (alarms and other nested components are skipped). */
public static List<VEvent> parse(String ics) {
List<VEvent> out = new ArrayList<VEvent>();
List<String> stack = new ArrayList<String>();
VEvent cur = null;
boolean inAlarm = false;
String alarmAction = null;
long alarmTrigger = 0;
boolean alarmRelEnd = false;
boolean alarmHasTrigger = false;
for (String line : unfold(ics)) {
Prop p = prop(line);
if (p.name.equals("BEGIN")) {
String comp = p.value.trim().toUpperCase(Locale.US);
stack.add(comp);
if (comp.equals("VEVENT") && stack.size() == 2) {
cur = new VEvent();
} else if (comp.equals("VALARM") && cur != null) {
inAlarm = true;
alarmAction = null;
alarmHasTrigger = false;
alarmRelEnd = false;
}
continue;
}
if (p.name.equals("END")) {
String comp = p.value.trim().toUpperCase(Locale.US);
if (comp.equals("VALARM") && inAlarm) {
inAlarm = false;
if (alarmHasTrigger && !alarmRelEnd && alarmTrigger <= 0 && cur.alarmMin < 0
&& (alarmAction == null || alarmAction.equals("DISPLAY") || alarmAction.equals("AUDIO"))) {
cur.alarmMin = (int) (-alarmTrigger / MIN);
}
}
if (comp.equals("VEVENT") && cur != null && stack.size() == 2) {
finish(cur);
out.add(cur);
cur = null;
}
if (!stack.isEmpty()) stack.remove(stack.size() - 1);
continue;
}
if (cur == null) continue;
if (inAlarm) {
if (p.name.equals("ACTION")) alarmAction = p.value.trim().toUpperCase(Locale.US);
if (p.name.equals("TRIGGER") && !"DATE-TIME".equalsIgnoreCase(p.param("VALUE"))) {
try {
alarmTrigger = parseDuration(p.value);
alarmHasTrigger = true;
alarmRelEnd = "END".equalsIgnoreCase(p.param("RELATED"));
} catch (RuntimeException ignored) {
}
}
continue;
}
if (stack.size() != 2) continue;
try {
if (p.name.equals("UID")) cur.uid = p.value.trim();
else if (p.name.equals("SUMMARY")) cur.summary = unescape(p.value);
else if (p.name.equals("LOCATION")) cur.location = unescape(p.value);
else if (p.name.equals("DESCRIPTION")) cur.description = unescape(p.value);
else if (p.name.equals("RRULE")) cur.rrule = p.value.trim();
else if (p.name.equals("RDATE")) cur.hasRdate = true;
else if (p.name.equals("SEQUENCE")) cur.sequence = Integer.parseInt(p.value.trim());
else if (p.name.equals("RECURRENCE-ID")) {
cur.recurrenceId = p.value.trim();
} else if (p.name.equals("DTSTART")) {
long[] t = parseTime(p.value, p.param("TZID"), p.param("VALUE"));
cur.start = t[0];
cur.allDay = t[1] == 1;
String v = p.value.trim();
cur.tz = v.endsWith("Z") ? UTC : zone(p.param("TZID"));
} else if (p.name.equals("DTEND")) {
cur.end = parseTime(p.value, p.param("TZID"), p.param("VALUE"))[0];
cur.hasEnd = true;
} else if (p.name.equals("DURATION")) {
cur.duration = parseDuration(p.value);
} else if (p.name.equals("EXDATE")) {
String[] vals = p.value.split(",");
for (int i = 0; i < vals.length; i++) {
if (vals[i].trim().length() >= 8) {
cur.exdates.add(parseTime(vals[i], p.param("TZID"), p.param("VALUE"))[0]);
}
}
}
} catch (RuntimeException badValue) {
// ignore a malformed property rather than dropping the whole event
}
}
return out;
}
static void finish(VEvent e) {
if (!e.hasEnd) {
if (e.duration >= 0) e.end = e.start + e.duration;
else e.end = e.allDay ? addDays(e.start, 1) : e.start;
}
if (e.end < e.start) e.end = e.start;
}
/** Adds calendar days in the default zone (DST-safe for all-day dates). */
public static long addDays(long t, int days) {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(t);
c.add(Calendar.DAY_OF_MONTH, days);
return c.getTimeInMillis();
}
public static long startOfDay(long t) {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(t);
c.set(Calendar.HOUR_OF_DAY, 0);
c.set(Calendar.MINUTE, 0);
c.set(Calendar.SECOND, 0);
c.set(Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
// ------------------------------------------------- client-side recurrence
// Used only if the server ignores <C:expand>. Covers the common rules.
public static List<long[]> occurrences(VEvent e, long from, long to) {
List<long[]> out = new ArrayList<long[]>();
long dur = e.end - e.start;
if (e.rrule == null) {
if (e.end > from && e.start < to || e.start >= from && e.start < to) out.add(new long[] {e.start, e.end});
return out;
}
Map<String, String> r = new HashMap<String, String>();
String[] parts = e.rrule.split(";");
for (int i = 0; i < parts.length; i++) {
int eq = parts[i].indexOf('=');
if (eq > 0) r.put(parts[i].substring(0, eq).toUpperCase(Locale.US), parts[i].substring(eq + 1));
}
String freq = r.containsKey("FREQ") ? r.get("FREQ").toUpperCase(Locale.US) : "DAILY";
int interval = r.containsKey("INTERVAL") ? Math.max(1, Integer.parseInt(r.get("INTERVAL"))) : 1;
int count = r.containsKey("COUNT") ? Integer.parseInt(r.get("COUNT")) : -1;
long until = Long.MAX_VALUE;
if (r.containsKey("UNTIL")) {
long[] u = parseTime(r.get("UNTIL"), null, null);
until = u[1] == 1 ? addDays(u[0], 1) - 1 : u[0];
}
int[] byDay = null;
if (freq.equals("WEEKLY") && r.containsKey("BYDAY")) {
String[] days = r.get("BYDAY").split(",");
byDay = new int[days.length];
for (int i = 0; i < days.length; i++) byDay[i] = weekday(days[i].replaceAll("[^A-Za-z]", ""));
}
Calendar c = Calendar.getInstance(e.allDay ? TimeZone.getDefault() : e.tz);
c.setTimeInMillis(e.start);
int field = freq.equals("YEARLY") ? Calendar.YEAR
: freq.equals("MONTHLY") ? Calendar.MONTH
: freq.equals("WEEKLY") ? Calendar.WEEK_OF_YEAR : Calendar.DAY_OF_MONTH;
int produced = 0;
for (int step = 0; step < 3000; step++) {
Calendar base = (Calendar) c.clone();
base.add(field, step * interval);
List<Long> starts = new ArrayList<Long>();
if (byDay != null) {
Calendar wk = (Calendar) base.clone();
// move to the first day of that week (week starts on the event's weekday reference)
int dow = wk.get(Calendar.DAY_OF_WEEK);
wk.add(Calendar.DAY_OF_MONTH, -((dow - Calendar.MONDAY + 7) % 7));
for (int d = 0; d < 7; d++) {
Calendar x = (Calendar) wk.clone();
x.add(Calendar.DAY_OF_MONTH, d);
for (int k = 0; k < byDay.length; k++) {
if (x.get(Calendar.DAY_OF_WEEK) == byDay[k] && x.getTimeInMillis() >= e.start) {
starts.add(x.getTimeInMillis());
}
}
}
} else {
starts.add(base.getTimeInMillis());
}
for (int i = 0; i < starts.size(); i++) {
long s = starts.get(i);
if (s > until || (count >= 0 && produced >= count)) return out;
produced++;
if (e.exdates.contains(s)) continue;
if (s < to && s + dur > from || (dur == 0 && s >= from && s < to)) out.add(new long[] {s, s + dur});
}
if (base.getTimeInMillis() >= to) break;
}
return out;
}
static int weekday(String s) {
s = s.toUpperCase(Locale.US);
if (s.equals("MO")) return Calendar.MONDAY;
if (s.equals("TU")) return Calendar.TUESDAY;
if (s.equals("WE")) return Calendar.WEDNESDAY;
if (s.equals("TH")) return Calendar.THURSDAY;
if (s.equals("FR")) return Calendar.FRIDAY;
if (s.equals("SA")) return Calendar.SATURDAY;
return Calendar.SUNDAY;
}
// ---------------------------------------------------------------- writing
public static String utc(long t) {
SimpleDateFormat f = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US);
f.setTimeZone(UTC);
return f.format(new Date(t));
}
public static String date(long t) {
SimpleDateFormat f = new SimpleDateFormat("yyyyMMdd", Locale.US);
f.setTimeZone(TimeZone.getDefault());
return f.format(new Date(t));
}
static void timeProps(List<String> out, long start, long end, boolean allDay) {
if (allDay) {
out.add("DTSTART;VALUE=DATE:" + date(start));
out.add("DTEND;VALUE=DATE:" + date(end));
} else {
out.add("DTSTART:" + utc(start));
out.add("DTEND:" + utc(end));
}
}
static void alarm(List<String> out, int minutes) {
if (minutes < 0) return;
out.add("BEGIN:VALARM");
out.add("ACTION:DISPLAY");
out.add("DESCRIPTION:Reminder");
out.add("TRIGGER:-PT" + minutes + "M");
out.add("END:VALARM");
}
public static String newEvent(String uid, String summary, String location, String description,
long start, long end, boolean allDay, int alarmMin) {
List<String> l = new ArrayList<String>();
l.add("BEGIN:VCALENDAR");
l.add("VERSION:2.0");
l.add("PRODID:-//GingerCal//Android 2.3//EN");
l.add("BEGIN:VEVENT");
l.add("UID:" + uid);
long now = System.currentTimeMillis();
l.add("DTSTAMP:" + utc(now));
l.add("CREATED:" + utc(now));
l.add("LAST-MODIFIED:" + utc(now));
timeProps(l, start, end, allDay);
l.add("SUMMARY:" + escape(summary));
if (location.length() > 0) l.add("LOCATION:" + escape(location));
if (description.length() > 0) l.add("DESCRIPTION:" + escape(description));
alarm(l, alarmMin);
l.add("END:VEVENT");
l.add("END:VCALENDAR");
return join(l);
}
/** True when the object holds a recurring series (we only edit single events). */
public static boolean isRecurring(String ics) {
List<VEvent> vs = parse(ics);
if (vs.size() > 1) return true;
for (VEvent v : vs) if (v.rrule != null || v.recurrenceId != null || v.hasRdate) return true;
return false;
}
static final String[] REPLACED = {"DTSTART", "DTEND", "DURATION", "SUMMARY", "LOCATION",
"DESCRIPTION", "DTSTAMP", "LAST-MODIFIED", "SEQUENCE"};
/**
* Rewrites the editable fields of the (single) VEVENT, keeping every other property
* (attendees, categories, URL, ...) untouched. alarmMin == -2 keeps existing alarms.
*/
public static String modify(String ics, String summary, String location, String description,
long start, long end, boolean allDay, int alarmMin) {
List<String> in = unfold(ics);
List<String> out = new ArrayList<String>();
List<String> stack = new ArrayList<String>();
boolean inTarget = false, done = false, skipAlarm = false;
int sequence = 0;
for (String line : in) {
Prop p = prop(line);
if (p.name.equals("BEGIN")) {
String comp = p.value.trim().toUpperCase(Locale.US);
stack.add(comp);
if (comp.equals("VEVENT") && stack.size() == 2 && !done) inTarget = true;
if (comp.equals("VALARM") && inTarget && alarmMin != -2) {
skipAlarm = true;
continue;
}
} else if (p.name.equals("END")) {
String comp = p.value.trim().toUpperCase(Locale.US);
if (comp.equals("VALARM") && skipAlarm) {
skipAlarm = false;
stack.remove(stack.size() - 1);
continue;
}
if (comp.equals("VEVENT") && inTarget && stack.size() == 2) {
long now = System.currentTimeMillis();
out.add("DTSTAMP:" + utc(now));
out.add("LAST-MODIFIED:" + utc(now));
out.add("SEQUENCE:" + (sequence + 1));
timeProps(out, start, end, allDay);
out.add("SUMMARY:" + escape(summary));
if (location.length() > 0) out.add("LOCATION:" + escape(location));
if (description.length() > 0) out.add("DESCRIPTION:" + escape(description));
if (alarmMin != -2) alarm(out, alarmMin);
inTarget = false;
done = true;
}
if (!stack.isEmpty()) stack.remove(stack.size() - 1);
} else if (skipAlarm) {
continue;
} else if (inTarget && stack.size() == 2) {
if (p.name.equals("SEQUENCE")) {
try {
sequence = Integer.parseInt(p.value.trim());
} catch (RuntimeException ignored) {
}
}
boolean drop = false;
for (int i = 0; i < REPLACED.length; i++) if (REPLACED[i].equals(p.name)) drop = true;
if (drop) continue;
}
out.add(line);
}
return join(out);
}
static String join(List<String> lines) {
StringBuilder b = new StringBuilder();
for (String l : lines) b.append(fold(l)).append("\r\n");
return b.toString();
}
/** Folds to 75 octets without splitting UTF-8 sequences. */
static String fold(String line) {
try {
if (line.getBytes("UTF-8").length <= 75) return line;
StringBuilder b = new StringBuilder();
int bytes = 0;
int limit = 75;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
int n;
String unit;
if (Character.isHighSurrogate(c) && i + 1 < line.length()) {
unit = line.substring(i, i + 2);
i++;
} else {
unit = String.valueOf(c);
}
n = unit.getBytes("UTF-8").length;
if (bytes + n > limit) {
b.append("\r\n ");
bytes = 1;
limit = 75;
}
b.append(unit);
bytes += n;
}
return b.toString();
} catch (UnsupportedEncodingException e) {
return line;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package org.gingercal;
import java.util.List;
import java.util.Map;
/** Plain data holders shared by the sync engine, the database and the UI. */
public final class Model {
private Model() {}
public static class Cal {
public String href = "", name = "";
public int color = 0xFF0082C9; // Nextcloud blue
public boolean enabled = true, writable = true;
public String ctag = "";
/** Start-of-window (ms) the cached events were fetched for; 0 = never. */
public long synced;
}
public static class Ev {
public long id;
public String cal = "", href = "", etag = "", uid = "";
public String title = "", location = "", descr = "";
public long start, end;
public boolean allDay, recurring;
public int color;
public boolean writable = true;
}
/** Persistence used by the sync engine (SQLite on the phone, in-memory in tests). */
public interface Store {
Map<String, Cal> calendars();
void saveCalendar(Cal c);
void deleteCalendar(String href);
void replaceEvents(String calHref, List<Ev> events);
}
}
+70
View File
@@ -0,0 +1,70 @@
package org.gingercal;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
import org.gingercal.Model.Store;
/** One-way server-to-phone cache refresh. Writes go straight to the server. */
public class Syncer {
public static class Result {
public int calendars, fetched, skipped, events;
public String summary() {
return calendars + " calendars: " + fetched + " refreshed (" + events + " events)"
+ (skipped > 0 ? ", " + skipped + " unchanged" : "");
}
}
public static Result sync(Dav dav, Store store, long now, int pastDays, int futureDays, boolean force)
throws IOException {
Result res = new Result();
String home = dav.calendarHome();
List<Cal> remote = dav.calendars(home);
Map<String, Cal> local = store.calendars();
Set<String> seen = new HashSet<String>();
long from = ICal.addDays(ICal.startOfDay(now), -pastDays);
long to = ICal.addDays(ICal.startOfDay(now), futureDays + 1);
for (Cal r : remote) {
seen.add(r.href);
Cal old = local.get(r.href);
if (old != null) {
r.enabled = old.enabled;
r.synced = old.synced;
}
boolean unchanged = !force && old != null && r.ctag.length() > 0 && r.ctag.equals(old.ctag)
&& old.synced == from;
if (!r.enabled) {
if (old == null || old.synced != 0) store.replaceEvents(r.href, new ArrayList<Ev>());
r.synced = 0;
store.saveCalendar(r);
continue;
}
res.calendars++;
if (unchanged) {
res.skipped++;
r.ctag = old.ctag;
store.saveCalendar(r);
continue;
}
List<Ev> evs = dav.events(r, from, to);
store.replaceEvents(r.href, evs);
res.events += evs.size();
r.synced = from;
store.saveCalendar(r);
res.fetched++;
}
for (String href : local.keySet()) {
if (!seen.contains(href)) store.deleteCalendar(href);
}
return res;
}
}
+112
View File
@@ -0,0 +1,112 @@
package org.gingercal;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.Locale;
import javax.net.ssl.SSLException;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
/** Certificate pinning for servers the phone can't validate itself (self-signed bridge). */
public final class Tls {
private Tls() {}
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 (GeneralSecurityException e) {
return "";
}
}
public static String normPin(String s) {
if (s == null) return "";
return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US);
}
/** Accepts the system CAs, or exactly one pinned leaf certificate (SHA-256). */
public static class PinTrust implements X509TrustManager {
final String pin;
final X509TrustManager system;
String seenFp, seenSubject, seenIssuer, seenExpiry;
boolean rejected, mismatch;
public PinTrust(String pin) throws GeneralSecurityException {
this.pin = normPin(pin);
TrustManagerFactory f = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
f.init((KeyStore) null);
X509TrustManager s = null;
TrustManager[] tms = f.getTrustManagers();
for (int i = 0; i < tms.length; i++) if (tms[i] instanceof X509TrustManager) s = (X509TrustManager) tms[i];
system = s;
}
public boolean pinned() {
return pin.length() > 0;
}
public void reset() {
seenFp = null;
rejected = false;
mismatch = false;
}
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new CertificateException("client certs not supported");
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) throw new CertificateException("empty chain");
X509Certificate leaf = chain[0];
seenFp = sha256(leaf.getEncoded());
seenSubject = leaf.getSubjectDN().getName();
seenIssuer = leaf.getIssuerDN().getName();
seenExpiry = new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(leaf.getNotAfter());
if (pinned()) {
if (normPin(seenFp).equals(pin)) return;
rejected = true;
mismatch = true;
throw new CertificateException("certificate does not match pin");
}
try {
if (system == null) throw new CertificateException("no system trust store");
system.checkServerTrusted(chain, authType);
} catch (CertificateException e) {
rejected = true;
throw e;
}
}
public X509Certificate[] getAcceptedIssuers() {
return system == null ? new X509Certificate[0] : system.getAcceptedIssuers();
}
public IOException translate(IOException e) {
if (rejected && seenFp != null) {
return new DavException.UntrustedCert(seenFp, seenSubject, seenIssuer, seenExpiry, mismatch);
}
if (e instanceof SSLException && seenFp == null) {
return new DavException(0, "TLS handshake failed before any certificate was sent. The server "
+ "most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the "
+ "legacy-TLS bridge (see README) or use http:// on your LAN. (" + e.getMessage() + ")");
}
return e;
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package org.gingercal;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.helpers.DefaultHandler;
/** Tiny namespace-aware XML tree built with SAX (works on Android 2.3 and desktop Java). */
public class Xml {
public static class Node {
public String ns = "";
public String name = "";
public final StringBuilder sb = new StringBuilder();
public final List<Node> kids = new ArrayList<Node>();
public final java.util.Map<String, String> attrs = new java.util.HashMap<String, String>();
public String attr(String name) {
String v = attrs.get(name);
return v == null ? "" : v;
}
public boolean is(String ns, String name) {
return this.name.equals(name) && this.ns.equals(ns);
}
public Node child(String ns, String name) {
for (int i = 0; i < kids.size(); i++) {
Node k = kids.get(i);
if (k.is(ns, name)) return k;
}
return null;
}
public List<Node> children(String ns, String name) {
List<Node> out = new ArrayList<Node>();
for (int i = 0; i < kids.size(); i++) {
Node k = kids.get(i);
if (k.is(ns, name)) out.add(k);
}
return out;
}
/** Depth-first search for the first descendant with this name. */
public Node find(String ns, String name) {
for (int i = 0; i < kids.size(); i++) {
Node k = kids.get(i);
if (k.is(ns, name)) return k;
Node f = k.find(ns, name);
if (f != null) return f;
}
return null;
}
public String text() {
return sb.toString().trim();
}
}
public static Node parse(String xml) throws IOException {
final Node root = new Node();
final ArrayList<Node> stack = new ArrayList<Node>();
stack.add(root);
DefaultHandler h = new DefaultHandler() {
@Override
public void startElement(String uri, String local, String qName, Attributes atts) {
Node n = new Node();
n.ns = uri == null ? "" : uri;
if (local != null && local.length() > 0) {
n.name = local;
} else {
int c = qName.indexOf(':');
n.name = c >= 0 ? qName.substring(c + 1) : qName;
}
for (int i = 0; i < atts.getLength(); i++) {
String an = atts.getLocalName(i);
if (an == null || an.length() == 0) an = atts.getQName(i);
n.attrs.put(an, atts.getValue(i));
}
stack.get(stack.size() - 1).kids.add(n);
stack.add(n);
}
@Override
public void endElement(String uri, String local, String qName) {
if (stack.size() > 1) stack.remove(stack.size() - 1);
}
@Override
public void characters(char[] ch, int start, int length) {
stack.get(stack.size() - 1).sb.append(ch, start, length);
}
};
try {
SAXParserFactory f = SAXParserFactory.newInstance();
f.setNamespaceAware(true);
SAXParser p = f.newSAXParser();
p.parse(new InputSource(new StringReader(xml)), h);
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("Bad XML from server: " + e.getMessage());
}
if (root.kids.isEmpty()) throw new IOException("Empty XML from server");
return root.kids.get(0);
}
public static String esc(String s) {
StringBuilder b = new StringBuilder(s.length() + 8);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '<') b.append("&lt;");
else if (c == '>') b.append("&gt;");
else if (c == '&') b.append("&amp;");
else if (c == '"') b.append("&quot;");
else b.append(c);
}
return b.toString();
}
}