Commit a32ec6d9 authored by Ricki Hirner's avatar Ricki Hirner

Use callback/streaming approach instead of bad-scaling lists

parent fc6cf159
Pipeline #25515556 passed with stages
in 3 minutes and 36 seconds
......@@ -19,6 +19,9 @@ dav4android is licensed under [Mozilla Public License, v. 2.0](LICENSE).
For questions, suggestions etc. please use the DAVdroid forum:
https://www.davdroid.com/forums/
If you want to contribute, please work in your own repository and then
notify us on your changes so that we can backport them.
Email: [play@bitfire.at](mailto:play@bitfire.at)
......
buildscript {
ext.kotlin_version = '1.2.50'
ext.kotlin_version = '1.2.51'
ext.dokka_version = '0.9.16'
repositories {
......
......@@ -7,6 +7,7 @@
package at.bitfire.dav4android
import okhttp3.*
import okhttp3.Response
import okio.Buffer
import okio.ByteString
import java.io.IOException
......@@ -23,7 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger
* Authentication methods/credentials found to be working will be cached for further requests
* (this is why the interceptor is needed).
*
* Usage: Set as authenticator **and** as network interceptor.
* Usage: Set as authenticator *and* as network interceptor.
*/
class BasicDigestAuthHandler(
/** Authenticate only against hosts ending with this domain (may be null, which means no restriction) */
......@@ -54,8 +55,8 @@ class BasicDigestAuthHandler(
}
// cached authentication schemes
var basicAuth: HttpUtils.AuthScheme? = null
var digestAuth: HttpUtils.AuthScheme? = null
private var basicAuth: HttpUtils.AuthScheme? = null
private var digestAuth: HttpUtils.AuthScheme? = null
fun authenticateRequest(request: Request, response: Response?): Request? {
......
......@@ -24,14 +24,19 @@ class DavAddressBook @JvmOverloads constructor(
val MIME_VCARD4 = MediaType.parse("text/vcard;version=4.0")
}
/**
* Sends an addressbook-query REPORT request to the resource.
*
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on DAV error
* @throws DavException on WebDAV error
*/
fun addressbookQuery(): DavResponse {
fun addressbookQuery(callback: DavResponseCallback): List<Property> {
/* <!ELEMENT addressbook-query ((DAV:allprop |
DAV:propname |
DAV:prop)?, filter, limit?)>
......@@ -53,25 +58,32 @@ class DavAddressBook @JvmOverloads constructor(
serializer.endTag(XmlUtils.NS_CARDDAV, "addressbook-query")
serializer.endDocument()
val response = httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "1")
.build()).execute()
checkStatus(response)
assertMultiStatus(response)
return processMultiStatus(response.body()?.charStream()!!)
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "1")
.build()).execute()
}.use { response ->
return processMultiStatus(response, callback)
}
}
/**
* Sends an addressbook-multiget REPORT request to the resource.
*
* @param urls list of vCard URLs to be requested
* @param vCard4 whether vCards should be requested as vCard4 4.0 (true: 4.0, false: 3.0)
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on DAV error
* @throws DavException on WebDAV error
*/
fun multiget(urls: List<HttpUrl>, vCard4: Boolean): DavResponse {
fun multiget(urls: List<HttpUrl>, vCard4: Boolean, callback: DavResponseCallback): List<Property> {
/* <!ELEMENT addressbook-multiget ((DAV:allprop |
DAV:propname |
DAV:prop)?,
......@@ -104,16 +116,15 @@ class DavAddressBook @JvmOverloads constructor(
serializer.endTag(XmlUtils.NS_CARDDAV, "addressbook-multiget")
serializer.endDocument()
val response = httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "0") // "The request MUST include a Depth: 0 header [...]"
.build()).execute()
checkStatus(response)
assertMultiStatus(response)
return processMultiStatus(response.body()?.charStream()!!)
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "0") // "The request MUST include a Depth: 0 header [...]"
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
......@@ -34,11 +34,20 @@ class DavCalendar @JvmOverloads constructor(
/**
* Sends a calendar-query REPORT to the resource.
*
* @param component requested component name (like VEVENT or VTODO)
* @param start time-range filter: start date (optional)
* @param end time-range filter: end date (optional)
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on DAV error
* @throws DavException on WebDAV error
*/
fun calendarQuery(component: String, start: Date?, end: Date?): DavResponse {
fun calendarQuery(component: String, start: Date?, end: Date?, callback: DavResponseCallback): List<Property> {
/* <!ELEMENT calendar-query ((DAV:allprop |
DAV:propname |
DAV:prop)?, filter, timezone?)>
......@@ -80,25 +89,31 @@ class DavCalendar @JvmOverloads constructor(
serializer.endTag(XmlUtils.NS_CALDAV, "calendar-query")
serializer.endDocument()
val response = httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "1")
.build()).execute()
checkStatus(response)
assertMultiStatus(response)
return processMultiStatus(response.body()?.charStream()!!)
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "1")
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
/**
* Sends a calendar-multiget REPORT to the resource.
*
* @param urls list of iCalendar URLs to be requested
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on DAV error
* @throws DavException on WebDAV error
*/
fun multiget(urls: List<HttpUrl>): DavResponse {
fun multiget(urls: List<HttpUrl>, callback: DavResponseCallback): List<Property> {
/* <!ELEMENT calendar-multiget ((DAV:allprop |
DAV:propname |
DAV:prop)?, DAV:href+)>
......@@ -126,15 +141,14 @@ class DavCalendar @JvmOverloads constructor(
serializer.endTag(XmlUtils.NS_CALDAV, "calendar-multiget")
serializer.endDocument()
val response = httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.build()).execute()
checkStatus(response)
assertMultiStatus(response)
return processMultiStatus(response.body()?.charStream()!!)
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
......@@ -16,6 +16,9 @@ import okhttp3.RequestBody
import java.io.StringWriter
import java.util.logging.Logger
/**
* Represents a WebDAV collection.
*/
open class DavCollection @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
......@@ -23,19 +26,22 @@ open class DavCollection @JvmOverloads constructor(
): DavResource(httpClient, location, log) {
/**
* Sends a REPORT sync-collection request. If a sync-token is returned, it will be made
* available in [properties].
* Sends a REPORT sync-collection request.
*
* @param syncToken sync-token to be sent with the request
* @param infiniteDepth sync-level to be sent with the request: false = "1", true = "infinite"
* @param limit maximum number of results (may cause truncation)
* @param properties WebDAV properties to be requested
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws java.io.IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on DAV error
* @throws DavException on WebDAV error
*/
fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name): DavResponse {
fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: DavResponseCallback): List<Property> {
/* <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
<!ELEMENT sync-token CDATA> <!-- Text MUST be a URI -->
......@@ -74,16 +80,15 @@ open class DavCollection @JvmOverloads constructor(
serializer.endTag(XmlUtils.NS_WEBDAV, "sync-collection")
serializer.endDocument()
val response = httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "0")
.build()).execute()
checkStatus(response)
assertMultiStatus(response)
return processMultiStatus(response.body()?.charStream()!!)
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", "0")
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
\ No newline at end of file
......@@ -7,12 +7,9 @@
package at.bitfire.dav4android
import at.bitfire.dav4android.exception.*
import at.bitfire.dav4android.property.GetContentType
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.ResourceType
import at.bitfire.dav4android.property.SyncToken
import okhttp3.*
import okhttp3.internal.http.StatusLine
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.EOFException
......@@ -20,17 +17,19 @@ import java.io.IOException
import java.io.Reader
import java.io.StringWriter
import java.net.HttpURLConnection
import java.net.ProtocolException
import java.util.logging.Level
import java.util.logging.Logger
/**
* Represents a WebDAV resource at the given location and allows WebDAV
* requests to be performed on this resource.
*
* @param httpClient [OkHttpClient] to access this object
* Requests are executed synchronously (blocking). If no error occurs, the given
* callback will be called. Otherwise, an exception is thrown. *These callbacks
* don't need to close the response.*
*
* @param httpClient [OkHttpClient] to access this object (must not follow redirects)
* @param location location of the WebDAV resource
* @param log [Logger] which will be used for logging
* @param log will be used for logging
*/
open class DavResource @JvmOverloads constructor(
val httpClient: OkHttpClient,
......@@ -43,14 +42,16 @@ open class DavResource @JvmOverloads constructor(
val MIME_XML = MediaType.parse("application/xml; charset=utf-8")
}
/**
* URL of this resource (changes when being redirected by server)
*/
var location: HttpUrl
private set // allow internal modification only (for redirects)
init {
// Don't follow redirects (only useful for GET/POST).
// This means we have to handle 30x responses manually.
if (httpClient.followRedirects())
throw IllegalArgumentException("httpClient must not follow redirects automatically")
// This means we have to handle 30x responses ourselves.
require(!httpClient.followRedirects()) { "httpClient must not follow redirects automatically" }
this.location = location
}
......@@ -65,58 +66,44 @@ open class DavResource @JvmOverloads constructor(
/**
* Sends an OPTIONS request to this resource, requesting [DavResponse.capabilities].
* Doesn't follow redirects.
* Sends an OPTIONS request to this resource. Doesn't follow redirects.
*
* @return response object with capabilities set as received in HTTP response (must
* be closed by caller)
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
*/
@Throws(IOException::class, HttpException::class)
fun options(): DavResponse {
val response = httpClient.newCall(Request.Builder()
fun options(callback: (davCapabilities: Set<String>, response: Response) -> Unit) {
httpClient.newCall(Request.Builder()
.method("OPTIONS", null)
.header("Content-Length", "0")
.url(location)
.build()).execute()
checkStatus(response)
return DavResponse.Builder(location)
.capabilities(HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet())
.responseBody(response.body())
.build()
.build()).execute().use { response ->
checkStatus(response)
callback(HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(), response)
}
}
/**
* Sends a MKCOL request to this resource. Follows up to [MAX_REDIRECTS] redirects.
*
* @return response object (must be closed by caller)
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
*/
@Throws(IOException::class, HttpException::class)
fun mkCol(xmlBody: String?): DavResponse {
fun mkCol(xmlBody: String?, callback: (response: Response) -> Unit) {
val rqBody = if (xmlBody != null) RequestBody.create(MIME_XML, xmlBody) else null
var response: Response? = null
for (attempt in 1..MAX_REDIRECTS) {
response = httpClient.newCall(Request.Builder()
followRedirects {
httpClient.newCall(Request.Builder()
.method("MKCOL", rqBody)
.url(location)
.build()).execute()
if (response.isRedirect)
processRedirect(response)
else
break
}.use { response ->
checkStatus(response)
callback(response)
}
checkStatus(response!!)
return DavResponse.Builder(location)
.responseBody(response.body())
.build()
}
/**
......@@ -125,46 +112,25 @@ open class DavResource @JvmOverloads constructor(
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* When the server returns ETag and/or Content-Type, they're stored as response properties.
*
* @param accept value of Accept header (must not be null, but may be *&#47;*)
*
* @return response object (must be closed by caller)
* @param accept value of Accept header (must not be null, but may be *&#47;*)
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
*/
@Throws(IOException::class, HttpException::class)
fun get(accept: String): DavResponse {
var response: Response? = null
for (attempt in 1..MAX_REDIRECTS) {
response = httpClient.newCall(Request.Builder()
fun get(accept: String, callback: (response: Response) -> Unit) {
followRedirects {
httpClient.newCall(Request.Builder()
.get()
.url(location)
.header("Accept", accept)
.header("Accept-Encoding", "identity") // disable compression because it can change the ETag
.build()).execute()
if (response.isRedirect)
processRedirect(response)
else
break
}
checkStatus(response!!)
val properties = mutableListOf<Property>()
response.header("ETag")?.let { eTag ->
properties += GetETag(eTag)
}.use { response ->
checkStatus(response)
callback(response)
}
val body = response.body() ?: throw DavException("Received GET response without body", httpResponse = response)
body.contentType()?.let { mimeType ->
properties += GetContentType(mimeType)
}
return DavResponse.Builder(location)
.responseBody(body)
.properties(properties)
.build()
}
/**
......@@ -172,19 +138,17 @@ open class DavResource @JvmOverloads constructor(
*
* When the server returns an ETag, it is stored in response properties.
*
* @param body new resource body to upload
* @param ifMatchETag value of `If-Match` header to set, or null to omit
* @param ifNoneMatch indicates whether `If-None-Match: *` ("don't overwrite anything existing") header shall be sent
*
* @return response object (must be closed by caller)
* @param body new resource body to upload
* @param ifMatchETag value of `If-Match` header to set, or null to omit
* @param ifNoneMatch indicates whether `If-None-Match: *` ("don't overwrite anything existing") header shall be sent
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
*/
@Throws(IOException::class, HttpException::class)
fun put(body: RequestBody, ifMatchETag: String?, ifNoneMatch: Boolean): DavResponse {
var response: Response? = null
for (attempt in 1..MAX_REDIRECTS) {
fun put(body: RequestBody, ifMatchETag: String?, ifNoneMatch: Boolean, callback: (Response) -> Unit) {
followRedirects {
val builder = Request.Builder()
.put(body)
.url(location)
......@@ -196,23 +160,11 @@ open class DavResource @JvmOverloads constructor(
// don't overwrite anything existing
builder.header("If-None-Match", "*")
response = httpClient.newCall(builder.build()).execute()
if (response.isRedirect)
processRedirect(response)
else
break
}
checkStatus(response!!)
val properties = mutableListOf<Property>()
response.header("ETag")?.let { eTag ->
properties += GetETag(eTag)
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
callback(response)
}
return DavResponse.Builder(location)
.properties(properties)
.responseBody(response.body())
.build()
}
/**
......@@ -221,41 +173,34 @@ open class DavResource @JvmOverloads constructor(
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param ifMatchETag value of `If-Match` header to set, or null to omit
*
* @return response object (must be closed by caller)
* @param ifMatchETag value of `If-Match` header to set, or null to omit
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP errors, or when 207 Multi-Status is returned
* (because then there was probably a problem with a member resource)
*/
@Throws(IOException::class, HttpException::class)
fun delete(ifMatchETag: String?): DavResponse {
var response: Response? = null
for (attempt in 1..MAX_REDIRECTS) {
fun delete(ifMatchETag: String?, callback: (Response) -> Unit) {
followRedirects {
val builder = Request.Builder()
.delete()
.url(location)
if (ifMatchETag != null)
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifMatchETag))
response = httpClient.newCall(builder.build()).execute()
if (response.isRedirect)
processRedirect(response)
else
break
}
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
checkStatus(response!!)
if (response.code() == 207)
if (response.code() == 207)
/* If an error occurs deleting a member resource (a resource other than
the resource identified in the Request-URI), then the response can be
a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */
throw HttpException(response)
throw HttpException(response)
return DavResponse.Builder(location)
.responseBody(response.body())
.build()
callback(response)
}
}
/**
......@@ -265,15 +210,14 @@ open class DavResource @JvmOverloads constructor(
*
* @param depth "Depth" header to send (-1 for `infinity`)
* @param reqProp properties to request
*
* @return response object (must be closed by caller)
* @param callback called for every XML response element in the Multi-Status response
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response)
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun propfind(depth: Int, vararg reqProp: Property.Name): DavResponse {
fun propfind(depth: Int, vararg reqProp: Property.Name, callback: DavResponseCallback) {
// build XML request body
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
......@@ -292,23 +236,15 @@ open class DavResource @JvmOverloads constructor(
serializer.endTag(XmlUtils.NS_WEBDAV, "propfind")
serializer.endDocument()
var response: Response? = null
for (attempt in 1..MAX_REDIRECTS) {
response = httpClient.newCall(Request.Builder()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("PROPFIND", RequestBody.create(MIME_XML, writer.toString()))
.header("Depth", if (depth >= 0) depth.toString() else "infinity")
.build()).execute()
if (response.isRedirect)
processRedirect(response)
else
break
}.use {
processMultiStatus(it, callback)
}
checkStatus(response!!)
assertMultiStatus(response)
return processMultiStatus(response.body()?.charStream()!!)
}
......@@ -319,24 +255,16 @@ open class DavResource @JvmOverloads constructor(
*
* @throws HttpException in case of an HTTP error
*/
protected fun checkStatus(response: Response) =
checkStatus(response.code(), response.message(), response)
/**
* Checks the status from an HTTP [StatusLine] and throws an exception in case of an error.
*
* @throws HttpException in case of an HTTP error
*/
protected fun checkStatus(status: StatusLine) =
checkStatus(status.code, status.message, null)
private fun checkStatus(response: Response) =
checkStatus(response.code(), response.message(), response)
/**
* Checks the status from an HTTP response and throws an exception in case of an error.
*
* @throws HttpException in case of an HTTP error
* @throws HttpException (with XML error names, if available) in case of an HTTP error
*/
protected fun checkStatus(code: Int, message: String?, response: Response?) {
if (code/100 == 2)
private fun checkStatus(code: Int, message: String?, response: Response?) {
if (code / 100 == 2)
// everything OK
return
......@@ -357,11 +285,40 @@ open class DavResource @JvmOverloads constructor(
}
/**
* Asserts a 207 Multi-Status response.
* Send a request and follows up to [MAX_REDIRECTS] redirects.
*
* @param sendRequest called to send the request (may be called multiple times)
*
* @return response of the last request (whether it is a redirect or not)
*/
protected fun followRedirects(sendRequest: () -> Response): Response {
lateinit var response: Response
for (attempt in 1..MAX_REDIRECTS) {
response = sendRequest()
if (response.isRedirect)
// handle 3xx Redirection
response.use {
val target = it.header("Location")?.let { location.resolve(it) }
if (target != null) {
log.fine("Redirected, new location = $target")
location = target
} else
throw DavException("Redirected without new Location")
}
else
break
}
return response
}
/**
* Asserts a Multi-Status response.
*
* @param response will be checked for Multi-Status response
*
* @throws DavException if the response is not a Multi-Status response
*/
protected fun assertMultiStatus(response: Response) {
private fun assertMultiStatus(response: Response) {
if (response.code() != 207)
throw DavException("Expected 207 Multi-Status, got ${response.code()} ${response.message()}")
......@@ -374,266 +331,87 @@ open class DavResource @JvmOverloads constructor(
} ?: log.warning("Received 207 Multi-Status without Content-Type, assuming XML")
}