Java tutorial
/* * Copyright 2013 2015 Ricki Hirner (bitfire web engineering). * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0 * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ package com.granita.contacticloudsync.resource; import android.content.Context; import android.text.TextUtils; import com.squareup.okhttp.HttpUrl; import com.squareup.okhttp.OkHttpClient; import org.slf4j.Logger; import org.xbill.DNS.Lookup; import org.xbill.DNS.Record; import org.xbill.DNS.SRVRecord; import org.xbill.DNS.TXTRecord; import org.xbill.DNS.Type; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import at.bitfire.dav4android.DavResource; import at.bitfire.dav4android.UrlUtils; import at.bitfire.dav4android.exception.DavException; import at.bitfire.dav4android.exception.HttpException; import at.bitfire.dav4android.exception.NotFoundException; import at.bitfire.dav4android.property.AddressbookDescription; import at.bitfire.dav4android.property.AddressbookHomeSet; import at.bitfire.dav4android.property.CalendarColor; import at.bitfire.dav4android.property.CalendarDescription; import at.bitfire.dav4android.property.CalendarHomeSet; import at.bitfire.dav4android.property.CalendarTimezone; import at.bitfire.dav4android.property.CurrentUserPrincipal; import at.bitfire.dav4android.property.CurrentUserPrivilegeSet; import at.bitfire.dav4android.property.DisplayName; import at.bitfire.dav4android.property.ResourceType; import at.bitfire.dav4android.property.SupportedCalendarComponentSet; import com.granita.contacticloudsync.Constants; import com.granita.contacticloudsync.HttpClient; import lombok.NonNull; public class DavResourceFinder { protected enum Service { CALDAV("caldav"), CARDDAV("carddav"); final String name; Service(String name) { this.name = name; } @Override public String toString() { return name; } }; protected final Logger log; protected final Context context; protected final HttpClient httpClient; protected final ServerInfo serverInfo; protected Map<HttpUrl, ServerInfo.ResourceInfo> addressbooks = new HashMap<>(), calendars = new HashMap<>(), taskLists = new HashMap<>(); public DavResourceFinder(Logger log, Context context, ServerInfo serverInfo) { this.log = log; this.context = context; this.serverInfo = serverInfo; httpClient = new HttpClient(log, context, serverInfo.getUserName(), serverInfo.getPassword(), serverInfo.authPreemptive); } public void findResources() { try { findResources(Service.CARDDAV); findResources(Service.CALDAV); } catch (URISyntaxException e) { log.warn("Invalid user-given URI", e); } } public void findResources(Service service) throws URISyntaxException { URI baseURI = serverInfo.getBaseURI(); String domain = null; HttpUrl principalUrl = null; Set<HttpUrl> homeSets = new HashSet<>(); if (service == Service.CALDAV) { calendars.clear(); taskLists.clear(); } else if (service == Service.CARDDAV) addressbooks.clear(); log.info("*** STARTING COLLECTION DISCOVERY FOR SERVICE " + service.name.toUpperCase(Locale.US) + "***"); if ("http".equals(baseURI.getScheme()) || "https".equals(baseURI.getScheme())) { HttpUrl userURL = HttpUrl.get(baseURI); /* check whether: 1. user-given URL is a calendar 2. user-given URL has a calendar-home-set property (i.e. is a principal URL) */ log.info( "Check whether user-given URL is a calendar collection and/or contains <calendar-home-set> and/or has <current-user-principal>"); DavResource davBase = new DavResource(log, httpClient, userURL); try { if (service == Service.CALDAV) { davBase.propfind(0, CalendarHomeSet.NAME, SupportedCalendarComponentSet.NAME, ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, CurrentUserPrincipal.NAME); addIfCalendar(davBase); } else if (service == Service.CARDDAV) { davBase.propfind(0, AddressbookHomeSet.NAME, ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, CurrentUserPrivilegeSet.NAME, CurrentUserPrincipal.NAME); addIfAddressBook(davBase); } } catch (IOException | HttpException | DavException e) { log.debug("PROPFIND on user-given URL failed", e); } if (service == Service.CALDAV) { CalendarHomeSet calendarHomeSet = (CalendarHomeSet) davBase.properties.get(CalendarHomeSet.NAME); if (calendarHomeSet != null) { log.info("Found <calendar-home-set> at user-given URL"); for (String href : calendarHomeSet.hrefs) { HttpUrl url = userURL.resolve(href); if (url != null) homeSets.add(url); } } } else if (service == Service.CARDDAV) { AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet) davBase.properties .get(AddressbookHomeSet.NAME); if (addressbookHomeSet != null) { log.info("Found <addressbook-home-set> at user-given URL"); for (String href : addressbookHomeSet.hrefs) { HttpUrl url = userURL.resolve(href); if (url != null) homeSets.add(url); } } } /* When home sets haven already been found, skip further searching. * Otherwise (no home sets found), treat the user-given URL as "initial context path" for service discovery. * * Keep in mind that the CalDAV principal URL must not be the CardDAV principal URL! */ if (homeSets.isEmpty()) try { log.info("No home sets found, looking for <current-user-principal>"); davBase.options(); if ((service == Service.CALDAV && davBase.capabilities.contains("calendar-access")) || (service == Service.CARDDAV && davBase.capabilities.contains("addressbook"))) { CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal) davBase.properties .get(CurrentUserPrincipal.NAME); if (currentUserPrincipal != null && currentUserPrincipal.href != null) principalUrl = davBase.location.resolve(currentUserPrincipal.href); } } catch (IOException | HttpException | DavException e) { log.debug("Couldn't find <current-user-principal> at user-given URL", e); } if (principalUrl == null) try { log.info("User-given URL doesn't contain <current-user-principal>, trying /.well-known/" + service.name); principalUrl = getCurrentUserPrincipal(userURL.resolve("/.well-known/" + service.name)); } catch (IOException | HttpException | DavException e) { log.debug("Couldn't determine <current-user-principal> from well-known " + service + " path", e); } if (principalUrl == null) // still no principal URL, try service discovery with "domain" = user-given host name domain = baseURI.getHost(); } else if ("mailto".equals(baseURI.getScheme())) { String mailbox = baseURI.getSchemeSpecificPart(); // determine service FQDN int posAt = mailbox.lastIndexOf("@"); if (posAt == -1) throw new URISyntaxException(mailbox, "Missing @ sign"); domain = mailbox.substring(posAt + 1); } if (principalUrl == null && domain != null) { log.info("No principal URL yet, trying SRV/TXT records with domain " + domain); try { principalUrl = discoverPrincipalUrl(domain, service); } catch (IOException | HttpException | DavException e) { log.info("Couldn't find principal URL using service discovery"); } } // principal URL has been found, get addressbook-home-set/calendar-home-set if (principalUrl != null) { log.info("Principal URL=" + principalUrl + ", getting <calendar-home-set>"); try { DavResource principal = new DavResource(log, httpClient, principalUrl); if (service == Service.CALDAV) { principal.propfind(0, CalendarHomeSet.NAME); CalendarHomeSet calendarHomeSet = (CalendarHomeSet) principal.properties .get(CalendarHomeSet.NAME); if (calendarHomeSet != null) { log.info("Found <calendar-home-set> at principal URL"); for (String href : calendarHomeSet.hrefs) { HttpUrl url = principal.location.resolve(href); if (url != null) homeSets.add(url); } } } else if (service == Service.CARDDAV) { principal.propfind(0, AddressbookHomeSet.NAME); AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet) principal.properties .get(AddressbookHomeSet.NAME); if (addressbookHomeSet != null) { log.info("Found <addressbook-home-set> at principal URL"); for (String href : addressbookHomeSet.hrefs) { HttpUrl url = principal.location.resolve(href); if (url != null) homeSets.add(url); } } } } catch (IOException | HttpException | DavException e) { log.debug("PROPFIND on " + principalUrl + " failed", e); } } // now query all home sets for (HttpUrl url : homeSets) if (service == Service.CALDAV) try { log.info("Listing calendar collections in home set " + url); DavResource homeSet = new DavResource(log, httpClient, url); homeSet.propfind(1, SupportedCalendarComponentSet.NAME, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME); // home set should not be a calendar, but some servers have only one calendar and it's the home set addIfCalendar(homeSet); // members of the home set can be calendars, too for (DavResource member : homeSet.members) addIfCalendar(member); } catch (IOException | HttpException | DavException e) { log.debug("PROPFIND on " + url + " failed", e); } else if (service == Service.CARDDAV) try { log.info("Listing address books in home set " + url); DavResource homeSet = new DavResource(log, httpClient, url); homeSet.propfind(1, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME, AddressbookDescription.NAME); // home set should not be an address book, but some servers have only one address book and it's the home set addIfAddressBook(homeSet); // members of the home set can be calendars, too for (DavResource member : homeSet.members) addIfAddressBook(member); } catch (IOException | HttpException | DavException e) { log.debug("PROPFIND on " + url + " failed", e); } if (service == Service.CALDAV) { serverInfo.setCalendars(calendars.values().toArray(new ServerInfo.ResourceInfo[calendars.size()])); serverInfo.setTaskLists(taskLists.values().toArray(new ServerInfo.ResourceInfo[taskLists.size()])); } else if (service == Service.CARDDAV) serverInfo.setAddressBooks( addressbooks.values().toArray(new ServerInfo.ResourceInfo[addressbooks.size()])); } /** * If the given DavResource is a #{@link ResourceType#ADDRESSBOOK}, add it to #{@link #addressbooks}. * @param dav DavResource to check */ protected void addIfAddressBook(@NonNull DavResource dav) { ResourceType resourceType = (ResourceType) dav.properties.get(ResourceType.NAME); if (resourceType != null && resourceType.types.contains(ResourceType.ADDRESSBOOK)) { dav.location = UrlUtils.withTrailingSlash(dav.location); log.info("Found address book at " + dav.location); addressbooks.put(dav.location, resourceInfo(dav, ServerInfo.ResourceInfo.Type.ADDRESS_BOOK)); } } /** * If the given DavResource is a #{@link ResourceType#CALENDAR}: * <ul> * <li>add it to #{@link #calendars} if it supports VEVENT</li> * <li>add it to #{@link #taskLists} if it supports VTODO</li> * </ul> * @param dav DavResource to check */ protected void addIfCalendar(@NonNull DavResource dav) { ResourceType resourceType = (ResourceType) dav.properties.get(ResourceType.NAME); if (resourceType != null && resourceType.types.contains(ResourceType.CALENDAR)) { dav.location = UrlUtils.withTrailingSlash(dav.location); log.info("Found calendar collection at " + dav.location); boolean supportsEvents = true, supportsTasks = true; SupportedCalendarComponentSet supportedCalendarComponentSet = (SupportedCalendarComponentSet) dav.properties .get(SupportedCalendarComponentSet.NAME); if (supportedCalendarComponentSet != null) { supportsEvents = supportedCalendarComponentSet.supportsEvents; supportsTasks = supportedCalendarComponentSet.supportsTasks; } if (supportsEvents) calendars.put(dav.location, resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR)); if (supportsTasks) taskLists.put(dav.location, resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR)); } } /** * Builds a #{@link com.granita.contacticloudsync.resource.ServerInfo.ResourceInfo} from a given * #{@link DavResource}. Uses these DAV properties: * <ul> * <li>calendars: current-user-properties, current-user-privilege-set, displayname, calendar-description, calendar-color</li> * <li>address books: current-user-properties, current-user-privilege-set, displayname, addressbook-description</li> * </ul>. Make sure you have queried these properties from the DavResource. * @param dav DavResource to take the resource info from * @param type must be ADDRESS_BOOK or CALENDAR * @return ResourceInfo which represents the DavResource */ protected ServerInfo.ResourceInfo resourceInfo(DavResource dav, ServerInfo.ResourceInfo.Type type) { boolean readOnly = false; CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet) dav.properties .get(CurrentUserPrivilegeSet.NAME); if (privilegeSet != null) readOnly = !privilegeSet.mayWriteContent; String title = null; DisplayName displayName = (DisplayName) dav.properties.get(DisplayName.NAME); if (displayName != null) title = displayName.displayName; if (TextUtils.isEmpty(title)) title = UrlUtils.lastSegment(dav.location); String description = null; Integer color = null; if (type == ServerInfo.ResourceInfo.Type.ADDRESS_BOOK) { AddressbookDescription addressbookDescription = (AddressbookDescription) dav.properties .get(AddressbookDescription.NAME); if (addressbookDescription != null) description = addressbookDescription.description; } else if (type == ServerInfo.ResourceInfo.Type.CALENDAR) { CalendarDescription calendarDescription = (CalendarDescription) dav.properties .get(CalendarDescription.NAME); if (calendarDescription != null) description = calendarDescription.description; CalendarColor calendarColor = (CalendarColor) dav.properties.get(CalendarColor.NAME); if (calendarColor != null) color = calendarColor.color; } return new ServerInfo.ResourceInfo(type, readOnly, UrlUtils.withTrailingSlash(dav.location).toString(), title, description, color); } /** * Try to find the principal URL by performing service discovery on a given domain name. * @param domain domain name, e.g. "icloud.com" * @param service service to discover (CALDAV or CARDDAV) * @return principal URL, or null if none found */ protected HttpUrl discoverPrincipalUrl(String domain, Service service) throws IOException, HttpException, DavException { String scheme = null; String fqdn = null; Integer port = null; List<String> paths = new LinkedList<>(); // there may be multiple paths to try final String query = "_" + service.name + "s._tcp." + domain; log.debug("Looking up SRV records for " + query); Record[] records = new Lookup(query, Type.SRV).run(); if (records != null && records.length >= 1) { // choose SRV record to use (query may return multiple SRV records) SRVRecord srv = selectSRVRecord(records); scheme = "https"; fqdn = srv.getTarget().toString(true); port = srv.getPort(); log.info("Found " + service + " service: fqdn=" + fqdn + ", port=" + port); // look for TXT record too (for initial context path) records = new Lookup(query, Type.TXT).run(); if (records != null) for (Record record : records) if (record instanceof TXTRecord) for (String segment : (List<String>) ((TXTRecord) record).getStrings()) if (segment.startsWith("path=")) { paths.add(segment.substring(5)); log.info("Found TXT record; initial context path=" + paths); break; } // if there's TXT record and if it it's wrong, try well-known paths.add("/.well-known/" + service.name); // if this fails, too, try "/" paths.add("/"); } for (String path : paths) try { if (!TextUtils.isEmpty(scheme) && !TextUtils.isEmpty(fqdn) && port != null && paths != null) { HttpUrl initialContextPath = new HttpUrl.Builder().scheme(scheme).host(fqdn).port(port) .encodedPath(path).build(); log.info("Trying to determine principal from initial context path=" + initialContextPath); HttpUrl principal = getCurrentUserPrincipal(initialContextPath); if (principal != null) return principal; } } catch (NotFoundException e) { log.warn("No resource found", e); } return null; } /** * Queries a given URL for current-user-principal * @param url URL to query with PROPFIND (Depth: 0) * @return current-user-principal URL, or null if none */ protected HttpUrl getCurrentUserPrincipal(HttpUrl url) throws IOException, HttpException, DavException { DavResource dav = new DavResource(log, httpClient, url); dav.propfind(0, CurrentUserPrincipal.NAME); CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal) dav.properties .get(CurrentUserPrincipal.NAME); if (currentUserPrincipal != null && currentUserPrincipal.href != null) { HttpUrl principal = url.resolve(currentUserPrincipal.href); if (principal != null) { log.info("Found current-user-principal: " + principal); return principal; } } return null; } // helpers private SRVRecord selectSRVRecord(Record[] records) { if (records.length > 1) log.warn("Multiple SRV records not supported yet; using first one"); return (SRVRecord) records[0]; } }