Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 46a2880e authored by Ricki Hirner's avatar Ricki Hirner
Browse files

New collection/service discovery: CalDAV+CardDAV

parent 275335ad
Loading
Loading
Loading
Loading
+198 −166
Original line number Diff line number Diff line
@@ -9,7 +9,6 @@ package at.bitfire.davdroid.resource;

import android.content.Context;
import android.text.TextUtils;
import android.util.Log;

import com.squareup.okhttp.HttpUrl;

@@ -17,7 +16,6 @@ import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.SRVRecord;
import org.xbill.DNS.TXTRecord;
import org.xbill.DNS.TextParseException;
import org.xbill.DNS.Type;

import java.io.IOException;
@@ -33,6 +31,8 @@ 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;
@@ -49,11 +49,21 @@ import lombok.NonNull;
public class DavResourceFinder {
	private final static String TAG = "davdroid.ResourceFinder";

    protected enum Service {
        CALDAV("caldav"),
        CARDDAV("carddav");

        final String name;
        Service(String name) { this.name = name;}
        @Override public String toString() { return name; }
    };

	protected Context context;
    protected final HttpClient httpClient;
    protected final ServerInfo serverInfo;

    protected List<ServerInfo.ResourceInfo>
            addressbooks = new LinkedList<>(),
            calendars = new LinkedList<>(),
            taskLists = new LinkedList<>();

@@ -66,12 +76,25 @@ public class DavResourceFinder {
	}

	public void findResources() throws URISyntaxException, IOException, HttpException, DavException {
        findResources(Service.CARDDAV);
        findResources(Service.CALDAV);
    }

    public void findResources(Service service) throws URISyntaxException, IOException, HttpException, DavException { {
        URI baseURI = serverInfo.getBaseURI();
        String domain = null;

        HttpUrl principalUrl = null;
        Set<HttpUrl> calendarHomeSets = new HashSet<>();
        Set<HttpUrl>    calendarHomeSets = new HashSet<>(),
                        addressbookHomeSets = new HashSet<>();

        if (service == Service.CALDAV) {
            calendars.clear();
            taskLists.clear();
        } else if (service == Service.CARDDAV)
            addressbooks.clear();

        Constants.log.info("STARTING COLLECTION DISCOVERY FOR SERVICE " + service);
        if ("http".equals(baseURI.getScheme()) || "https".equals(baseURI.getScheme())) {
            HttpUrl userURL = HttpUrl.get(baseURI);

@@ -82,16 +105,26 @@ public class DavResourceFinder {
            Constants.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(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) {
                Constants.log.debug("PROPFIND on user-given URL failed", e);
            }

            if (service == Service.CALDAV) {
                CalendarHomeSet calendarHomeSet = (CalendarHomeSet)davBase.properties.get(CalendarHomeSet.NAME);
                if (calendarHomeSet != null) {
                    Constants.log.info("Found <calendar-home-set> at user-given URL");
@@ -101,6 +134,17 @@ public class DavResourceFinder {
                            calendarHomeSets.add(url);
                    }
                }
            } else if (service == Service.CARDDAV) {
                AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)davBase.properties.get(AddressbookHomeSet.NAME);
                if (addressbookHomeSet != null) {
                    Constants.log.info("Found <addressbook-home-set> at user-given URL");
                    for (String href : addressbookHomeSet.hrefs) {
                        HttpUrl url = userURL.resolve(href);
                        if (url != null)
                            addressbookHomeSets.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. */
@@ -112,12 +156,8 @@ public class DavResourceFinder {
                    principalUrl = davBase.location.resolve(currentUserPrincipal.href);

                if (principalUrl == null) {
                    Constants.log.info("User-given URL doesn't contain <current-user-principal>, trying /.well-known/caldav");
                    try {
                        principalUrl = getCurrentUserPrincipal(userURL.resolve("/.well-known/caldav"));
                    } catch (IOException|HttpException|DavException e) {
                        Constants.log.debug("PROPFIND on /.well-known/caldav failed", e);
                    }
                    Constants.log.info("User-given URL doesn't contain <current-user-principal>, trying /.well-known/" + service.name);
                    principalUrl = getCurrentUserPrincipal(userURL.resolve("/.well-known/" + service.name));
                }
            }

@@ -136,32 +176,49 @@ public class DavResourceFinder {

        if (principalUrl == null && domain != null) {
            Constants.log.info("No principal URL yet, trying SRV/TXT records with domain " + domain);
            principalUrl = discoverPrincipalUrl(domain, "caldavs");
            principalUrl = discoverPrincipalUrl(domain, service);
        }

        // principal URL has been found, get calendar-home-set
        // principal URL has been found, get addressbook-home-set/calendar-home-set
        if (principalUrl != null) {
            Constants.log.info("Principal URL=" + principalUrl + ", getting <calendar-home-set>");
            try {
                DavResource principal = new DavResource(httpClient, principalUrl);

                if (service == Service.CALDAV) {
                    principal.propfind(0, CalendarHomeSet.NAME);
                    CalendarHomeSet calendarHomeSet = (CalendarHomeSet)principal.properties.get(CalendarHomeSet.NAME);
                if (calendarHomeSet != null)
                    if (calendarHomeSet != null) {
                        Constants.log.info("Found <calendar-home-set> at principal URL");
                        for (String href : calendarHomeSet.hrefs) {
                            HttpUrl url = principal.location.resolve(href);
                            if (url != null)
                                calendarHomeSets.add(url);
                        }
                    }
                } else if (service == Service.CARDDAV) {
                    principal.propfind(0, AddressbookHomeSet.NAME);
                    AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)principal.properties.get(AddressbookHomeSet.NAME);
                    if (addressbookHomeSet != null) {
                        Constants.log.info("Found <addressbook-home-set> at principal URL");
                        for (String href : addressbookHomeSet.hrefs) {
                            HttpUrl url = principal.location.resolve(href);
                            if (url != null)
                                addressbookHomeSets.add(url);
                        }
                    }
                }

            } catch (IOException|HttpException|DavException e) {
                Constants.log.debug("PROPFIND on " + principalUrl + " failed", e);
            }
        }

        // now query all home sets
        if (service == Service.CALDAV)
            for (HttpUrl url : calendarHomeSets)
                try {
                Constants.log.info("Listing collections in home set " + url);
                    Constants.log.info("Listing calendar collections in home set " + url);
                    DavResource homeSet = new DavResource(httpClient, url);
                    homeSet.propfind(1, SupportedCalendarComponentSet.NAME, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME,
                            CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME);
@@ -175,14 +232,43 @@ public class DavResourceFinder {
                } catch (IOException|HttpException|DavException e) {
                    Constants.log.debug("PROPFIND on " + url + " failed", e);
                }
        else if (service == Service.CARDDAV)
            for (HttpUrl url : addressbookHomeSets)
                try {
                    Constants.log.info("Listing address books in home set " + url);
                    DavResource homeSet = new DavResource(httpClient, url);
                    homeSet.propfind(1, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME, AddressbookDescription.NAME);

        // TODO CardDAV
                    // 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) {
                    Constants.log.debug("PROPFIND on " + url + " failed", e);
                }

        // TODO remove duplicates
        // TODO notify user on errors?

        if (service == Service.CALDAV) {
            serverInfo.setCalendars(calendars);
            serverInfo.setTaskLists(taskLists);
        } else if (service == Service.CARDDAV)
            serverInfo.setAddressBooks(addressbooks);
    }}

    /**
         * 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)) {
            Constants.log.info("Found address book at " + dav.location);
            addressbooks.add(resourceInfo(dav, ServerInfo.ResourceInfo.Type.ADDRESS_BOOK));
        }
    }

    /**
@@ -204,21 +290,24 @@ public class DavResourceFinder {
                supportsTasks = supportedCalendarComponentSet.supportsTasks;
            }
            if (supportsEvents)
                calendars.add(resourceInfo(dav));
                calendars.add(resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR));
            if (supportsTasks)
                taskLists.add(resourceInfo(dav));
                taskLists.add(resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR));
        }
    }

    /**
     * Builds a #{@link at.bitfire.davdroid.resource.ServerInfo.ResourceInfo} from a given
     * #{@link DavResource}. Uses the DAV properties current-user-properties, current-user-privilege-set,
     * displayname, calendar-description and calendar-color. Make sure you have queried these
     * properties from the DavResource.
     * #{@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) {
    protected ServerInfo.ResourceInfo resourceInfo(DavResource dav, ServerInfo.ResourceInfo.Type type) {
        boolean readOnly = false;
        CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME);
        if (privilegeSet != null)
@@ -232,17 +321,23 @@ public class DavResourceFinder {
            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;

        Integer color = null;
            CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME);
            if (calendarColor != null)
            color = calendarColor.color;
        }

        return new ServerInfo.ResourceInfo(
                ServerInfo.ResourceInfo.Type.CALENDAR,
                type,
                readOnly,
                dav.location.toString(),
                title,
@@ -254,17 +349,17 @@ public class DavResourceFinder {
    /**
     * Try to find the principal URL by performing service discovery on a given domain name.
     * @param domain         domain name, e.g. "icloud.com"
     * @param serviceName    service name: "caldavs" or "carddavs"
     * @param service        service to discover (CALDAV or CARDDAV)
     * @return principal URL, or null if none found
     */
    protected HttpUrl discoverPrincipalUrl(String domain, String serviceName) {
    protected HttpUrl discoverPrincipalUrl(String domain, Service service) {
        String scheme = null;
        String fqdn = null;
        Integer port = null;
        String path = null;
        List<String> paths = new LinkedList<>();     // there may be multiple paths to try

        try {
            final String query = "_" + serviceName + "._tcp." + domain;
            final String query = "_" + service.name + "s._tcp." + domain;
            Constants.log.debug("Looking up SRV records for " + query);
            Record[] records = new Lookup(query, Type.SRV).run();
            if (records != null && records.length >= 1) {
@@ -274,7 +369,7 @@ public class DavResourceFinder {
                scheme = "https";
                fqdn = srv.getTarget().toString(true);
                port = srv.getPort();
                Constants.log.info("Found " + serviceName + " service: fqdn=" + fqdn + ", port=" + port);
                Constants.log.info("Found " + service + " service: fqdn=" + fqdn + ", port=" + port);

                // look for TXT record too (for initial context path)
                records = new Lookup(domain, Type.TXT).run();
@@ -282,33 +377,35 @@ public class DavResourceFinder {
                    TXTRecord txt = (TXTRecord)records[0];
                    for (String segment : (String[])txt.getStrings().toArray(new String[0]))
                        if (segment.startsWith("path=")) {
                            path = segment.substring(5);
                            Constants.log.info("Found TXT record; initial context path=" + path);
                            paths.add(segment.substring(5));
                            Constants.log.info("Found TXT record; initial context path=" + paths);
                            break;
                        }
                }

                if (path == null)      // no path from TXT records, use .well-known
                    path = "/.well-known/caldav";
                // if there's TXT record if it it's wrong, try well-known
                paths.add("/.well-known/" + service.name);
                // if this fails, too, try "/"
                paths.add("/");
            }
        } catch (IOException e) {
            Constants.log.debug("SRV/TXT record discovery failed", e);
            return null;
        }

            if (!TextUtils.isEmpty(scheme) && !TextUtils.isEmpty(fqdn) && port != null && path != null) {
        for (String path : paths) {
            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();

                HttpUrl principal = null;
                try {
                    principal = getCurrentUserPrincipal(initialContextPath);
                } catch(NotFoundException e) {
                    principal = getCurrentUserPrincipal(initialContextPath.resolve("/"));
                }
                Constants.log.info("Trying to determine principal from initial context path=" + initialContextPath);
                HttpUrl principal = getCurrentUserPrincipal(initialContextPath);
                if (principal != null)
                    return principal;
            }
        } catch (IOException|HttpException|DavException e) {
            Constants.log.debug("Service discovery failed", e);
        }
        return null;
    }
@@ -318,87 +415,22 @@ public class DavResourceFinder {
     * @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 {
    protected HttpUrl getCurrentUserPrincipal(HttpUrl url) {
        try {
            DavResource dav = new DavResource(httpClient, url);
            dav.propfind(0, CurrentUserPrincipal.NAME);
            CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal) dav.properties.get(CurrentUserPrincipal.NAME);
        if (currentUserPrincipal != null && currentUserPrincipal.href != null)
            return url.resolve(currentUserPrincipal.href);
        return null;
    }

	/**
	 * Finds the initial service URL from a given base URI (HTTP[S] or mailto URI, user name, password)
	 * @param serverInfo	User-given service information (including base URI, i.e. HTTP[S] URL+user name+password or mailto URI and password)
	 * @param serviceName	Service name ("carddav" or "caldav")
	 * @return				Initial service URL (HTTP/HTTPS), without user credentials
	 * @throws URISyntaxException when the user-given URI is invalid
	 */
	public HttpUrl getInitialContextURL(ServerInfo serverInfo, String serviceName) throws URISyntaxException {
		String	scheme,
				domain;
		int		port = -1;
		String	path = "/";
		
		URI baseURI = serverInfo.getBaseURI();
		if ("mailto".equalsIgnoreCase(baseURI.getScheme())) {
			// mailto URIs
			String mailbox = serverInfo.getBaseURI().getSchemeSpecificPart();

			// determine service FQDN
			int pos = mailbox.lastIndexOf("@");
			if (pos == -1)
				throw new URISyntaxException(mailbox, "Missing @ sign");
			
			scheme = "https";
			domain = mailbox.substring(pos + 1);
			if (domain.isEmpty())
				throw new URISyntaxException(mailbox, "Missing domain name");
		} else {
			// HTTP(S) URLs
			scheme = baseURI.getScheme();
			domain = baseURI.getHost();
			port = baseURI.getPort();
			path = baseURI.getPath();
		}

		// try to determine FQDN and port number using SRV records
		try {
			String name = "_" + serviceName + "._tcp." + domain;
			Constants.log.debug("Looking up SRV records for " + name);
			Record[] records = new Lookup(name, Type.SRV).run();
			if (records != null && records.length >= 1) {
				SRVRecord srv = selectSRVRecord(records);
				
				scheme = "https";
				domain = srv.getTarget().toString(true);
				port = srv.getPort();
				Log.d(TAG, "Found " + serviceName + " service for " + domain + " -> " + domain + ":" + port);

				// SRV record found, look for TXT record too (for initial context path)
				records = new Lookup(name, Type.TXT).run();
				if (records != null && records.length >= 1) {
					TXTRecord txt = (TXTRecord)records[0];
					for (Object o : txt.getStrings().toArray()) {
						String segment = (String)o;
						if (segment.startsWith("path=")) {
							path = segment.substring(5);
                            Constants.log.debug("Found initial context path for " + serviceName + " at " + domain + " -> " + path);
							break;
						}
					}
            if (currentUserPrincipal != null && currentUserPrincipal.href != null) {
                HttpUrl principal = url.resolve(currentUserPrincipal.href);
                if (principal != null) {
                    Constants.log.info("Found current-user-principal: " + principal);
                    return principal;
                }
            }
		} catch (TextParseException e) {
			throw new URISyntaxException(domain, "Invalid domain name");
        } catch(IOException|HttpException|DavException e) {
            Constants.log.debug("PROPFIND for current-user-principal on " + url + " failed", e);
        }
		
		HttpUrl.Builder builder = new HttpUrl.Builder().scheme(scheme).host(domain);
        if (port != -1)
            builder.port(port);
        if (TextUtils.isEmpty(path))
            path = "/";
        return builder.encodedPath(path).build();
        return null;
    }