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 props = new HashMap(); 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 visited = new ArrayList(); for (int hop = 0; hop < 6; hop++) { visited.add(u); List h = new ArrayList(); 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, L.t("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, L.t("Not allowed to write to this calendar (403). It may be read-only/shared.")); } return r; } throw new DavException(DavException.REDIRECT, L.t("Too many redirects from the server.")); } static void checkRedirect(String from, String to, List visited, int code) throws DavException { String a = originKey(from), b = originKey(to); if (a.startsWith("http:") && b.startsWith("https:")) { throw new DavException(DavException.REDIRECT, L.f("The server redirected http:// to %s (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').", origin(to))); } if (!a.equals(b)) { throw new DavException(DavException.REDIRECT, L.f("The server redirected to %s, not the address set in the app. Nextcloud is sending its public URL; check the bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').", origin(to))); } if (visited.contains(to)) { throw new DavException(DavException.REDIRECT, L.f("Redirect loop (HTTP %d 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').", code)); } } List 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, L.f("%s %s failed: HTTP %d%s", method, URI.create(target).getPath(), r.code, r.code == 404 ? L.t(" (not found)") : r.code == 405 ? L.t(" (not a WebDAV URL?)") : "")); } Node ms = Xml.parse(r.body); List out = new ArrayList(); 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 "" + "" + "" + props + ""; } 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 candidates = new ArrayList(); 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 rs = multistatus("PROPFIND", c, "0", propfind("")); 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, L.t("Server did not report a CalDAV principal")); } List rs = multistatus("PROPFIND", principal, "0", propfind("")); 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 calendars(String home) throws IOException { List rs = multistatus("PROPFIND", home, "1", propfind( "" + "")); List out = new ArrayList(); 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 events(Cal cal, long from, long to) throws IOException { String f = ICal.utc(from), t = ICal.utc(to); String body = "" + "" + "" + "" + "" + "" + ""; List rs = multistatus("REPORT", cal.href, "1", body); List out = new ArrayList(); 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 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 overrides = new HashMap(); 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 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, L.f("Could not load event: HTTP %d", 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, L.f("Saving failed: HTTP %d", 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, L.t("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, L.f("Saving failed: HTTP %d", 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, L.t("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, L.f("Delete failed: HTTP %d", 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; } } } }