DavResource.kt 26.7 KB
Newer Older
Ricki Hirner's avatar
Ricki Hirner committed
1
/*
2
 * Copyright © Ricki Hirner (bitfire web engineering).
Ricki Hirner's avatar
Ricki Hirner committed
3 4 5 6 7 8 9 10 11 12 13 14
 * 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 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
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
15
import at.bitfire.dav4android.property.SyncToken
Ricki Hirner's avatar
Ricki Hirner committed
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import okhttp3.*
import okhttp3.internal.http.StatusLine
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
import java.io.Reader
import java.io.StringWriter
import java.net.HttpURLConnection
import java.net.ProtocolException
import java.util.*
import java.util.logging.Level
import java.util.logging.Logger

/**
 * Represents a WebDAV resource at the given location.
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
31
 * @param httpClient    [OkHttpClient] to access this object
Ricki Hirner's avatar
Ricki Hirner committed
32
 * @param location      location of the WebDAV resource
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
33
 * @param log           [Logger] which will be used for logging, or null for default
Ricki Hirner's avatar
Ricki Hirner committed
34 35 36 37 38 39 40
 */
open class DavResource @JvmOverloads constructor(
        val httpClient: OkHttpClient,
        var location: HttpUrl,
        val log: Logger = Constants.log
) {

Ricki Hirner's avatar
Tests  
Ricki Hirner committed
41
    companion object {
42
        const val MAX_REDIRECTS = 5
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
43 44
        val MIME_XML = MediaType.parse("application/xml; charset=utf-8")
    }
Ricki Hirner's avatar
Ricki Hirner committed
45

Ricki Hirner's avatar
Tests  
Ricki Hirner committed
46
    /** HTTP capabilities reported by an OPTIONS response */
Ricki Hirner's avatar
Ricki Hirner committed
47
    val capabilities = mutableSetOf<String>()
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
48 49

    /** WebDAV properties of this resource */
Ricki Hirner's avatar
Ricki Hirner committed
50
    val properties = PropertyCollection()
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
51 52 53 54 55

    /** whether a 507 Insufficient Storage was found in the response */
    var furtherResults = false

    /** members of this resource */
Ricki Hirner's avatar
Ricki Hirner committed
56
    val members = mutableSetOf<DavResource>()
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
57 58 59
    /** members of this resource with status 404 Not Found */
    val removedMembers = mutableSetOf<DavResource>()
    /** resources which have been found in the answer, although they aren't members of this resource */
Ricki Hirner's avatar
Ricki Hirner committed
60
    val related = mutableSetOf<DavResource>()
Ricki Hirner's avatar
Ricki Hirner committed
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89

    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")
    }


    fun fileName(): String {
        val pathSegments = location.pathSegments()
        return pathSegments[pathSegments.size - 1]
    }

    override fun toString() = location.toString()


    /**
     * Sends an OPTIONS request to this resource, requesting [capabilities].
     * @throws IOException on I/O error
     * @throws HttpException on HTTP error
     * @throws DavException on DAV error
     */
    @Throws(IOException::class, HttpException::class, DavException::class)
    fun options() {
        capabilities.clear()

        val response = httpClient.newCall(Request.Builder()
                .method("OPTIONS", null)
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
90
                .header("Content-Length", "0")
Ricki Hirner's avatar
Ricki Hirner committed
91 92 93 94 95 96 97 98
                .url(location)
                .build()).execute()
        checkStatus(response, true)

        HttpUtils.listHeader(response, "DAV").mapTo(capabilities) { it.trim() }
    }

    /**
99 100
     * Sends a MKCOL request to this resource. Response body will be closed unless
     * an exception is thrown.
Ricki Hirner's avatar
Ricki Hirner committed
101 102 103 104 105 106 107 108
     * @throws IOException on I/O error
     * @throws HttpException on HTTP error
     */
    @Throws(IOException::class, HttpException::class)
    fun mkCol(xmlBody: String?) {
        val rqBody = if (xmlBody != null) RequestBody.create(MIME_XML, xmlBody) else null

        var response: Response? = null
Ricki Hirner's avatar
Ricki Hirner committed
109
        for (attempt in 1..MAX_REDIRECTS) {
Ricki Hirner's avatar
Ricki Hirner committed
110 111 112 113 114
            response = httpClient.newCall(Request.Builder()
                    .method("MKCOL", rqBody)
                    .url(location)
                    .build()).execute()
            if (response.isRedirect)
Ricki Hirner's avatar
Ricki Hirner committed
115
                processRedirect(response)
Ricki Hirner's avatar
Ricki Hirner committed
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
            else
                break
        }
        checkStatus(response!!, true)
    }

    /**
     * Sends a GET request to the resource. Note that this method expects the server to
     * return an ETag (which is required for CalDAV and CardDAV, but not for WebDAV in general).
     * @param accept    content of Accept header (must not be null, but may be &#42;&#47;* )
     * @return          response body (has to be closed by caller)
     * @throws IOException on I/O error
     * @throws HttpException on HTTP error
     * @throws DavException on WebDAV error, or when the response doesn't contain an ETag
     */
    @Throws(IOException::class, HttpException::class, DavException::class)
    fun get(accept: String): ResponseBody {
        var response: Response? = null
Ricki Hirner's avatar
Ricki Hirner committed
134
        for (attempt in 1..MAX_REDIRECTS) {
Ricki Hirner's avatar
Ricki Hirner committed
135 136 137 138 139 140 141
            response = 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)
Ricki Hirner's avatar
Ricki Hirner committed
142
                processRedirect(response)
Ricki Hirner's avatar
Ricki Hirner committed
143 144 145 146 147 148
            else
                break
        }
        checkStatus(response!!, false)

        val eTag = response.header("ETag")
Ricki Hirner's avatar
Ricki Hirner committed
149
        if (eTag.isNullOrEmpty())
150
            properties -= GetETag.NAME
Ricki Hirner's avatar
Ricki Hirner committed
151 152 153 154 155
        else
            properties[GetETag.NAME] = GetETag(eTag)

        val body = response.body() ?: throw HttpException("GET without response body")

Ricki Hirner's avatar
Ricki Hirner committed
156
        body.contentType()?.let { mimeType ->
Ricki Hirner's avatar
Ricki Hirner committed
157
            properties[GetContentType.NAME] = GetContentType(mimeType)
Ricki Hirner's avatar
Ricki Hirner committed
158
        }
Ricki Hirner's avatar
Ricki Hirner committed
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175

        return body
    }

    /**
     * Sends a PUT request to the resource.
     * @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                  true if the request was redirected successfully, i.e. #{@link #location} and maybe resource name may have changed
     * @throws IOException on I/O error
     * @throws HttpException on HTTP error
     */
    @Throws(IOException::class, HttpException::class)
    fun put(body: RequestBody, ifMatchETag: String?, ifNoneMatch: Boolean): Boolean {
        var redirected = false
        var response: Response? = null
Ricki Hirner's avatar
Ricki Hirner committed
176
        for (attempt in 1..MAX_REDIRECTS) {
Ricki Hirner's avatar
Ricki Hirner committed
177 178 179 180 181 182 183 184 185 186 187 188 189
            val builder = Request.Builder()
                    .put(body)
                    .url(location)

            if (ifMatchETag != null)
                // only overwrite specific version
                builder.header("If-Match", QuotedStringUtils.asQuotedString(ifMatchETag))
            if (ifNoneMatch)
                // don't overwrite anything existing
                builder.header("If-None-Match", "*")

            response = httpClient.newCall(builder.build()).execute()
            if (response.isRedirect) {
Ricki Hirner's avatar
Ricki Hirner committed
190
                processRedirect(response)
Ricki Hirner's avatar
Ricki Hirner committed
191 192 193 194 195 196 197
                redirected = true
            } else
                break
        }
        checkStatus(response!!, true)

        val eTag = response.header("ETag")
Ricki Hirner's avatar
Ricki Hirner committed
198
        if (eTag.isNullOrEmpty())
199
            properties -= GetETag.NAME
Ricki Hirner's avatar
Ricki Hirner committed
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214
        else
            properties[GetETag.NAME] = GetETag(eTag)

        return redirected
    }

    /**
     * Sends a DELETE request to the resource.
     * @param ifMatchETag value of "If-Match" header to set, or null to omit
     * @throws IOException on I/O error
     * @throws HttpException on HTTP errors, including redirects
     */
    @Throws(IOException::class, HttpException::class)
    fun delete(ifMatchETag: String?) {
        var response: Response? = null
Ricki Hirner's avatar
Ricki Hirner committed
215
        for (attempt in 1..MAX_REDIRECTS) {
Ricki Hirner's avatar
Ricki Hirner committed
216 217 218 219 220 221 222
            val builder = Request.Builder()
                    .delete()
                    .url(location)
            if (ifMatchETag != null)
                builder.header("If-Match", QuotedStringUtils.asQuotedString(ifMatchETag))

            response = httpClient.newCall(builder.build()).execute()
Ricki Hirner's avatar
Ricki Hirner committed
223 224 225
            if (response.isRedirect)
                processRedirect(response)
            else
Ricki Hirner's avatar
Ricki Hirner committed
226 227 228 229
                break
        }

        checkStatus(response!!, false)
Ricki Hirner's avatar
Ricki Hirner committed
230
        if (response.code() == 207)
Ricki Hirner's avatar
Ricki Hirner committed
231 232 233 234
            /* 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)
Ricki Hirner's avatar
Ricki Hirner committed
235
        else
Ricki Hirner's avatar
Ricki Hirner committed
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270
            response.body()?.close()
    }

    /**
     * Sends a PROPFIND request to the resource. Expects and processes a 207 multi-status response.
     * #{@link #properties} are updated according to the multi-status response.
     * #{@link #members} is re-built according to the multi-status response (i.e. previous member entries won't be retained).
     * @param depth      "Depth" header to send, e.g. 0 or 1
     * @param reqProp    properties to request
     * @throws IOException on I/O error
     * @throws HttpException on HTTP error
     * @throws DavException on WebDAV error
     */
    @Throws(IOException::class, HttpException::class, DavException::class)
    fun propfind(depth: Int, vararg reqProp: Property.Name) {
        // build XML request body
        val serializer = XmlUtils.newSerializer()
        val writer = StringWriter()
        serializer.setOutput(writer)
        serializer.setPrefix("", XmlUtils.NS_WEBDAV)
        serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
        serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
        serializer.startDocument("UTF-8", null)
        serializer.setPrefix("", XmlUtils.NS_WEBDAV)
        serializer.startTag(XmlUtils.NS_WEBDAV, "propfind")
        serializer.startTag(XmlUtils.NS_WEBDAV, "prop")
        for (prop in reqProp) {
            serializer.startTag(prop.namespace, prop.name)
            serializer.endTag(prop.namespace, prop.name)
        }
        serializer.endTag(XmlUtils.NS_WEBDAV, "prop")
        serializer.endTag(XmlUtils.NS_WEBDAV, "propfind")
        serializer.endDocument()

        var response: Response? = null
Ricki Hirner's avatar
Ricki Hirner committed
271
        for (attempt in 1..MAX_REDIRECTS) {
Ricki Hirner's avatar
Ricki Hirner committed
272 273 274 275 276 277
            response = httpClient.newCall(Request.Builder()
                    .url(location)
                    .method("PROPFIND", RequestBody.create(MIME_XML, writer.toString()))
                    .header("Depth", depth.toString())
                    .build()).execute()
            if (response.isRedirect)
Ricki Hirner's avatar
Ricki Hirner committed
278
                processRedirect(response)
Ricki Hirner's avatar
Ricki Hirner committed
279 280 281 282 283 284 285
            else
                break
        }

        checkStatus(response!!, false)
        assertMultiStatus(response)

286
        if (depth > 0)
Ricki Hirner's avatar
Ricki Hirner committed
287
            // collection listing requested, drop old member information
288
            resetMembers()
Ricki Hirner's avatar
Ricki Hirner committed
289

290
        // process and close multi-status response body
Ricki Hirner's avatar
Ricki Hirner committed
291 292 293 294 295 296 297 298
        response.body()?.charStream()?.use { processMultiStatus(it) }
    }


    // status handling

    /**
     * Checks the status from an HTTP response and throws an exception in case of an error.
299 300 301
     * @param closeBody whether [response] shall be closed by this method, unless an exception
     *        is thrown. When an exception is thrown, the body is not closed to allow
     *        reading for debugging.
Ricki Hirner's avatar
Ricki Hirner committed
302 303 304 305 306 307 308 309 310 311 312
     * @throws HttpException in case of an HTTP error
     */
    protected fun checkStatus(response: Response, closeBody: Boolean) {
        checkStatus(response.code(), response.message(), response)

        if (closeBody)
            response.body()?.close()
    }

    /**
     * Checks the status from an HTTP [StatusLine] and throws an exception in case of an error.
313
     * The response body is not being closed.
Ricki Hirner's avatar
Ricki Hirner committed
314 315 316 317 318 319 320
     * @throws HttpException in case of an HTTP error
     */
    protected fun checkStatus(status: StatusLine) =
        checkStatus(status.code, status.message, null)

    /**
     * Checks the status from an HTTP response and throws an exception in case of an error.
321
     * The response body is not being closed.
Ricki Hirner's avatar
Ricki Hirner committed
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364
     * @throws HttpException in case of an HTTP error
     */
    protected fun checkStatus(code: Int, message: String?, response: Response?) {
        if (code/100 == 2)
            // everything OK
            return

        throw when (code) {
            HttpURLConnection.HTTP_UNAUTHORIZED ->
                if (response != null) UnauthorizedException(response) else UnauthorizedException(message)
            HttpURLConnection.HTTP_NOT_FOUND ->
                if (response != null) NotFoundException(response) else NotFoundException(message)
            HttpURLConnection.HTTP_CONFLICT ->
                if (response != null) ConflictException(response) else ConflictException(message)
            HttpURLConnection.HTTP_PRECON_FAILED ->
                if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message)
            HttpURLConnection.HTTP_UNAVAILABLE ->
                if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message)
            else ->
                if (response != null) HttpException(response) else HttpException(code, message)
        }
    }

    /**
     * Asserts a 207 multi-status response.
     * @throws DavException if the response is not a multi-status response with body
     */
    protected fun assertMultiStatus(response: Response) {
        if (response.code() != 207)
            throw InvalidDavResponseException("Expected 207 Multi-Status, got ${response.code()} ${response.message()}")

        if (response.body() == null)
            throw InvalidDavResponseException("Received 207 Multi-Status without body")

        val mediaType = response.body()?.contentType()
        if (mediaType != null) {
            if (((mediaType.type() != "application" && mediaType.type() != "text")) || mediaType.subtype() != "xml")
                throw InvalidDavResponseException("Received non-XML 207 Multi-Status")
        } else
            log.warning("Received 207 Multi-Status without Content-Type, assuming XML")
    }

    /**
Ricki Hirner's avatar
Ricki Hirner committed
365
     * Sets the new [location] in case of a redirect. Closes the [response] body.
Ricki Hirner's avatar
Ricki Hirner committed
366 367
     * @throws HttpException in case of an HTTP error
     */
Ricki Hirner's avatar
Ricki Hirner committed
368
    protected fun processRedirect(response: Response) {
Ricki Hirner's avatar
Ricki Hirner committed
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402
        try {
            response.header("Location")?.let {
                val target = location.resolve(it)
                if (target != null) {
                    log.fine("Redirected, new location = $target")
                    location = target
                } else
                    throw HttpException("Redirected without new Location")
            }
        } finally {
            response.body()?.close()
        }
    }


    // multi-status handling

    /**
     * Process a 207 multi-status response.
     * @throws IOException on I/O error
     * @throws HttpException on HTTP error
     * @throws DavException on WebDAV error
     */
    protected fun processMultiStatus(reader: Reader) {
        val parser = XmlUtils.newPullParser()

        // some parsing sub-functions
        fun parseMultiStatus_Prop(): PropertyCollection? {
            // <!ELEMENT prop ANY >
            val depth = parser.depth
            val prop = PropertyCollection()

            var eventType = parser.eventType
            while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
Ricki Hirner's avatar
Ricki Hirner committed
403
                if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
Ricki Hirner's avatar
Ricki Hirner committed
404
                    val name = Property.Name(parser.namespace, parser.name)
Ricki Hirner's avatar
Ricki Hirner committed
405
                    val property = PropertyRegistry.create(name, parser)
Ricki Hirner's avatar
Ricki Hirner committed
406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
                    if (property != null)
                        prop[name] = property
                    else
                        log.fine("Ignoring unknown property $name")
                }
                eventType = parser.next()
            }

            return prop
        }

        fun parseMultiStatus_PropStat(): PropertyCollection? {
            // <!ELEMENT propstat (prop, status, error?, responsedescription?) >
            val depth = parser.depth

            var status: StatusLine? = null
            var prop: PropertyCollection? = null

            var eventType = parser.eventType
            while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
                if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1)
                    if (parser.namespace == XmlUtils.NS_WEBDAV)
                        when (parser.name) {
                            "prop" ->
                                prop = parseMultiStatus_Prop()
                            "status" ->
432 433
                                status = try {
                                    StatusLine.parse(parser.nextText())
Ricki Hirner's avatar
Ricki Hirner committed
434 435
                                } catch(e: ProtocolException) {
                                    log.warning("Invalid status line, treating as 500 Server Error")
436
                                    StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
Ricki Hirner's avatar
Ricki Hirner committed
437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487
                                }
                        }
                eventType = parser.next()
            }

            if (prop != null && status != null && status.code/100 != 2)
                // not successful, null out property values so that they can be removed when merging in parseMultiStatus_Response
                prop.nullAllValues()

            return prop
        }

        fun parseMultiStatus_Response() {
            /* <!ELEMENT response (href, ((href*, status)|(propstat+)),
                                           error?, responsedescription? , location?) > */
            val depth = parser.depth

            var href: HttpUrl? = null
            var status: StatusLine? = null
            val properties = PropertyCollection()

            var eventType = parser.eventType
            while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
                if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1)
                    if (parser.namespace == XmlUtils.NS_WEBDAV)
                        when (parser.name) {
                            "href" -> {
                                var sHref = parser.nextText()
                                if (!sHref.startsWith("/")) {
                                    /* According to RFC 4918 8.3 URL Handling, only absolute paths are allowed as relative
                                       URLs. However, some servers reply with relative paths. */
                                    val firstColon = sHref.indexOf(':')
                                    if (firstColon != -1) {
                                        /* There are some servers which return not only relative paths, but relative paths like "a:b.vcf",
                                           which would be interpreted as scheme: "a", scheme-specific part: "b.vcf" normally.
                                           For maximum compatibility, we prefix all relative paths which contain ":" (but not "://"),
                                           with "./" to allow resolving by HttpUrl. */
                                        var hierarchical = false
                                        try {
                                            if (sHref.substring(firstColon, firstColon + 3) == "://")
                                                hierarchical = true
                                        } catch (e: IndexOutOfBoundsException) {
                                            // no "://"
                                        }
                                        if (!hierarchical)
                                            sHref = "./$sHref"
                                    }
                                }
                                href = location.resolve(sHref)
                            }
                            "status" ->
488 489
                                status = try {
                                    StatusLine.parse(parser.nextText())
Ricki Hirner's avatar
Ricki Hirner committed
490 491
                                } catch(e: ProtocolException) {
                                    log.warning("Invalid status line, treating as 500 Server Error")
492
                                    StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
Ricki Hirner's avatar
Ricki Hirner committed
493 494 495 496 497 498 499 500 501 502 503 504 505 506 507
                                }
                            "propstat" ->
                                parseMultiStatus_PropStat()?.let { properties.merge(it, false) }
                            "location" ->
                                throw UnsupportedDavException("Redirected child resources are not supported yet")
                        }
                eventType = parser.next()
            }

            if (href == null) {
                log.warning("Ignoring <response> without valid <href>")
                return
            }

            // if we know this resource is a collection, make sure href has a trailing slash (for clarity and resolving relative paths)
508
            val type = properties[ResourceType::class.java]
Ricki Hirner's avatar
Ricki Hirner committed
509 510 511 512 513
            if (type != null && type.types.contains(ResourceType.COLLECTION))
                href = UrlUtils.withTrailingSlash(href)

            log.log(Level.FINE, "Received <response> for $href", if (status != null) status else properties)

Ricki Hirner's avatar
Tests  
Ricki Hirner committed
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529
            var removed = false
            var insufficientStorage = false
            status?.let {
                /* Treat an HTTP error of a single response (i.e. requested resource or a member)
                   like an HTTP error of the requested resource.

                Exceptions for RFC 6578 support:
                  - 507 Insufficient Storage on the requested resource means there are further results
                  - members with status 404 Not Found go into removedMembers instead of members
                */
                when (it.code) {
                    404  -> removed = true
                    507  -> insufficientStorage = true
                    else -> checkStatus(it)
                }
            }
Ricki Hirner's avatar
Ricki Hirner committed
530 531 532

            // Which resource does this <response> represent?
            var target: DavResource? = null
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
533 534
            if (UrlUtils.equals(UrlUtils.omitTrailingSlash(href), UrlUtils.omitTrailingSlash(location)) && !removed) {
                // it's about ourselves (and not 404)
Ricki Hirner's avatar
Ricki Hirner committed
535
                target = this
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
536

Ricki Hirner's avatar
Ricki Hirner committed
537 538 539 540 541 542 543 544 545 546 547 548 549 550
            } else if (location.scheme() == href.scheme() && location.host() == href.host() && location.port() == href.port()) {
                val locationSegments = location.pathSegments()
                val hrefSegments = href.pathSegments()

                // don't compare trailing slash segment ("")
                var nBasePathSegments = locationSegments.size
                if (locationSegments[nBasePathSegments-1] == "")
                    nBasePathSegments--

                /* example:   locationSegments  = [ "davCollection", "" ]
                              nBasePathSegments = 1
                              hrefSegments      = [ "davCollection", "aMember" ]
                */
                if (hrefSegments.size > nBasePathSegments) {
551
                    val sameBasePath = (0 until nBasePathSegments).none { locationSegments[it] != hrefSegments[it] }
Ricki Hirner's avatar
Ricki Hirner committed
552
                    if (sameBasePath) {
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
553
                        // it's about a member
Ricki Hirner's avatar
Ricki Hirner committed
554
                        target = DavResource(httpClient, href, log)
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
555 556 557 558
                        if (!removed)
                            members += target
                        else
                            removedMembers += target
Ricki Hirner's avatar
Ricki Hirner committed
559 560 561 562 563 564 565 566 567 568 569
                    }
                }
            }

            if (target == null) {
                log.warning("Received <response> not for self and not for member resource: $href")
                target = DavResource(httpClient, href, log)
                related.add(target)
            }

            // set properties for target
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
570
            target.furtherResults = insufficientStorage
Ricki Hirner's avatar
Ricki Hirner committed
571 572 573 574 575 576 577 578 579
            target.properties.merge(properties, true)
        }

        fun parseMultiStatus() {
            // <!ELEMENT multistatus (response*, responsedescription?)  >
            val depth = parser.depth

            var eventType = parser.eventType
            while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
580 581 582 583 584 585 586 587 588
                if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.namespace == XmlUtils.NS_WEBDAV)
                    when (parser.name) {
                        "response" ->
                            parseMultiStatus_Response()
                        "sync-token" ->
                            XmlUtils.readText(parser)?.let { token ->
                                properties[SyncToken.NAME] = SyncToken(token)
                            }
                    }
Ricki Hirner's avatar
Ricki Hirner committed
589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619
                eventType = parser.next()
            }
        }

        try {
            parser.setInput(reader)

            var multiStatus = false

            var eventType = parser.eventType
            while (eventType != XmlPullParser.END_DOCUMENT) {
                if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
                    if (parser.namespace == XmlUtils.NS_WEBDAV && parser.name == "multistatus") {
                        parseMultiStatus()
                        multiStatus = true
                    }
                eventType = parser.next()
            }

            if (!multiStatus)
                throw InvalidDavResponseException("Multi-Status response didn't contain <DAV:multistatus> root element")

        } catch (e: XmlPullParserException) {
            throw InvalidDavResponseException("Couldn't parse Multi-Status XML", e)
        }
    }


    // helpers

    /** Finds first property within all responses (including unasked responses) */
620
    fun<T: Property> findProperty(clazz: Class<T>): Pair<DavResource, T>? {
Ricki Hirner's avatar
Ricki Hirner committed
621
        // check resource itself
622
        val property = properties[clazz]
Ricki Hirner's avatar
Ricki Hirner committed
623
        if (property != null)
624
            return Pair(this, property)
Ricki Hirner's avatar
Ricki Hirner committed
625 626 627

        // check members
        for (member in members)
628
            member.findProperty(clazz)?.let { return it }
Ricki Hirner's avatar
Ricki Hirner committed
629 630 631

        // check unrequested responses
        for (resource in related)
632
            resource.findProperty(clazz)?.let { return it }
Ricki Hirner's avatar
Ricki Hirner committed
633 634 635 636 637

        return null
    }

    /** Finds properties within all responses (including unasked responses) */
638 639
    fun<T: Property> findProperties(clazz: Class<T>): List<Pair<DavResource, T>> {
        val result = LinkedList<Pair<DavResource, T>>()
Ricki Hirner's avatar
Ricki Hirner committed
640 641

        // check resource itself
642
        val property = properties[clazz]
Ricki Hirner's avatar
Ricki Hirner committed
643
        if (property != null)
644
            result.add(Pair(this, property))
Ricki Hirner's avatar
Ricki Hirner committed
645 646 647

        // check members
        for (member in members)
648
            result.addAll(member.findProperties(clazz))
Ricki Hirner's avatar
Ricki Hirner committed
649 650 651

        // check unrequested responses
        for (rel in related)
652
            result.addAll(rel.findProperties(clazz))
Ricki Hirner's avatar
Ricki Hirner committed
653 654 655 656

        return Collections.unmodifiableList(result)
    }

657 658 659 660 661 662
    protected fun resetMembers() {
        members.clear()
        removedMembers.clear()
        related.clear()
    }

Ricki Hirner's avatar
Ricki Hirner committed
663
}