sync(chunk 2a): vendor dav4jvm 2.2.1 as :dav
JitPack-only publishing conflicts with FAIL_ON_PROJECT_REPOS, artifacts are unsigned and not immutable, and upstream shipped two breaking majors 19 days apart. 2.2.1 is the last OkHttp release: 3.0.0 moved to Ktor and 4.x needs Java 21, and SYNC.md's whole auth section is written in OkHttp terms, so 4.x would have invalidated it. A plain JVM module, not an Android library — the tree has no Android imports and keeping it that way keeps the floret-kit extraction a file move. Four changes from upstream, all in dav/PROVENANCE.md: - commons-lang3 dropped (one import, one call). The replacement requires the whole string to be consumed, as DateUtils did: pattern 1 ends in a literal 'GMT', so SimpleDateFormat would match "...GMT+02:00" as a prefix and throw the offset away. - HTTP dates were parsed and formatted in the device's local zone — the GMT in the format string is a quoted literal and timeZone was never set, so every getlastmodified was out by the local UTC offset. Upstream tests dates nowhere. The formatter is also no longer shared and mutable. - dav4jvm#209: permanent redirects now reach the caller via DavResource.permanentLocation, cleared per request so it never describes an earlier one. - xpp3 is compileOnly; Android supplies org.xmlpull.v1. SYNC.md's other claimed defect, "does not follow 303", is not true of 2.2.1 — pinned by a test rather than removed from the notes. CI names :dav:test explicitly; testDebugUnitTest is Android-only and would have run none of the vendored suite. lintDebug added to the per-chunk checks after it caught a literal byte-order mark left by chunk 1.
This commit is contained in:
@@ -141,9 +141,12 @@ jobs:
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
||||
# that task would compile the vendored suite and run none of it, which is
|
||||
# the whole safety argument in dav/PROVENANCE.md.
|
||||
- name: Unit tests
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
run: ./gradlew testDebugUnitTest
|
||||
run: ./gradlew testDebugUnitTest :dav:test
|
||||
|
||||
- name: Assemble debug APK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
|
||||
@@ -182,6 +182,15 @@ dependencies {
|
||||
// API surface, so it isn't declared separately.
|
||||
implementation(libs.dmfs.lib.recur)
|
||||
|
||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||
implementation(project(":dav"))
|
||||
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
||||
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
||||
// with no framework, and android.jar's stub factory returns null under
|
||||
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
||||
// real implementation here.
|
||||
testImplementation(libs.xpp3)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
@@ -15,6 +15,13 @@ package de.jeanlucmakiola.agendula.domain.ical
|
||||
*/
|
||||
object ICalParser {
|
||||
|
||||
/**
|
||||
* Written as an escape, not as the character itself: a literal BOM anywhere
|
||||
* but byte 0 of a file is what Android Lint's `ByteOrderMark` check exists to
|
||||
* catch, and it fails the build.
|
||||
*/
|
||||
private const val BOM = "\uFEFF"
|
||||
|
||||
fun parse(text: String): ICalComponent =
|
||||
parseAll(text).firstOrNull() ?: throw ICalParseException("no component found")
|
||||
|
||||
@@ -67,7 +74,7 @@ object ICalParser {
|
||||
*/
|
||||
internal fun unfold(text: String): List<String> {
|
||||
val lines = mutableListOf<StringBuilder>()
|
||||
val normalised = text.removePrefix("")
|
||||
val normalised = text.removePrefix(BOM)
|
||||
for (raw in normalised.split("\r\n", "\n", "\r")) {
|
||||
if (raw.isEmpty()) continue
|
||||
val continuation = raw[0] == ' ' || raw[0] == '\t'
|
||||
|
||||
@@ -35,7 +35,7 @@ class ICalParserTest {
|
||||
|
||||
@Test
|
||||
fun `a leading BOM is stripped rather than glued onto the first name`() {
|
||||
val component = ICalParser.parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n")
|
||||
val component = ICalParser.parse("\uFEFFBEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n")
|
||||
assertThat(component.name).isEqualTo("VTODO")
|
||||
}
|
||||
}
|
||||
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1,143 @@
|
||||
# `:dav` — vendored dav4jvm
|
||||
|
||||
**Upstream:** [bitfireAT/dav4jvm](https://github.com/bitfireAT/dav4jvm),
|
||||
tag **2.2.1**, commit `f434c9d19b322228916c106beaebd8634b85ddb1`.
|
||||
**Licence:** MPL-2.0 (`dav/LICENSE`, verbatim). Every file keeps its upstream
|
||||
header, as §3.4 requires. Agendula's own code is MIT and unaffected — MPL is
|
||||
file-level copyleft, which is why this lives in its own module rather than
|
||||
inside `:app`.
|
||||
|
||||
62 source files, ~4,200 lines, plus upstream's 15 test classes.
|
||||
|
||||
---
|
||||
|
||||
## Why vendored, and why this version
|
||||
|
||||
`docs/SYNC-PLAN.md` decision 1 settled *vendor rather than depend*. dav4jvm is
|
||||
published on **JitPack only**, which conflicts with our `FAIL_ON_PROJECT_REPOS`
|
||||
policy, does not sign artifacts, and rebuilds on demand — so a coordinate is not
|
||||
immutable. Upstream also shipped two breaking majors nineteen days apart
|
||||
(3.0.0 on 2026-07-08, 4.0.0 on 2026-07-27).
|
||||
|
||||
**2.2.1 is the last OkHttp release.** 3.0.0 deleted the OkHttp package for Ktor,
|
||||
and 4.x additionally requires **Java 21** bytecode while we target 17 across
|
||||
`:app` and all of floret-kit. Taking 4.x would mean Ktor (~2.45 MB), the wrong
|
||||
`guava` flavour, and a Java-target migration — and it would invalidate the whole
|
||||
auth design in `docs/SYNC.md`, which is written in OkHttp terms throughout
|
||||
(preemptive Basic via an `Interceptor`, OkHttp stripping `Authorization` on
|
||||
cross-host redirects, `BasicDigestAuthHandler` because OkHttp has no Digest).
|
||||
|
||||
Vendoring at 2.2.1 costs us upstream's later work and makes us responsible for
|
||||
this tree. What it buys: our own Java target, no JitPack, no Ktor tail, no xpp3
|
||||
in the APK, and the freedom to fix the defects below rather than route around
|
||||
them.
|
||||
|
||||
**This is a plain JVM module, not an Android library.** The upstream tree has
|
||||
zero Android imports and must not gain any: `docs/SYNC.md` earmarks this layer
|
||||
for floret-kit's `core-dav`, and Calendula needs the same primitives, so
|
||||
extraction should stay a file move.
|
||||
|
||||
---
|
||||
|
||||
## Changes from upstream
|
||||
|
||||
Upstream's own test suite is vendored with the code and passes unmodified —
|
||||
that is what makes these changes safe to make. Our additions live in
|
||||
`LocalChangesTest`; every other test file is upstream's, untouched.
|
||||
|
||||
### 1. `commons-lang3` removed
|
||||
|
||||
`HttpUtils.kt` imported `org.apache.commons.lang3.time.DateUtils` for exactly one
|
||||
call — `DateUtils.parseDate(str, locale, patterns…)`, a loop over format strings.
|
||||
Replaced with that loop. The dependency is gone; the format list is byte-for-byte
|
||||
upstream's, comments included.
|
||||
|
||||
⚠️ **The loop is not the whole of what `DateUtils` did.** It parsed with a
|
||||
`ParsePosition` and rejected a pattern unless the *entire* string was consumed;
|
||||
`SimpleDateFormat.parse(String)` accepts a prefix. Pattern 1 ends in the quoted
|
||||
**literal** `'GMT'`, so `"Wed, 21 Oct 2015 07:28:00 GMT+02:00"` matches it as a
|
||||
prefix and the offset is silently discarded — a two-hour error, and precisely the
|
||||
failure change 2 exists to remove. The replacement requires full consumption.
|
||||
|
||||
### 2. ⚠️ HTTP dates were parsed and formatted in the device's local zone
|
||||
|
||||
**An upstream defect, not a porting artefact.** `httpDateFormatStr` is
|
||||
`"EEE, dd MMM yyyy HH:mm:ss 'GMT'"` — the `GMT` is a **quoted literal**, so
|
||||
`SimpleDateFormat` neither reads nor writes a zone from it, and
|
||||
`httpDateFormat` never had `timeZone` set. So `formatDate` emitted local time
|
||||
labelled `GMT`, and `parseDate` read `07:28:00 GMT` as 07:28 *local* — every
|
||||
`getlastmodified` out by the device's UTC offset, in whichever direction the
|
||||
user happens to live.
|
||||
|
||||
Upstream's `HttpUtilsTest` covers only `fileName()` and never touches dates,
|
||||
which is why this survived. Fixed by forcing GMT on the formatter and on every
|
||||
parse attempt; patterns carrying a real `z` still take the zone from the input,
|
||||
as they must. Covered by `LocalChangesTest`.
|
||||
|
||||
### 3. Permanent redirects now reach the caller (`dav4jvm#209`)
|
||||
|
||||
`followRedirects` mutated `location` in place for every 3xx and told the caller
|
||||
nothing, so a caller could not distinguish *"this resource has moved, store the
|
||||
new URL"* from *"follow this once"*. DAVx5 consequently never rewrites a stored
|
||||
collection URL after a 301 and re-follows it on every sync;
|
||||
`docs/SYNC.md` names persisting the new URL ourselves as the fix.
|
||||
|
||||
Added `DavResource.permanentLocation`, set only along an unbroken chain of 301 /
|
||||
308. A temporary hop ends the chain — `301 → 302` means the resource moved to the
|
||||
301's target and is being served elsewhere *for now*, so persisting the 302's
|
||||
target would be wrong. `location` still moves for every redirect, unchanged.
|
||||
|
||||
It is **cleared at the start of every request**, so it describes the request just
|
||||
made and never one made earlier through the same object. `DavResource` instances
|
||||
are reused, and a stale value would have the caller persist a URL that a later
|
||||
`move()` already superseded.
|
||||
|
||||
### 4. `xpp3` is compile-time only
|
||||
|
||||
Upstream declares `org.ogce:xpp3` as `api`. Android ships `org.xmlpull.v1` in the
|
||||
framework, so the 371 KB jar is `compileOnly` here and never reaches the APK. The
|
||||
unit tests run on a plain JVM, which has no framework, so they get the real
|
||||
implementation via `testImplementation`.
|
||||
|
||||
### 5. `httpDateFormat` is no longer a shared mutable formatter
|
||||
|
||||
Upstream exposed a single public `SimpleDateFormat`. It is mutable and not
|
||||
thread-safe: two workers formatting a header concurrently corrupt each other
|
||||
through its `Calendar`, and any caller could `setTimeZone` on it and undo change 2
|
||||
for everyone else. It is now private and built per call. Nothing else in the tree
|
||||
referenced it.
|
||||
|
||||
---
|
||||
|
||||
## Build integration
|
||||
|
||||
- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testDebugUnitTest`,
|
||||
which exists only on Android variants, so `.forgejo/workflows/ci.yaml` names
|
||||
`:dav:test` explicitly. Without that the vendored suite is compiled by nobody
|
||||
and run by nobody, and the safety argument above is void.
|
||||
- `:app` declares `testImplementation(libs.xpp3)`. `compileOnly` is not
|
||||
transitive, `:app`'s unit tests run on a plain JVM with no framework, and
|
||||
`isReturnDefaultValues = true` makes android.jar's stub factory return `null` —
|
||||
so anything touching `XmlUtils` would fail with an unrelated-looking NPE.
|
||||
|
||||
---
|
||||
|
||||
## What was *not* a defect
|
||||
|
||||
`docs/SYNC.md` lists two dav4jvm defects we would inherit. Only one of them
|
||||
exists in this version:
|
||||
|
||||
- **"Handles 301/302/307/308 but not 303"** — does not hold for 2.2.1.
|
||||
`followRedirects` gates on OkHttp's `Response.isRedirect`, which includes
|
||||
`HTTP_SEE_OTHER`, and it re-sends the same method, which is what RFC 6764 §5
|
||||
asks for during discovery. Pinned by a test so a future resync cannot lose it
|
||||
silently.
|
||||
- **`dav4jvm#209`** — real, and fixed above as change 3.
|
||||
|
||||
---
|
||||
|
||||
## Resyncing
|
||||
|
||||
Fetch the new tag, diff against `f434c9d`, reapply changes 1–4, run
|
||||
`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the
|
||||
entire reason it is vendored alongside the code.
|
||||
@@ -0,0 +1,36 @@
|
||||
plugins {
|
||||
// No version: AGP already puts the Kotlin plugin on the build classpath, and
|
||||
// asking for one here fails resolution ("already on the classpath with an
|
||||
// unknown version"). The version is the catalogue's `kotlin`, via AGP.
|
||||
id("org.jetbrains.kotlin.jvm")
|
||||
}
|
||||
|
||||
// A plain JVM library on purpose: the vendored tree has no Android imports and
|
||||
// must not gain any. That keeps it portable — `docs/SYNC.md` earmarks this layer
|
||||
// for floret-kit's `core-dav`, and Calendula needs the same primitives.
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.okhttp)
|
||||
|
||||
// Android supplies org.xmlpull.v1 in the framework, so the 371 KB xpp3 jar is
|
||||
// a compile-time stand-in only and never reaches the APK. The unit tests run
|
||||
// on a plain JVM, which has no framework, so they get the real thing.
|
||||
compileOnly(libs.xpp3)
|
||||
testImplementation(libs.xpp3)
|
||||
|
||||
// Upstream's own suite, vendored with the code. It is JUnit 4 while :app is
|
||||
// JUnit 5; rewriting it would forfeit the regression coverage that makes
|
||||
// vendoring safe, which is the same call provider/PROVENANCE.md made.
|
||||
testImplementation(libs.junit4)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.*
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Handler to manage authentication against a given service (may be limited to one domain).
|
||||
* There's no domain-based cache, because the same user name and password will be used for
|
||||
* all requests.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class BasicDigestAuthHandler(
|
||||
/** Authenticate only against hosts ending with this domain (may be null, which means no restriction) */
|
||||
val domain: String?,
|
||||
|
||||
val username: String,
|
||||
val password: String,
|
||||
|
||||
val insecurePreemptive: Boolean = false
|
||||
): Authenticator, Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val HEADER_AUTHORIZATION = "Authorization"
|
||||
|
||||
// cached digest parameters
|
||||
var clientNonce = h(UUID.randomUUID().toString())
|
||||
var nonceCount = AtomicInteger(1)
|
||||
|
||||
fun quotedString(s: String) = "\"" + s.replace("\"", "\\\"") + "\""
|
||||
fun h(data: String) = data.toByteArray().toByteString().md5().hex()
|
||||
|
||||
fun h(body: RequestBody): String {
|
||||
val buffer = Buffer()
|
||||
body.writeTo(buffer)
|
||||
return buffer.readByteArray().toByteString().md5().hex()
|
||||
}
|
||||
|
||||
fun kd(secret: String, data: String) = h("$secret:$data")
|
||||
}
|
||||
|
||||
// cached authentication schemes
|
||||
private var basicAuth: Challenge? = null
|
||||
private var digestAuth: Challenge? = null
|
||||
|
||||
|
||||
fun authenticateRequest(request: Request, response: Response?): Request? {
|
||||
domain?.let {
|
||||
val host = request.url.host
|
||||
if (!domain.equals(UrlUtils.hostToDomain(host), true)) {
|
||||
Dav4jvm.log.warning("Not authenticating against $host because it doesn't belong to $domain")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
// we're not processing a 401 response
|
||||
|
||||
if (basicAuth == null && digestAuth == null && (request.isHttps || insecurePreemptive)) {
|
||||
Dav4jvm.log.fine("Trying Basic auth preemptively")
|
||||
basicAuth = Challenge("Basic", "")
|
||||
}
|
||||
|
||||
} else {
|
||||
// we're processing a 401 response
|
||||
|
||||
var newBasicAuth: Challenge? = null
|
||||
var newDigestAuth: Challenge? = null
|
||||
for (challenge in response.challenges())
|
||||
when {
|
||||
"Basic".equals(challenge.scheme, true) -> {
|
||||
basicAuth?.let {
|
||||
Dav4jvm.log.warning("Basic credentials didn't work last time -> aborting")
|
||||
basicAuth = null
|
||||
return null
|
||||
}
|
||||
newBasicAuth = challenge
|
||||
}
|
||||
"Digest".equals(challenge.scheme, true) -> {
|
||||
if (digestAuth != null && !"true".equals(challenge.authParams["stale"], true)) {
|
||||
Dav4jvm.log.warning("Digest credentials didn't work last time and server nonce has not expired -> aborting")
|
||||
digestAuth = null
|
||||
return null
|
||||
}
|
||||
newDigestAuth = challenge
|
||||
}
|
||||
}
|
||||
|
||||
basicAuth = newBasicAuth
|
||||
digestAuth = newDigestAuth
|
||||
}
|
||||
|
||||
// we MUST prefer Digest auth [https://tools.ietf.org/html/rfc2617#section-4.6]
|
||||
when {
|
||||
digestAuth != null -> {
|
||||
Dav4jvm.log.fine("Adding Digest authorization request for ${request.url}")
|
||||
return digestRequest(request, digestAuth)
|
||||
}
|
||||
|
||||
basicAuth != null -> {
|
||||
Dav4jvm.log.fine("Adding Basic authorization header for ${request.url}")
|
||||
|
||||
/* In RFC 2617 (obsolete), there was no encoding for credentials defined, although
|
||||
one can interpret it as "use ISO-8859-1 encoding". This has been clarified by RFC 7617,
|
||||
which creates a new charset parameter for WWW-Authenticate, which always must be UTF-8.
|
||||
So, UTF-8 encoding for credentials is compatible with all RFC 7617 servers and many,
|
||||
but not all pre-RFC 7617 servers. */
|
||||
return request.newBuilder()
|
||||
.header(HEADER_AUTHORIZATION, Credentials.basic(username, password, Charsets.UTF_8))
|
||||
.build()
|
||||
}
|
||||
|
||||
response != null ->
|
||||
Dav4jvm.log.warning("No supported authentication scheme")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun digestRequest(request: Request, digest: Challenge?): Request? {
|
||||
if (digest == null)
|
||||
return null
|
||||
|
||||
val realm = digest.authParams["realm"]
|
||||
val opaque = digest.authParams["opaque"]
|
||||
val nonce = digest.authParams["nonce"]
|
||||
|
||||
val algorithm = Algorithm.determine(digest.authParams["algorithm"])
|
||||
val qop = Protection.selectFrom(digest.authParams["qop"])
|
||||
|
||||
// build response parameters
|
||||
var response: String? = null
|
||||
|
||||
val params = LinkedList<String>()
|
||||
params.add("username=${quotedString(username)}")
|
||||
if (realm != null)
|
||||
params.add("realm=${quotedString(realm)}")
|
||||
else {
|
||||
Dav4jvm.log.warning("No realm provided, aborting Digest auth")
|
||||
return null
|
||||
}
|
||||
if (nonce != null)
|
||||
params.add("nonce=${quotedString(nonce)}")
|
||||
else {
|
||||
Dav4jvm.log.warning("No nonce provided, aborting Digest auth")
|
||||
return null
|
||||
}
|
||||
if (opaque != null)
|
||||
params.add("opaque=${quotedString(opaque)}")
|
||||
|
||||
if (algorithm != null)
|
||||
params.add("algorithm=${quotedString(algorithm.algorithm)}")
|
||||
|
||||
val method = request.method
|
||||
val digestURI = request.url.encodedPath
|
||||
params.add("uri=${quotedString(digestURI)}")
|
||||
|
||||
if (qop != null) {
|
||||
params.add("qop=${qop.qop}")
|
||||
params.add("cnonce=${quotedString(clientNonce)}")
|
||||
|
||||
val nc = nonceCount.getAndIncrement()
|
||||
val ncValue = String.format(Locale.ROOT, "%08x", nc)
|
||||
params.add("nc=$ncValue")
|
||||
|
||||
val a1: String? = when (algorithm) {
|
||||
Algorithm.MD5 ->
|
||||
"$username:$realm:$password"
|
||||
Algorithm.MD5_SESSION ->
|
||||
h("$username:$realm:$password") + ":$nonce:$clientNonce"
|
||||
else ->
|
||||
null
|
||||
}
|
||||
Dav4jvm.log.finer("A1=$a1")
|
||||
|
||||
val a2: String? = when (qop) {
|
||||
Protection.Auth ->
|
||||
"$method:$digestURI"
|
||||
Protection.AuthInt -> {
|
||||
try {
|
||||
val body = request.body
|
||||
"$method:$digestURI:" + (if (body != null) h(body) else h(""))
|
||||
} catch(e: IOException) {
|
||||
Dav4jvm.log.warning("Couldn't get entity-body for hash calculation")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Dav4jvm.log.finer("A2=$a2")
|
||||
|
||||
if (a1 != null && a2 != null)
|
||||
response = kd(h(a1), "$nonce:$ncValue:$clientNonce:${qop.qop}:${h(a2)}")
|
||||
|
||||
} else {
|
||||
Dav4jvm.log.finer("Using legacy Digest auth")
|
||||
|
||||
// legacy (backwards compatibility with RFC 2069)
|
||||
if (algorithm == Algorithm.MD5) {
|
||||
val a1 = "$username:$realm:$password"
|
||||
val a2 = "$method:$digestURI"
|
||||
response = kd(h(a1), nonce + ":" + h(a2))
|
||||
}
|
||||
}
|
||||
|
||||
return if (response != null) {
|
||||
params.add("response=" + quotedString(response))
|
||||
request.newBuilder()
|
||||
.header(HEADER_AUTHORIZATION, "Digest " + params.joinToString(", "))
|
||||
.build()
|
||||
} else
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
private enum class Algorithm(
|
||||
val algorithm: String
|
||||
) {
|
||||
MD5("MD5"),
|
||||
MD5_SESSION("MD5-sess");
|
||||
|
||||
companion object {
|
||||
fun determine(paramValue: String?): Algorithm? {
|
||||
return when {
|
||||
paramValue == null || MD5.algorithm.equals(paramValue, true) ->
|
||||
MD5
|
||||
MD5_SESSION.algorithm.equals(paramValue, true) ->
|
||||
MD5_SESSION
|
||||
else -> {
|
||||
Dav4jvm.log.warning("Ignoring unknown hash algorithm: $paramValue")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Protection(
|
||||
val qop: String
|
||||
) { // quality of protection:
|
||||
Auth("auth"), // authentication only
|
||||
AuthInt("auth-int"); // authentication with integrity protection
|
||||
|
||||
companion object {
|
||||
fun selectFrom(paramValue: String?): Protection? {
|
||||
paramValue?.let {
|
||||
var qopAuth = false
|
||||
var qopAuthInt = false
|
||||
for (qop in paramValue.split(","))
|
||||
when (qop) {
|
||||
"auth" -> qopAuth = true
|
||||
"auth-int" -> qopAuthInt = true
|
||||
}
|
||||
|
||||
// prefer auth-int as it provides more protection
|
||||
if (qopAuthInt)
|
||||
return AuthInt
|
||||
else if (qopAuth)
|
||||
return Auth
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun authenticate(route: Route?, response: Response) =
|
||||
authenticateRequest(response.request, response)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
if (request.header(HEADER_AUTHORIZATION) == null) {
|
||||
// try to apply cached authentication
|
||||
val authRequest = authenticateRequest(request, null)
|
||||
if (authRequest != null)
|
||||
request = authRequest
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
/**
|
||||
* Callback for the OPTIONS request.
|
||||
*/
|
||||
fun interface CapabilitiesCallback {
|
||||
fun onCapabilities(davCapabilities: Set<String>, response: okhttp3.Response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for 207 Multi-Status responses.
|
||||
*/
|
||||
fun interface MultiResponseCallback {
|
||||
/**
|
||||
* Called for every `<response>` element in the `<multistatus>` body. For instance,
|
||||
* in response to a `PROPFIND` request, this callback will be called once for every found
|
||||
* member resource.
|
||||
*
|
||||
* @param response the parsed response (including URL)
|
||||
* @param relation relation of the response to the called resource
|
||||
*/
|
||||
fun onResponse(response: Response, relation: Response.HrefRelation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for HTTP responses.
|
||||
*/
|
||||
fun interface ResponseCallback {
|
||||
/**
|
||||
* Called for a HTTP response. Typically this is only called for successful/redirect
|
||||
* responses because HTTP errors throw an exception before this callback is called.
|
||||
*/
|
||||
fun onResponse(response: okhttp3.Response)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import java.util.logging.Logger
|
||||
|
||||
object Dav4jvm {
|
||||
|
||||
var log = Logger.getLogger("dav4jvm")!!
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.AddressData
|
||||
import at.bitfire.dav4jvm.property.GetContentType
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Logger
|
||||
|
||||
class DavAddressBook @JvmOverloads constructor(
|
||||
httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
log: Logger = Dav4jvm.log
|
||||
): DavCollection(httpClient, location, log) {
|
||||
|
||||
companion object {
|
||||
val MIME_JCARD = "application/vcard+json".toMediaType()
|
||||
val MIME_VCARD3_UTF8 = "text/vcard;charset=utf-8".toMediaType()
|
||||
val MIME_VCARD4 = "text/vcard;version=4.0".toMediaType()
|
||||
|
||||
val ADDRESSBOOK_QUERY = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-query")
|
||||
val ADDRESSBOOK_MULTIGET = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-multiget")
|
||||
val FILTER = Property.Name(XmlUtils.NS_CARDDAV, "filter")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 WebDAV error
|
||||
*/
|
||||
fun addressbookQuery(callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT addressbook-query ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?, filter, limit?)>
|
||||
<!ELEMENT filter (prop-filter*)>
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
serializer.insertTag(ADDRESSBOOK_QUERY) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetETag.NAME)
|
||||
}
|
||||
insertTag(FILTER)
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.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 contentType MIME type of requested format; may be "text/vcard" for vCard or
|
||||
* "application/vcard+json" for jCard. *null*: don't request specific representation type
|
||||
* @param version vCard version subtype of the requested format. Should only be specified together with a [contentType] of "text/vcard".
|
||||
* Currently only useful value: "4.0" for vCard 4. *null*: don't request specific version
|
||||
* @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 WebDAV error
|
||||
*/
|
||||
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT addressbook-multiget ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?,
|
||||
DAV:href+)>
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
serializer.insertTag(ADDRESSBOOK_MULTIGET) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetContentType.NAME)
|
||||
insertTag(GetETag.NAME)
|
||||
insertTag(AddressData.NAME) {
|
||||
if (contentType != null)
|
||||
attribute(null, AddressData.CONTENT_TYPE, contentType)
|
||||
if (version != null)
|
||||
attribute(null, AddressData.VERSION, version)
|
||||
}
|
||||
}
|
||||
for (url in urls)
|
||||
insertTag(HREF) {
|
||||
text(url.encodedPath)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "0") // "The request MUST include a Depth: 0 header [...]"
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.CalendarData
|
||||
import at.bitfire.dav4jvm.property.GetContentType
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.ScheduleTag
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.logging.Logger
|
||||
|
||||
class DavCalendar @JvmOverloads constructor(
|
||||
httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
log: Logger = Dav4jvm.log
|
||||
): DavCollection(httpClient, location, log) {
|
||||
|
||||
companion object {
|
||||
val MIME_ICALENDAR = "text/calendar".toMediaType()
|
||||
val MIME_ICALENDAR_UTF8 = "text/calendar;charset=utf-8".toMediaType()
|
||||
|
||||
val CALENDAR_QUERY = Property.Name(XmlUtils.NS_CALDAV, "calendar-query")
|
||||
val CALENDAR_MULTIGET = Property.Name(XmlUtils.NS_CALDAV, "calendar-multiget")
|
||||
|
||||
val FILTER = Property.Name(XmlUtils.NS_CALDAV, "filter")
|
||||
val COMP_FILTER = Property.Name(XmlUtils.NS_CALDAV, "comp-filter")
|
||||
const val COMP_FILTER_NAME = "name"
|
||||
val TIME_RANGE = Property.Name(XmlUtils.NS_CALDAV, "time-range")
|
||||
const val TIME_RANGE_START = "start"
|
||||
const val TIME_RANGE_END = "end"
|
||||
|
||||
private val timeFormatUTC = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
|
||||
init {
|
||||
timeFormatUTC.timeZone = TimeZone.getTimeZone("Etc/UTC")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 WebDAV error
|
||||
*/
|
||||
fun calendarQuery(component: String, start: Date?, end: Date?, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT calendar-query ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?, filter, timezone?)>
|
||||
<!ELEMENT filter (comp-filter)>
|
||||
<!ELEMENT comp-filter (is-not-defined | (time-range?,
|
||||
prop-filter*, comp-filter*))>
|
||||
<!ATTLIST comp-filter name CDATA #REQUIRED>
|
||||
name value: a calendar object or calendar component
|
||||
type (e.g., VEVENT)
|
||||
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
serializer.insertTag(CALENDAR_QUERY) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetETag.NAME)
|
||||
}
|
||||
insertTag(FILTER) {
|
||||
insertTag(COMP_FILTER) {
|
||||
attribute(null, COMP_FILTER_NAME, "VCALENDAR")
|
||||
insertTag(COMP_FILTER) {
|
||||
attribute(null, COMP_FILTER_NAME, component)
|
||||
if (start != null || end != null) {
|
||||
insertTag(TIME_RANGE) {
|
||||
if (start != null)
|
||||
attribute(null, TIME_RANGE_START, timeFormatUTC.format(start))
|
||||
if (end != null)
|
||||
attribute(null, TIME_RANGE_END, timeFormatUTC.format(end))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "1")
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a calendar-multiget REPORT to the resource. Received responses are sent
|
||||
* to the callback, whether they are successful (2xx) or not.
|
||||
*
|
||||
* @param urls list of iCalendar URLs to be requested
|
||||
* @param contentType MIME type of requested format; may be "text/calendar" for iCalendar or
|
||||
* "application/calendar+json" for jCard. *null*: don't request specific representation type
|
||||
* @param version Version subtype of the requested format, like "2.0" for iCalendar 2. *null*: don't request specific version
|
||||
* @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 WebDAV error
|
||||
*/
|
||||
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT calendar-multiget ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?, DAV:href+)>
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
serializer.insertTag(CALENDAR_MULTIGET) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetContentType.NAME) // to determine the character set
|
||||
insertTag(GetETag.NAME)
|
||||
insertTag(ScheduleTag.NAME)
|
||||
insertTag(CalendarData.NAME) {
|
||||
if (contentType != null)
|
||||
attribute(null, CalendarData.CONTENT_TYPE, contentType)
|
||||
if (version != null)
|
||||
attribute(null, CalendarData.VERSION, version)
|
||||
}
|
||||
}
|
||||
for (url in urls)
|
||||
insertTag(HREF) {
|
||||
serializer.text(url.encodedPath)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Represents a WebDAV collection.
|
||||
*/
|
||||
open class DavCollection @JvmOverloads constructor(
|
||||
httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
log: Logger = Dav4jvm.log
|
||||
): DavResource(httpClient, location, log) {
|
||||
|
||||
companion object {
|
||||
val SYNC_COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "sync-collection")
|
||||
val SYNC_LEVEL = Property.Name(XmlUtils.NS_WEBDAV, "sync-level")
|
||||
val LIMIT = Property.Name(XmlUtils.NS_WEBDAV, "limit")
|
||||
val NRESULTS = Property.Name(XmlUtils.NS_WEBDAV, "nresults")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST request. Primarily intended to be used with an Add-Member URL (RFC 5995).
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun post(body: RequestBody, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val builder = Request.Builder()
|
||||
.post(body)
|
||||
.url(location)
|
||||
|
||||
if (ifNoneMatch)
|
||||
// don't overwrite anything existing
|
||||
builder.header("If-None-Match", "*")
|
||||
|
||||
httpClient.newCall(builder.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 WebDAV error
|
||||
*/
|
||||
fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
|
||||
|
||||
<!ELEMENT sync-token CDATA> <!-- Text MUST be a URI -->
|
||||
<!ELEMENT sync-level CDATA> <!-- Text MUST be either "1" or "infinite" -->
|
||||
|
||||
<!ELEMENT limit (nresults) >
|
||||
<!ELEMENT nresults (#PCDATA)> <!-- only digits -->
|
||||
|
||||
<!-- DAV:prop defined in RFC 4918, Section 14.18 -->
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.insertTag(SYNC_COLLECTION) {
|
||||
insertTag(SyncToken.NAME) {
|
||||
if (syncToken != null)
|
||||
text(syncToken)
|
||||
}
|
||||
insertTag(SYNC_LEVEL) {
|
||||
text(if (infiniteDepth) "infinite" else "1")
|
||||
}
|
||||
if (limit != null)
|
||||
insertTag(LIMIT) {
|
||||
insertTag(NRESULTS) {
|
||||
text(limit.toString())
|
||||
}
|
||||
}
|
||||
insertTag(PROP) {
|
||||
for (prop in properties)
|
||||
insertTag(prop)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "0")
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import at.bitfire.dav4jvm.exception.*
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.Reader
|
||||
import java.io.StringWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import at.bitfire.dav4jvm.Response as DavResponse
|
||||
|
||||
/**
|
||||
* Represents a WebDAV resource at the given location and allows WebDAV
|
||||
* requests to be performed on this resource.
|
||||
*
|
||||
* 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.*
|
||||
*
|
||||
* To cancel a request, interrupt the thread. This will cause the requests to
|
||||
* throw `InterruptedException` or `InterruptedIOException`.
|
||||
*
|
||||
* @param httpClient [OkHttpClient] to access this object (must not follow redirects)
|
||||
* @param location location of the WebDAV resource
|
||||
* @param log will be used for logging
|
||||
*/
|
||||
open class DavResource @JvmOverloads constructor(
|
||||
val httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
val log: Logger = Dav4jvm.log
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val MAX_REDIRECTS = 5
|
||||
|
||||
const val HTTP_MULTISTATUS = 207
|
||||
|
||||
/** 301 and 308 — the two redirects a caller should persist. */
|
||||
const val HTTP_MOVED_PERM = 301
|
||||
const val HTTP_PERM_REDIRECT = 308
|
||||
val MIME_XML = "application/xml; charset=utf-8".toMediaType()
|
||||
|
||||
val PROPFIND = Property.Name(XmlUtils.NS_WEBDAV, "propfind")
|
||||
val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate")
|
||||
val SET = Property.Name(XmlUtils.NS_WEBDAV, "set")
|
||||
val REMOVE = Property.Name(XmlUtils.NS_WEBDAV, "remove")
|
||||
val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop")
|
||||
val HREF = Property.Name(XmlUtils.NS_WEBDAV, "href")
|
||||
|
||||
val XML_SIGNATURE = "<?xml".toByteArray()
|
||||
|
||||
|
||||
/**
|
||||
* Creates a request body for the PROPPATCH request.
|
||||
*/
|
||||
internal fun createProppatchXml(
|
||||
setProperties: Map<Property.Name, String>,
|
||||
removeProperties: List<Property.Name>
|
||||
): String {
|
||||
// build XML request body
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.setPrefix("d", XmlUtils.NS_WEBDAV)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.insertTag(PROPERTYUPDATE) {
|
||||
// DAV:set
|
||||
if (setProperties.isNotEmpty()) {
|
||||
serializer.insertTag(SET) {
|
||||
for (prop in setProperties) {
|
||||
serializer.insertTag(PROP) {
|
||||
serializer.insertTag(prop.key) {
|
||||
text(prop.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DAV:remove
|
||||
if (removeProperties.isNotEmpty()) {
|
||||
serializer.insertTag(REMOVE) {
|
||||
for (prop in removeProperties) {
|
||||
insertTag(PROP) {
|
||||
insertTag(prop)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serializer.endDocument()
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* URL of this resource (changes when being redirected by server)
|
||||
*/
|
||||
/**
|
||||
* The location reached through an unbroken chain of **permanent** redirects
|
||||
* (301, 308), or null if none was followed.
|
||||
*
|
||||
* Upstream mutates [location] in place for every redirect and tells the
|
||||
* caller nothing, so a caller cannot distinguish "this resource has moved,
|
||||
* store the new URL" from "follow this once". DAVx5 consequently never
|
||||
* rewrites a stored collection URL after a 301 and re-follows it on every
|
||||
* single sync — bitfireAT/dav4jvm#209, and `docs/SYNC.md` names persisting
|
||||
* the new URL ourselves as the fix. Local addition; see dav/PROVENANCE.md
|
||||
* change 3.
|
||||
*/
|
||||
var permanentLocation: HttpUrl? = null
|
||||
private set
|
||||
|
||||
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 ourselves.
|
||||
require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" }
|
||||
|
||||
this.location = location
|
||||
}
|
||||
|
||||
override fun toString() = location.toString()
|
||||
|
||||
|
||||
/**
|
||||
* Gets the file name of this resource. See [HttpUtils.fileName] for details.
|
||||
*/
|
||||
fun fileName() = HttpUtils.fileName(location)
|
||||
|
||||
|
||||
/**
|
||||
* Sends an OPTIONS request to this resource without HTTP compression (because some servers have
|
||||
* broken compression for OPTIONS). Doesn't follow redirects.
|
||||
*
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun options(callback: CapabilitiesCallback) {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.method("OPTIONS", null)
|
||||
.header("Content-Length", "0")
|
||||
.url(location)
|
||||
.header("Accept-Encoding", "identity") // disable compression
|
||||
.build()).execute().use { response ->
|
||||
checkStatus(response)
|
||||
callback.onCapabilities(
|
||||
HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(),
|
||||
response
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MOVE request to this resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
* Updates [location] on success.
|
||||
*
|
||||
* @param destination where the resource shall be moved to
|
||||
* @param forceOverride whether resources are overwritten when they already exist in destination
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun move(destination: HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
|
||||
val requestBuilder = Request.Builder()
|
||||
.method("MOVE", null)
|
||||
.header("Content-Length", "0")
|
||||
.header("Destination", destination.toString())
|
||||
|
||||
if (forceOverride) requestBuilder.header("Overwrite", "F")
|
||||
|
||||
followRedirects {
|
||||
requestBuilder.url(location)
|
||||
httpClient.newCall(requestBuilder
|
||||
.build())
|
||||
.execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
if (response.code == HTTP_MULTISTATUS)
|
||||
/* Multiple resources were to be affected by the MOVE, but errors on some
|
||||
of them prevented the operation from taking place.
|
||||
[_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */
|
||||
throw HttpException(response)
|
||||
|
||||
// update location
|
||||
location.resolve(response.header("Location") ?: destination.toString())?.let {
|
||||
location = it
|
||||
}
|
||||
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a COPY request for this resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param destination where the resource shall be copied to
|
||||
* @param forceOverride whether resources are overwritten when they already exist in destination
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun copy(destination:HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
|
||||
val requestBuilder = Request.Builder()
|
||||
.method("COPY", null)
|
||||
.header("Content-Length", "0")
|
||||
.header("Destination", destination.toString())
|
||||
|
||||
if (forceOverride) requestBuilder.header("Overwrite", "F")
|
||||
|
||||
followRedirects {
|
||||
requestBuilder.url(location)
|
||||
httpClient.newCall(requestBuilder
|
||||
.build())
|
||||
.execute()
|
||||
}.use{ response ->
|
||||
checkStatus(response)
|
||||
|
||||
if (response.code == HTTP_MULTISTATUS)
|
||||
/* Multiple resources were to be affected by the COPY, but errors on some
|
||||
of them prevented the operation from taking place.
|
||||
[_] (RFC 4918 9.8.5. Status Codes for COPY Method) */
|
||||
throw HttpException(response)
|
||||
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MKCOL request to this resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun mkCol(xmlBody: String?, callback: ResponseCallback) {
|
||||
val rqBody = xmlBody?.toRequestBody(MIME_XML)
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.method("MKCOL", rqBody)
|
||||
.url(location)
|
||||
.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a HEAD request to the resource.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun head(callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
httpClient.newCall(
|
||||
Request.Builder()
|
||||
.head()
|
||||
.url(location)
|
||||
.build()
|
||||
).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET request to the resource. Sends `Accept-Encoding: identity` to disable
|
||||
* compression, because compression might change the ETag.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param accept value of `Accept` header (always sent for clarity; use */* if you don't care)
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Deprecated("Use get(accept, headers, callback) with explicit Accept-Encoding instead")
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun get(accept: String, callback: ResponseCallback) =
|
||||
get(accept, Headers.headersOf("Accept-Encoding", "identity"), callback)
|
||||
|
||||
/**
|
||||
* Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* Note: Add `Accept-Encoding: identity` to [headers] if you want to disable compression
|
||||
* (compression might change the returned ETag).
|
||||
*
|
||||
* @param accept value of `Accept` header (always sent for clarity; use */* if you don't care)
|
||||
* @param headers additional headers to send with the request
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun get(accept: String, headers: Headers?, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(location)
|
||||
|
||||
if (headers != null)
|
||||
request.headers(headers)
|
||||
|
||||
// always Accept header
|
||||
request.header("Accept", accept)
|
||||
|
||||
httpClient.newCall(request.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET request to the resource for a specific byte range. Make sure to check the
|
||||
* response code: servers may return the whole resource with 200 or partials with 206.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param accept value of `Accept` header (always sent for clarity; use */* if you don't care)
|
||||
* @param offset zero-based index of first byte to request
|
||||
* @param size number of bytes to request
|
||||
* @param headers additional headers to send with the request
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on high-level errors
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(location)
|
||||
|
||||
if (headers != null)
|
||||
request.headers(headers)
|
||||
|
||||
val lastIndex = offset + size - 1
|
||||
request
|
||||
.header("Accept", accept)
|
||||
.header("Range", "bytes=$offset-$lastIndex")
|
||||
|
||||
httpClient.newCall(request.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PUT request to the resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* When the server returns an ETag, it is stored in response properties.
|
||||
*
|
||||
* @param body new resource body to upload
|
||||
* @param ifETag value of `If-Match` header to set, or null to omit
|
||||
* @param ifScheduleTag value of `If-Schedule-Tag-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 DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun put(body: RequestBody, ifETag: String? = null, ifScheduleTag: String? = null, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val builder = Request.Builder()
|
||||
.put(body)
|
||||
.url(location)
|
||||
|
||||
if (ifETag != null)
|
||||
// only overwrite specific version
|
||||
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
|
||||
if (ifScheduleTag != null)
|
||||
// only overwrite specific version
|
||||
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
|
||||
if (ifNoneMatch)
|
||||
// don't overwrite anything existing
|
||||
builder.header("If-None-Match", "*")
|
||||
|
||||
httpClient.newCall(builder.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DELETE request to the resource. Warning: Sending this request to a collection will
|
||||
* delete the collection with all its contents!
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param ifETag value of `If-Match` header to set, or null to omit
|
||||
* @param ifScheduleTag value of `If-Schedule-Tag-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 DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun delete(ifETag: String? = null, ifScheduleTag: String? = null, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val builder = Request.Builder()
|
||||
.delete()
|
||||
.url(location)
|
||||
if (ifETag != null)
|
||||
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
|
||||
if (ifScheduleTag != null)
|
||||
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
|
||||
|
||||
httpClient.newCall(builder.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
|
||||
if (response.code == HTTP_MULTISTATUS)
|
||||
/* 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)
|
||||
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PROPFIND request to the resource. Expects and processes a 207 Multi-Status response.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param depth "Depth" header to send (-1 for `infinity`)
|
||||
* @param reqProp properties to request
|
||||
* @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) or HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) {
|
||||
// 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.insertTag(PROPFIND) {
|
||||
insertTag(PROP) {
|
||||
for (prop in reqProp)
|
||||
insertTag(prop)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("PROPFIND", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", if (depth >= 0) depth.toString() else "infinity")
|
||||
.build()).execute()
|
||||
}.use {
|
||||
processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PROPPATCH request to the server in order to set and remove properties.
|
||||
*
|
||||
* @param setProperties map of properties that shall be set (values currently have to be strings)
|
||||
* @param removeProperties list of names of properties that shall be removed
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* Currently expects a 207 Multi-Status response although servers are allowed to
|
||||
* return other values, too.
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun proppatch(
|
||||
setProperties: Map<Property.Name, String>,
|
||||
removeProperties: List<Property.Name>,
|
||||
callback: MultiResponseCallback
|
||||
) {
|
||||
followRedirects {
|
||||
val rqBody = createProppatchXml(setProperties, removeProperties)
|
||||
|
||||
httpClient.newCall(
|
||||
Request.Builder()
|
||||
.url(location)
|
||||
.method("PROPPATCH", rqBody.toRequestBody(MIME_XML))
|
||||
.build()
|
||||
).execute()
|
||||
}.use {
|
||||
// TODO handle not only 207 Multi-Status
|
||||
// http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status
|
||||
|
||||
processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SEARCH request (RFC 5323) with the given body to the server.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects. Expects a 207 Multi-Status response.
|
||||
*
|
||||
* @param search search request body (XML format, DAV:searchrequest or DAV:query-schema-discovery)
|
||||
* @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) or HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun search(search: String, callback: MultiResponseCallback) {
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("SEARCH", search.toRequestBody(MIME_XML))
|
||||
.build()).execute()
|
||||
}.use {
|
||||
processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// status handling
|
||||
|
||||
/**
|
||||
* Checks the status from an HTTP response and throws an exception in case of an error.
|
||||
*
|
||||
* @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 response and throws an exception in case of an error.
|
||||
*
|
||||
* @throws HttpException (with XML error names, if available) in case of an HTTP error
|
||||
*/
|
||||
private 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_FORBIDDEN ->
|
||||
if (response != null) ForbiddenException(response) else ForbiddenException(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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
internal fun followRedirects(sendRequest: () -> Response): Response {
|
||||
lateinit var response: Response
|
||||
// Describes the request being made now, not one made earlier through the
|
||||
// same object. DavResource instances are reused, and a stale value would
|
||||
// have the caller persist a URL that a later move() already superseded.
|
||||
permanentLocation = null
|
||||
// A permanent target is only permanent while every hop so far has been
|
||||
// permanent: 301 -> 302 means the resource moved to the 301's target and
|
||||
// is being served from elsewhere temporarily.
|
||||
var chainStillPermanent = true
|
||||
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")
|
||||
|
||||
if (location.isHttps && !target.isHttps)
|
||||
throw DavException("Received redirect from HTTPS to HTTP")
|
||||
|
||||
if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT))
|
||||
permanentLocation = target
|
||||
else
|
||||
chainStillPermanent = false
|
||||
|
||||
location = target
|
||||
} else
|
||||
throw DavException("Redirected without new Location")
|
||||
}
|
||||
else
|
||||
break
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a 207 Multi-Status response.
|
||||
*
|
||||
* @param response will be checked for Multi-Status response
|
||||
*
|
||||
* @throws DavException if the response is not a Multi-Status response
|
||||
*/
|
||||
fun assertMultiStatus(response: Response) {
|
||||
if (response.code != HTTP_MULTISTATUS)
|
||||
throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response)
|
||||
|
||||
val body = response.body ?:
|
||||
throw DavException("Received 207 Multi-Status without body", httpResponse = response)
|
||||
|
||||
body.contentType()?.let { mimeType ->
|
||||
if (((mimeType.type != "application" && mimeType.type != "text")) || mimeType.subtype != "xml") {
|
||||
/* Content-Type is not application/xml or text/xml although that is expected here.
|
||||
Some broken servers return an XML response with some other MIME type. So we try to see
|
||||
whether the response is maybe XML although the Content-Type is something else. */
|
||||
try {
|
||||
val firstBytes = ByteArray(XML_SIGNATURE.size)
|
||||
body.source().peek().readFully(firstBytes)
|
||||
if (XML_SIGNATURE.contentEquals(firstBytes)) {
|
||||
Dav4jvm.log.warning("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType")
|
||||
|
||||
// response is OK, return and do not throw Exception below
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't scan for XML signature", e)
|
||||
}
|
||||
|
||||
throw DavException("Received non-XML 207 Multi-Status", httpResponse = response)
|
||||
}
|
||||
} ?: log.warning("Received 207 Multi-Status without Content-Type, assuming XML")
|
||||
}
|
||||
|
||||
|
||||
// Multi-Status handling
|
||||
|
||||
/**
|
||||
* Processes a Multi-Status response.
|
||||
*
|
||||
* @param response response which is expected to contain a Multi-Status response
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* @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 IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (for instance, when the response is not a Multi-Status response)
|
||||
*/
|
||||
protected fun processMultiStatus(response: Response, callback: MultiResponseCallback): List<Property> {
|
||||
checkStatus(response)
|
||||
assertMultiStatus(response)
|
||||
response.body!!.use {
|
||||
return processMultiStatus(it.charStream(), callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a Multi-Status response.
|
||||
*
|
||||
* @param reader the Multi-Status response is read from this
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* @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 IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (like an invalid XML response)
|
||||
*/
|
||||
protected fun processMultiStatus(reader: Reader, callback: MultiResponseCallback): List<Property> {
|
||||
val responseProperties = mutableListOf<Property>()
|
||||
val parser = XmlUtils.newPullParser()
|
||||
|
||||
fun parseMultiStatus(): List<Property> {
|
||||
// <!ELEMENT multistatus (response*, responsedescription?,
|
||||
// sync-token?) >
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
when (parser.propertyName()) {
|
||||
DavResponse.RESPONSE ->
|
||||
at.bitfire.dav4jvm.Response.parse(parser, location, callback)
|
||||
SyncToken.NAME ->
|
||||
XmlUtils.readText(parser)?.let {
|
||||
responseProperties += SyncToken(it)
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return responseProperties
|
||||
}
|
||||
|
||||
try {
|
||||
parser.setInput(reader)
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
|
||||
if (parser.propertyName() == DavResponse.MULTISTATUS)
|
||||
return parseMultiStatus()
|
||||
// ignore further <multistatus> elements
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
throw DavException("Multi-Status response didn't contain multistatus XML element")
|
||||
|
||||
} catch (e: EOFException) {
|
||||
throw DavException("Incomplete multistatus XML element", e)
|
||||
} catch (e: XmlPullParserException) {
|
||||
throw DavException("Couldn't parse multistatus XML element", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © 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 at.bitfire.dav4jvm
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Represents an XML precondition/postcondition error. Every error has a name, which is the XML element
|
||||
* name. Subclassed errors may have more specific information available.
|
||||
*
|
||||
* At the moment, there is no logic for subclassing errors.
|
||||
*/
|
||||
class Error(
|
||||
val name: Property.Name
|
||||
): Serializable {
|
||||
|
||||
companion object {
|
||||
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "error")
|
||||
|
||||
fun parseError(parser: XmlPullParser): List<Error> {
|
||||
val names = mutableSetOf<Property.Name>()
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
names += Property.Name(parser.namespace, parser.name)
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return names.map { Error(it) }
|
||||
}
|
||||
|
||||
|
||||
// some pre-defined errors
|
||||
|
||||
val NEED_PRIVILEGES = Error(Property.Name(XmlUtils.NS_WEBDAV, "need-privileges"))
|
||||
val VALID_SYNC_TOKEN = Error(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token"))
|
||||
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
(other is Error) && other.name == name
|
||||
|
||||
override fun hashCode() = name.hashCode()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Response
|
||||
import java.text.ParsePosition
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
object HttpUtils {
|
||||
|
||||
const val httpDateFormatStr = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
|
||||
|
||||
/**
|
||||
* RFC 7231 §7.1.1.1: an HTTP-date is always GMT.
|
||||
*
|
||||
* ⚠️ Upstream never set this, and the `GMT` in [httpDateFormatStr] is a
|
||||
* quoted *literal* — so both formatting and parsing silently used the
|
||||
* device's default zone, putting every `getlastmodified` out by the local
|
||||
* UTC offset. Upstream's suite does not test date handling at all. Local
|
||||
* fix; see dav/PROVENANCE.md change 2.
|
||||
*/
|
||||
private val GMT: TimeZone = TimeZone.getTimeZone("GMT")
|
||||
|
||||
/**
|
||||
* Built per call rather than shared. `SimpleDateFormat` is mutable and not
|
||||
* thread-safe, and upstream exposed one instance publicly: two sync workers
|
||||
* formatting a header concurrently corrupt each other through its `Calendar`,
|
||||
* and any caller could `setTimeZone` on it and undo the fix above for
|
||||
* everyone. `:dav` is earmarked for floret-kit, where concurrent workers are
|
||||
* the normal case. Local change; see dav/PROVENANCE.md change 5.
|
||||
*/
|
||||
private fun httpDateFormat() = SimpleDateFormat(httpDateFormatStr, Locale.ROOT).apply {
|
||||
timeZone = GMT
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the resource name (the last segment of the path) from an URL.
|
||||
* Empty if the resource is the base directory.
|
||||
*
|
||||
* * `dir` for `https://example.com/dir/`
|
||||
* * `file` for `https://example.com/file`
|
||||
* * `` for `https://example.com` or `https://example.com/`
|
||||
*
|
||||
* @return resource name
|
||||
*/
|
||||
fun fileName(url: HttpUrl): String {
|
||||
val pathSegments = url.pathSegments.dropLastWhile { it == "" }
|
||||
return pathSegments.lastOrNull() ?: ""
|
||||
}
|
||||
|
||||
fun listHeader(response: Response, name: String): Array<String> {
|
||||
val value = response.headers(name).joinToString(",")
|
||||
return value.split(',').filter { it.isNotEmpty() }.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats a date for use in HTTP headers.
|
||||
*
|
||||
* @param date date to be formatted
|
||||
* @return date in HTTP-date format
|
||||
*/
|
||||
fun formatDate(date: Date): String = httpDateFormat().format(date)
|
||||
|
||||
/**
|
||||
* Parses a HTTP-date.
|
||||
*
|
||||
* @param dateStr date with format specified by RFC 7231 section 7.1.1.1
|
||||
* or in one of the obsolete formats (copied from okhttp internal date-parsing class)
|
||||
*
|
||||
* @return date, or null if date could not be parsed
|
||||
*/
|
||||
/**
|
||||
* The formats [parseDate] accepts, in order. RFC 7231 §7.1.1.1 first, then the
|
||||
* obsolete ones, then the malformed-but-real ones — copied from OkHttp's
|
||||
* internal date parser, which is where upstream took them from.
|
||||
*/
|
||||
private val dateFormats = arrayOf(
|
||||
httpDateFormatStr,
|
||||
"EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 822, updated by RFC 1123 with any TZ
|
||||
"EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850, obsoleted by RFC 1036 with any TZ
|
||||
"EEE MMM d HH:mm:ss yyyy", // ANSI C's asctime() format
|
||||
// Alternative formats.
|
||||
"EEE, dd-MMM-yyyy HH:mm:ss z",
|
||||
"EEE, dd-MMM-yyyy HH-mm-ss z",
|
||||
"EEE, dd MMM yy HH:mm:ss z",
|
||||
"EEE dd-MMM-yyyy HH:mm:ss z",
|
||||
"EEE dd MMM yyyy HH:mm:ss z",
|
||||
"EEE dd-MMM-yyyy HH-mm-ss z",
|
||||
"EEE dd-MMM-yy HH:mm:ss z",
|
||||
"EEE dd MMM yy HH:mm:ss z",
|
||||
"EEE,dd-MMM-yy HH:mm:ss z",
|
||||
"EEE,dd-MMM-yyyy HH:mm:ss z",
|
||||
"EEE, dd-MM-yyyy HH:mm:ss z",
|
||||
/* RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com */
|
||||
"EEE MMM d yyyy HH:mm:ss z",
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses an HTTP-date.
|
||||
*
|
||||
* @param dateStr date in the format of RFC 7231 §7.1.1.1, or one of the
|
||||
* obsolete formats
|
||||
*
|
||||
* @return the date, or null if none of the formats matched
|
||||
*/
|
||||
fun parseDate(dateStr: String): Date? {
|
||||
// Upstream used commons-lang3 DateUtils.parseDate for exactly this loop,
|
||||
// and that import was the library's only use of the dependency. See
|
||||
// dav/PROVENANCE.md change 1.
|
||||
for (format in dateFormats) {
|
||||
val parser = SimpleDateFormat(format, Locale.US).apply {
|
||||
// GMT is the default for the formats that carry no zone — the
|
||||
// quoted-literal 'GMT' one and ANSI C's asctime(). The patterns
|
||||
// with a real `z` still take the zone from the input.
|
||||
timeZone = GMT
|
||||
// The obsolete formats carry two-digit years, and lenient parsing
|
||||
// is what lets "dd MMM yy" resolve at all.
|
||||
isLenient = true
|
||||
}
|
||||
val position = ParsePosition(0)
|
||||
val parsed = parser.parse(dateStr, position)
|
||||
// ⚠️ The whole string must be consumed. `DateUtils.parseDate` enforced
|
||||
// this and `SimpleDateFormat.parse` does not: pattern 1 ends in the
|
||||
// *literal* 'GMT', so "…07:28:00 GMT+02:00" matches it as a prefix and
|
||||
// the offset is silently discarded — reintroducing the exact two-hour
|
||||
// error change 2 exists to fix.
|
||||
if (parsed != null && position.index == dateStr.length) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright © 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 at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.Response.Companion.STATUS
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.net.ProtocolException
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Represents a WebDAV propstat XML element.
|
||||
*
|
||||
* <!ELEMENT propstat (prop, status, error?, responsedescription?) >
|
||||
*/
|
||||
data class PropStat(
|
||||
val properties: List<Property>,
|
||||
val status: StatusLine,
|
||||
val error: List<Error>? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "propstat")
|
||||
|
||||
private val ASSUMING_OK = StatusLine(Protocol.HTTP_1_1, 200, "Assuming OK")
|
||||
private val INVALID_STATUS = StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
|
||||
|
||||
fun parse(parser: XmlPullParser): PropStat {
|
||||
val depth = parser.depth
|
||||
|
||||
var status: StatusLine? = null
|
||||
val prop = LinkedList<Property>()
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
when (parser.propertyName()) {
|
||||
DavResource.PROP ->
|
||||
prop.addAll(Property.parse(parser))
|
||||
STATUS ->
|
||||
status = try {
|
||||
StatusLine.parse(parser.nextText())
|
||||
} catch (e: ProtocolException) {
|
||||
// invalid status line, treat as 500 Internal Server Error
|
||||
INVALID_STATUS
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return PropStat(prop, status ?: ASSUMING_OK)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun isSuccess() = status.code/100 == 2
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm.log
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.Serializable
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Represents a WebDAV property.
|
||||
*
|
||||
* Every [Property] must define a static field (use `@JvmStatic`) called `NAME` of type [Property.Name],
|
||||
* which will be accessed by reflection.
|
||||
*/
|
||||
interface Property {
|
||||
|
||||
data class Name(
|
||||
val namespace: String,
|
||||
val name: String
|
||||
): Serializable {
|
||||
|
||||
override fun toString() = "$namespace:$name"
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun parse(parser: XmlPullParser): List<Property> {
|
||||
// <!ELEMENT prop ANY >
|
||||
val depth = parser.depth
|
||||
val properties = LinkedList<Property>()
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
|
||||
val depthBeforeParsing = parser.depth
|
||||
val name = Property.Name(parser.namespace, parser.name)
|
||||
val property = PropertyRegistry.create(name, parser)
|
||||
assert(parser.depth == depthBeforeParsing)
|
||||
|
||||
if (property != null) {
|
||||
properties.add(property)
|
||||
} else
|
||||
log.fine("Ignoring unknown property $name")
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
|
||||
interface PropertyFactory {
|
||||
|
||||
/**
|
||||
* Name of the Property the factory creates,
|
||||
* e.g. Property.Name("DAV:", "displayname") if the factory creates DisplayName objects)
|
||||
*/
|
||||
fun getName(): Property.Name
|
||||
|
||||
/**
|
||||
* Parses XML of a property and returns its data class.
|
||||
* @throws XmlPullParserException in case of parsing errors
|
||||
*/
|
||||
fun create(parser: XmlPullParser): Property?
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.util.logging.Level
|
||||
|
||||
object PropertyRegistry {
|
||||
|
||||
private val factories = mutableMapOf<Property.Name, PropertyFactory>()
|
||||
|
||||
init {
|
||||
Dav4jvm.log.info("Registering DAV property factories")
|
||||
registerDefaultFactories()
|
||||
}
|
||||
|
||||
private fun registerDefaultFactories() {
|
||||
register(listOf(
|
||||
AddMember.Factory,
|
||||
AddressbookDescription.Factory,
|
||||
AddressbookHomeSet.Factory,
|
||||
AddressData.Factory,
|
||||
CalendarColor.Factory,
|
||||
CalendarData.Factory,
|
||||
CalendarDescription.Factory,
|
||||
CalendarHomeSet.Factory,
|
||||
CalendarProxyReadFor.Factory,
|
||||
CalendarProxyWriteFor.Factory,
|
||||
CalendarTimezone.Factory,
|
||||
CalendarUserAddressSet.Factory,
|
||||
CreationDate.Factory,
|
||||
CurrentUserPrincipal.Factory,
|
||||
CurrentUserPrivilegeSet.Factory,
|
||||
DisplayName.Factory,
|
||||
GetContentLength.Factory,
|
||||
GetContentType.Factory,
|
||||
GetCTag.Factory,
|
||||
GetETag.Factory,
|
||||
GetLastModified.Factory,
|
||||
GroupMembership.Factory,
|
||||
MaxICalendarSize.Factory,
|
||||
MaxVCardSize.Factory,
|
||||
Owner.Factory,
|
||||
QuotaAvailableBytes.Factory,
|
||||
QuotaUsedBytes.Factory,
|
||||
ResourceType.Factory,
|
||||
ScheduleTag.Factory,
|
||||
Source.Factory,
|
||||
SupportedAddressData.Factory,
|
||||
SupportedCalendarComponentSet.Factory,
|
||||
SupportedCalendarData.Factory,
|
||||
SupportedReportSet.Factory,
|
||||
SyncToken.Factory
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers a property factory, so that objects for all WebDAV properties which are handled
|
||||
* by this factory can be created.
|
||||
*
|
||||
* @param factory property factory to be registered
|
||||
*/
|
||||
fun register(factory: PropertyFactory) {
|
||||
Dav4jvm.log.fine("Registering ${factory::class.java.name} for ${factory.getName()}")
|
||||
factories[factory.getName()] = factory
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers some property factories, so that objects for all WebDAV properties which are handled
|
||||
* by these factories can be created.
|
||||
|
||||
* @param factories property factories to be registered
|
||||
*/
|
||||
fun register(factories: Iterable<PropertyFactory>) {
|
||||
factories.forEach {
|
||||
register(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(name: Property.Name, parser: XmlPullParser) =
|
||||
try {
|
||||
factories[name]?.create(parser)
|
||||
} catch (e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $name", e)
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
object QuotedStringUtils {
|
||||
|
||||
fun asQuotedString(raw: String) =
|
||||
"\"" + raw.replace("\\" ,"\\\\").replace("\"", "\\\"") + "\""
|
||||
|
||||
fun decodeQuotedString(quoted: String): String {
|
||||
/* quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
|
||||
qdtext = <any TEXT except <">>
|
||||
quoted-pair = "\" CHAR
|
||||
*/
|
||||
|
||||
val len = quoted.length
|
||||
if (len >= 2 && quoted[0] == '"' && quoted[len-1] == '"') {
|
||||
val result = StringBuffer(len)
|
||||
var pos = 1
|
||||
while (pos < len-1) {
|
||||
var c = quoted[pos]
|
||||
if (c == '\\' && pos != len-2)
|
||||
c = quoted[++pos]
|
||||
result.append(c)
|
||||
pos++
|
||||
}
|
||||
return result.toString()
|
||||
} else
|
||||
return quoted
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright © 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 at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm.log
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.net.ProtocolException
|
||||
|
||||
/**
|
||||
* Represents a WebDAV response XML Element.
|
||||
*
|
||||
* <!ELEMENT response (href, ((href*, status)|(propstat+)),
|
||||
* error?, responsedescription? , location?) >
|
||||
*/
|
||||
data class Response(
|
||||
/**
|
||||
* URL of the requested resource. For instance, if `this` is a result
|
||||
* of a PROPFIND request, the `requestedUrl` would be the URL where the
|
||||
* PROPFIND request has been sent to (usually the collection URL).
|
||||
*/
|
||||
val requestedUrl: HttpUrl,
|
||||
|
||||
/**
|
||||
* URL of this response (`href` element)
|
||||
*/
|
||||
val href: HttpUrl,
|
||||
|
||||
/**
|
||||
* status of this response (`status` XML element)
|
||||
*/
|
||||
val status: StatusLine?,
|
||||
|
||||
/**
|
||||
* property/status elements (`propstat` XML elements)
|
||||
*/
|
||||
val propstat: List<PropStat>,
|
||||
|
||||
/**
|
||||
* list of precondition/postcondition elements (`error` XML elements)
|
||||
*/
|
||||
val error: List<Error>? = null,
|
||||
|
||||
/**
|
||||
* new location of this response (`location` XML element), used for redirects
|
||||
*/
|
||||
val newLocation: HttpUrl? = null
|
||||
) {
|
||||
|
||||
enum class HrefRelation {
|
||||
SELF, MEMBER, OTHER
|
||||
}
|
||||
|
||||
/**
|
||||
* All properties from propstat elements with empty status or status code 2xx.
|
||||
*/
|
||||
val properties: List<Property> by lazy {
|
||||
if (isSuccess())
|
||||
propstat.filter { it.isSuccess() }.map { it.properties }.flatten()
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to get a certain property with empty status or status code 2xx
|
||||
* from the current response.
|
||||
*/
|
||||
operator fun<T: Property> get(clazz: Class<T>) =
|
||||
properties.filterIsInstance(clazz).firstOrNull()
|
||||
|
||||
/**
|
||||
* Returns whether the request was successful.
|
||||
*
|
||||
* @return true: no status XML element or status code 2xx; false: otherwise
|
||||
*/
|
||||
fun isSuccess() = status == null || status.code/100 == 2
|
||||
|
||||
/**
|
||||
* Returns the name (last path segment) of the resource.
|
||||
*/
|
||||
fun hrefName() = HttpUtils.fileName(href)
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val RESPONSE = Property.Name(XmlUtils.NS_WEBDAV, "response")
|
||||
val MULTISTATUS = Property.Name(XmlUtils.NS_WEBDAV, "multistatus")
|
||||
val STATUS = Property.Name(XmlUtils.NS_WEBDAV, "status")
|
||||
val LOCATION = Property.Name(XmlUtils.NS_WEBDAV, "location")
|
||||
|
||||
/**
|
||||
* Parses an XML response element.
|
||||
*/
|
||||
fun parse(parser: XmlPullParser, location: HttpUrl, callback: MultiResponseCallback) {
|
||||
val depth = parser.depth
|
||||
|
||||
var href: HttpUrl? = null
|
||||
var status: StatusLine? = null
|
||||
val propStat = mutableListOf<PropStat>()
|
||||
var error: List<Error>? = null
|
||||
var newLocation: HttpUrl? = null
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1)
|
||||
when (parser.propertyName()) {
|
||||
DavResource.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 ->
|
||||
status = try {
|
||||
StatusLine.parse(parser.nextText())
|
||||
} catch(e: ProtocolException) {
|
||||
log.warning("Invalid status line, treating as HTTP error 500")
|
||||
StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
|
||||
}
|
||||
PropStat.NAME ->
|
||||
PropStat.parse(parser).let { propStat += it }
|
||||
Error.NAME ->
|
||||
error = Error.parseError(parser)
|
||||
LOCATION ->
|
||||
newLocation = parser.nextText().toHttpUrlOrNull()
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
if (href == null) {
|
||||
log.warning("Ignoring XML response element 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)
|
||||
propStat.filter { it.isSuccess() }
|
||||
.map { it.properties }
|
||||
.filterIsInstance(ResourceType::class.java)
|
||||
.firstOrNull()
|
||||
?.let { type ->
|
||||
if (type.types.contains(ResourceType.COLLECTION))
|
||||
href = UrlUtils.withTrailingSlash(href!!)
|
||||
}
|
||||
|
||||
//log.log(Level.FINE, "Received properties for $href", if (status != null) status else propStat)
|
||||
|
||||
// Which resource does this <response> represent?
|
||||
val relation = when {
|
||||
UrlUtils.equals(UrlUtils.omitTrailingSlash(href!!), UrlUtils.omitTrailingSlash(location)) ->
|
||||
HrefRelation.SELF
|
||||
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" ]
|
||||
*/
|
||||
var relation = HrefRelation.OTHER
|
||||
if (hrefSegments.size > nBasePathSegments) {
|
||||
val sameBasePath = (0 until nBasePathSegments).none { locationSegments[it] != hrefSegments[it] }
|
||||
if (sameBasePath)
|
||||
relation = HrefRelation.MEMBER
|
||||
}
|
||||
|
||||
relation
|
||||
} else
|
||||
HrefRelation.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
callback.onResponse(
|
||||
Response(
|
||||
location,
|
||||
href!!,
|
||||
status,
|
||||
propStat,
|
||||
error,
|
||||
newLocation
|
||||
),
|
||||
relation)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
object UrlUtils {
|
||||
|
||||
/**
|
||||
* Compares two URLs in WebDAV context. If two URLs are considered *equal*, both
|
||||
* represent the same WebDAV resource (e.g. `http://host:80/folder1` and `http://HOST/folder1#somefragment`).
|
||||
*
|
||||
* It decodes %xx entities in the path, so `/my@dav` and `/my%40dav` are considered the same.
|
||||
* This is important to process multi-status responses: some servers serve a multi-status
|
||||
* response with href `/my@dav` when you request `/my%40dav` and vice versa.
|
||||
*
|
||||
* This method does not deal with trailing slashes, so if you want to compare collection URLs,
|
||||
* make sure they both (don't) have a trailing slash before calling this method, for instance
|
||||
* with [omitTrailingSlash] or [withTrailingSlash].
|
||||
*
|
||||
* @param url1 the first URL to be compared
|
||||
* @param url2 the second URL to be compared
|
||||
*
|
||||
* @return whether [url1] and [url2] (usually) represent the same WebDAV resource
|
||||
*/
|
||||
fun equals(url1: HttpUrl, url2: HttpUrl): Boolean {
|
||||
// if okhttp thinks the two URLs are equal, they're in any case
|
||||
// (and it's a simple String comparison)
|
||||
if (url1 == url2)
|
||||
return true
|
||||
|
||||
// drop #fragment parts and convert to URI
|
||||
val uri1 = url1.newBuilder().fragment(null).build().toUri()
|
||||
val uri2 = url2.newBuilder().fragment(null).build().toUri()
|
||||
|
||||
return try {
|
||||
val decoded1 = URI(uri1.scheme, uri1.schemeSpecificPart, uri1.fragment)
|
||||
val decoded2 = URI(uri2.scheme, uri2.schemeSpecificPart, uri2.fragment)
|
||||
decoded1 == decoded2
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the first-level domain name (without subdomains) from a host name.
|
||||
* Also removes trailing dots.
|
||||
*
|
||||
* @param host name (e.g. `www.example.com.`)
|
||||
*
|
||||
* @return domain name (e.g. `example.com`)
|
||||
*/
|
||||
fun hostToDomain(host: String?): String? {
|
||||
if (host == null)
|
||||
return null
|
||||
|
||||
// remove optional dot at end
|
||||
val withoutTrailingDot = host.removeSuffix(".")
|
||||
|
||||
// split into labels
|
||||
val labels = withoutTrailingDot.split('.')
|
||||
return if (labels.size >= 2) {
|
||||
labels[labels.size - 2] + "." + labels[labels.size - 1]
|
||||
} else
|
||||
withoutTrailingDot
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a given URL doesn't have a trailing slash after member names.
|
||||
* If the path is the root path (`/`), the slash is preserved.
|
||||
*
|
||||
* @param url URL to process (e.g. 'http://host/test1/')
|
||||
*
|
||||
* @return URL without trailing slash (except when the path is the root path), e.g. `http://host/test1`
|
||||
*/
|
||||
fun omitTrailingSlash(url: HttpUrl): HttpUrl {
|
||||
val idxLast = url.pathSize - 1
|
||||
val hasTrailingSlash = url.pathSegments[idxLast] == ""
|
||||
|
||||
return if (hasTrailingSlash)
|
||||
url.newBuilder().removePathSegment(idxLast).build()
|
||||
else
|
||||
url
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a given URL has a trailing slash after member names.
|
||||
*
|
||||
* @param url URL to process (e.g. 'http://host/test1')
|
||||
*
|
||||
* @return URL with trailing slash, e.g. `http://host/test1/`
|
||||
*/
|
||||
fun withTrailingSlash(url: HttpUrl): HttpUrl {
|
||||
val idxLast = url.pathSize - 1
|
||||
val hasTrailingSlash = url.pathSegments[idxLast] == ""
|
||||
|
||||
return if (hasTrailingSlash)
|
||||
url
|
||||
else
|
||||
url.newBuilder().addPathSegment("").build()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
import java.io.IOException
|
||||
|
||||
object XmlUtils {
|
||||
|
||||
const val NS_WEBDAV = "DAV:"
|
||||
const val NS_CALDAV = "urn:ietf:params:xml:ns:caldav"
|
||||
const val NS_CARDDAV = "urn:ietf:params:xml:ns:carddav"
|
||||
const val NS_APPLE_ICAL = "http://apple.com/ns/ical/"
|
||||
const val NS_CALENDARSERVER = "http://calendarserver.org/ns/"
|
||||
|
||||
private val factory: XmlPullParserFactory
|
||||
init {
|
||||
try {
|
||||
factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
} catch (e: XmlPullParserException) {
|
||||
throw RuntimeException("Couldn't create XmlPullParserFactory", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun newPullParser() = factory.newPullParser()!!
|
||||
fun newSerializer() = factory.newSerializer()!!
|
||||
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun processTag(parser: XmlPullParser, name: Property.Name, processor: () -> Unit) {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
|
||||
processor()
|
||||
eventType = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun readText(parser: XmlPullParser): String? {
|
||||
var text: String? = null
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.TEXT && parser.depth == depth)
|
||||
text = parser.text
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun readTextProperty(parser: XmlPullParser, name: Property.Name): String? {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
var result: String? = null
|
||||
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
|
||||
result = parser.nextText()
|
||||
eventType = parser.next()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun readTextPropertyList(parser: XmlPullParser, name: Property.Name, list: MutableCollection<String>) {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
|
||||
list.add(parser.nextText())
|
||||
eventType = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun XmlSerializer.insertTag(name: Property.Name, contentGenerator: XmlSerializer.() -> Unit = {}) {
|
||||
startTag(name.namespace, name.name)
|
||||
contentGenerator(this)
|
||||
endTag(name.namespace, name.name)
|
||||
}
|
||||
|
||||
fun XmlPullParser.propertyName(): Property.Name {
|
||||
val propNs = namespace
|
||||
val propName = name
|
||||
if (propNs == null || propName == null)
|
||||
throw IllegalStateException("Current event must be START_TAG or END_TAG")
|
||||
return Property.Name(propNs, propName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class ConflictException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Error
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.Serializable
|
||||
import java.lang.Long.min
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Signals that an error occurred during a WebDAV-related operation.
|
||||
*
|
||||
* This could be a logical error like when a required ETag has not been
|
||||
* received, but also an explicit HTTP error.
|
||||
*/
|
||||
open class DavException @JvmOverloads constructor(
|
||||
message: String,
|
||||
ex: Throwable? = null,
|
||||
|
||||
/**
|
||||
* An associated HTTP [Response]. Will be closed after evaluation.
|
||||
*/
|
||||
httpResponse: Response? = null
|
||||
): Exception(message, ex), Serializable {
|
||||
|
||||
companion object {
|
||||
|
||||
const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB
|
||||
|
||||
fun isPlainText(type: MediaType) =
|
||||
type.type == "text" ||
|
||||
(type.type == "application" && type.subtype in arrayOf("html", "xml"))
|
||||
|
||||
}
|
||||
|
||||
var request: String? = null
|
||||
|
||||
/**
|
||||
* Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available
|
||||
* if the HTTP request body was textual content and could be read again.
|
||||
*/
|
||||
var requestBody: String? = null
|
||||
private set
|
||||
|
||||
val response: String?
|
||||
|
||||
/**
|
||||
* Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available
|
||||
* if the HTTP response body was textual content.
|
||||
*/
|
||||
var responseBody: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Precondition/postcondition XML elements which have been found in the XML response.
|
||||
*/
|
||||
var errors: List<Error> = listOf()
|
||||
private set
|
||||
|
||||
|
||||
init {
|
||||
if (httpResponse != null) {
|
||||
response = httpResponse.toString()
|
||||
|
||||
try {
|
||||
request = httpResponse.request.toString()
|
||||
|
||||
httpResponse.request.body?.let { body ->
|
||||
body.contentType()?.let { type ->
|
||||
if (isPlainText(type)) {
|
||||
val buffer = Buffer()
|
||||
body.writeTo(buffer)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong()))
|
||||
|
||||
requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP request", e)
|
||||
requestBody = "Couldn't read HTTP request: ${e.message}"
|
||||
}
|
||||
|
||||
try {
|
||||
// save response body excerpt
|
||||
if (httpResponse.body?.source() != null) {
|
||||
// response body has a source
|
||||
|
||||
httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body ->
|
||||
body.contentType()?.let { mimeType ->
|
||||
if (isPlainText(mimeType))
|
||||
responseBody = body.string()
|
||||
}
|
||||
}
|
||||
|
||||
httpResponse.body?.use { body ->
|
||||
body.contentType()?.let {
|
||||
if (it.type in arrayOf("application", "text") && it.subtype == "xml") {
|
||||
// look for precondition/postcondition XML elements
|
||||
try {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(body.charStream())
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
|
||||
if (parser.propertyName() == Error.NAME)
|
||||
errors = Error.parseError(parser)
|
||||
eventType = parser.next()
|
||||
}
|
||||
} catch (e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse XML response", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP response", e)
|
||||
responseBody = "Couldn't read HTTP response: ${e.message}"
|
||||
} finally {
|
||||
httpResponse.body?.close()
|
||||
}
|
||||
} else
|
||||
response = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class ForbiddenException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class GoneException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Signals that a HTTP error was sent by the server.
|
||||
*/
|
||||
open class HttpException: DavException {
|
||||
|
||||
var code: Int
|
||||
|
||||
constructor(response: Response): super(
|
||||
"HTTP ${response.code} ${response.message}",
|
||||
httpResponse = response
|
||||
) {
|
||||
code = response.code
|
||||
}
|
||||
|
||||
constructor(code: Int, message: String?): super("HTTP $code $message") {
|
||||
this.code = code
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class NotFoundException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class PreconditionFailedException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.*
|
||||
|
||||
class ServiceUnavailableException: HttpException {
|
||||
|
||||
var retryAfter: Date? = null
|
||||
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_UNAVAILABLE, message)
|
||||
|
||||
constructor(response: Response): super(response) {
|
||||
// Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds )
|
||||
// HTTP-date = rfc1123-date | rfc850-date | asctime-date
|
||||
|
||||
response.header("Retry-After")?.let { after ->
|
||||
retryAfter = HttpUtils.parseDate(after) ?:
|
||||
// not a HTTP-date, must be delta-seconds
|
||||
try {
|
||||
val seconds = Integer.parseInt(after)
|
||||
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.SECOND, seconds)
|
||||
cal.time
|
||||
|
||||
} catch (ignored: NumberFormatException) {
|
||||
Dav4jvm.log.warning("Received Retry-After which was not a HTTP-date nor delta-seconds: $after")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class UnauthorizedException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
/**
|
||||
* Defined in RFC 5995 3.2.1 DAV:add-member Property (Protected).
|
||||
*/
|
||||
data class AddMember(
|
||||
val href: String?
|
||||
): Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "add-member")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
AddMember(XmlUtils.readTextProperty(parser, DavResource.HREF))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class AddressData(
|
||||
val card: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "address-data")
|
||||
|
||||
// attributes
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT address-data (#PCDATA)>
|
||||
AddressData(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class AddressbookDescription(
|
||||
var description: String? = null
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT addressbook-description (#PCDATA)>
|
||||
AddressbookDescription(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class AddressbookHomeSet: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-home-set")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, AddressbookHomeSet())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
import java.util.regex.Pattern
|
||||
|
||||
data class CalendarColor(
|
||||
val color: Int
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
|
||||
private val PATTERN = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?")!!
|
||||
|
||||
/**
|
||||
* Converts a WebDAV color from one of these formats:
|
||||
* #RRGGBB (alpha = 0xFF)
|
||||
* RRGGBB (alpha = 0xFF)
|
||||
* #RRGGBBAA
|
||||
* RRGGBBAA
|
||||
* to an [Int] with alpha.
|
||||
*/
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun parseARGBColor(davColor: String): Int {
|
||||
val m = PATTERN.matcher(davColor)
|
||||
if (m.find()) {
|
||||
val color_rgb = Integer.parseInt(m.group(1), 16)
|
||||
val color_alpha = m.group(2)?.let { Integer.parseInt(m.group(2), 16) and 0xFF } ?: 0xFF
|
||||
return (color_alpha shl 24) or color_rgb
|
||||
} else
|
||||
throw IllegalArgumentException("Couldn't parse color value: $davColor")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CalendarColor? {
|
||||
XmlUtils.readText(parser)?.let {
|
||||
try {
|
||||
return CalendarColor(parseARGBColor(it))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse color, ignoring", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CalendarData(
|
||||
val iCalendar: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
|
||||
|
||||
// attributes
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT calendar-data (#PCDATA)>
|
||||
CalendarData(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CalendarDescription(
|
||||
val description: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT calendar-description (#PCDATA)>
|
||||
CalendarDescription(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarHomeSet: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-home-set")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarHomeSet())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarProxyReadFor: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-read-for")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarProxyReadFor())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarProxyWriteFor: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-write-for")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarProxyWriteFor())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CalendarTimezone(
|
||||
val vTimeZone: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT calendar-timezone (#PCDATA)>
|
||||
CalendarTimezone(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarUserAddressSet: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-user-address-set")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarUserAddressSet())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CreationDate(
|
||||
var creationDate: String
|
||||
): Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "creationdate")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CreationDate? {
|
||||
XmlUtils.readText(parser)?.let { it ->
|
||||
return CreationDate(it)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
// see RFC 5397: WebDAV Current Principal Extension
|
||||
|
||||
data class CurrentUserPrincipal(
|
||||
val href: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CurrentUserPrincipal {
|
||||
// <!ELEMENT current-user-principal (unauthenticated | href)>
|
||||
var href: String? = null
|
||||
XmlUtils.processTag(parser, DavResource.HREF) {
|
||||
href = XmlUtils.readText(parser)
|
||||
}
|
||||
return CurrentUserPrincipal(href)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CurrentUserPrivilegeSet(
|
||||
// not all privileges from RFC 3744 are implemented by now
|
||||
// feel free to add more if you need them for your project
|
||||
var mayRead: Boolean = false,
|
||||
var mayWriteProperties: Boolean = false,
|
||||
var mayWriteContent: Boolean = false,
|
||||
var mayBind: Boolean = false,
|
||||
var mayUnbind: Boolean = false
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-privilege-set")
|
||||
|
||||
val PRIVILEGE = Property.Name(XmlUtils.NS_WEBDAV, "privilege")
|
||||
val READ = Property.Name(XmlUtils.NS_WEBDAV, "read")
|
||||
val WRITE = Property.Name(XmlUtils.NS_WEBDAV, "write")
|
||||
val WRITE_PROPERTIES = Property.Name(XmlUtils.NS_WEBDAV, "write-properties")
|
||||
val WRITE_CONTENT = Property.Name(XmlUtils.NS_WEBDAV, "write-content")
|
||||
val BIND = Property.Name(XmlUtils.NS_WEBDAV, "bind")
|
||||
val UNBIND = Property.Name(XmlUtils.NS_WEBDAV, "unbind")
|
||||
val ALL = Property.Name(XmlUtils.NS_WEBDAV, "all")
|
||||
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CurrentUserPrivilegeSet? {
|
||||
// <!ELEMENT current-user-privilege-set (privilege*)>
|
||||
// <!ELEMENT privilege ANY>
|
||||
val privs = CurrentUserPrivilegeSet()
|
||||
|
||||
XmlUtils.processTag(parser, PRIVILEGE) {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
when (parser.propertyName()) {
|
||||
READ ->
|
||||
privs.mayRead = true
|
||||
WRITE -> {
|
||||
privs.mayBind = true
|
||||
privs.mayUnbind = true
|
||||
privs.mayWriteProperties = true
|
||||
privs.mayWriteContent = true
|
||||
}
|
||||
WRITE_PROPERTIES ->
|
||||
privs.mayWriteProperties = true
|
||||
WRITE_CONTENT ->
|
||||
privs.mayWriteContent = true
|
||||
BIND ->
|
||||
privs.mayBind = true
|
||||
UNBIND ->
|
||||
privs.mayUnbind = true
|
||||
ALL -> {
|
||||
privs.mayRead = true
|
||||
privs.mayBind = true
|
||||
privs.mayUnbind = true
|
||||
privs.mayWriteProperties = true
|
||||
privs.mayWriteContent = true
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
return privs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class DisplayName(
|
||||
val displayName: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT displayname (#PCDATA) >
|
||||
DisplayName(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class GetCTag(
|
||||
val cTag: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "getctag")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
GetCTag(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class GetContentLength(
|
||||
val contentLength: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontentlength")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): GetContentLength? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return GetContentLength(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class GetContentType(
|
||||
val type: MediaType?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontenttype")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT getcontenttype (#PCDATA) >
|
||||
GetContentType(XmlUtils.readText(parser)?.toMediaTypeOrNull())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.QuotedStringUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.Response
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
/**
|
||||
* The GetETag property.
|
||||
*
|
||||
* Can also be used to parse ETags from HTTP responses – just pass the raw ETag
|
||||
* header value to the constructor and then use [eTag] and [weak].
|
||||
*/
|
||||
class GetETag(
|
||||
rawETag: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getetag")
|
||||
|
||||
fun fromResponse(response: Response) =
|
||||
response.header("ETag")?.let { GetETag(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed eTag value. May be null if the tag is weak.
|
||||
*/
|
||||
val eTag: String?
|
||||
|
||||
/**
|
||||
* If the tag is weak. May be null if the tag passed is null.
|
||||
*/
|
||||
val weak: Boolean?
|
||||
|
||||
init {
|
||||
/* entity-tag = [ weak ] opaque-tag
|
||||
weak = "W/"
|
||||
opaque-tag = quoted-string
|
||||
*/
|
||||
var tag: String? = rawETag
|
||||
if (tag != null) {
|
||||
// remove trailing "W/"
|
||||
if (tag.startsWith("W/") && tag.length >= 3) {
|
||||
// entity tag is weak
|
||||
tag = tag.substring(2)
|
||||
weak = true
|
||||
} else
|
||||
weak = false
|
||||
|
||||
tag = QuotedStringUtils.decodeQuotedString(tag)
|
||||
} else
|
||||
weak = null
|
||||
|
||||
eTag = tag
|
||||
}
|
||||
|
||||
override fun toString() = eTag ?: "(null)"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
GetETag(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.*
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class GetLastModified(
|
||||
var lastModified: Long
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getlastmodified")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): GetLastModified? {
|
||||
// <!ELEMENT getlastmodified (#PCDATA) >
|
||||
XmlUtils.readText(parser)?.let { rawDate ->
|
||||
val date = HttpUtils.parseDate(rawDate)
|
||||
if (date != null)
|
||||
return GetLastModified(date.time)
|
||||
else
|
||||
Dav4jvm.log.warning("Couldn't parse Last-Modified date")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class GroupMembership: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "group-membership")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, GroupMembership())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.*
|
||||
|
||||
abstract class HrefListProperty: Property {
|
||||
|
||||
val hrefs = LinkedList<String>()
|
||||
|
||||
val href
|
||||
get() = hrefs.firstOrNull()
|
||||
|
||||
override fun toString() = "href=[" + hrefs.joinToString(", ") + "]"
|
||||
|
||||
|
||||
|
||||
abstract class Factory: PropertyFactory {
|
||||
|
||||
fun create(parser: XmlPullParser, list: HrefListProperty): HrefListProperty {
|
||||
XmlUtils.readTextPropertyList(parser, DavResource.HREF, list.hrefs)
|
||||
return list
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class MaxICalendarSize(
|
||||
val maxSize: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "max-resource-size")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): MaxICalendarSize? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return MaxICalendarSize(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class MaxVCardSize(
|
||||
val maxSize: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "max-resource-size")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): MaxVCardSize? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return MaxVCardSize(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class Owner: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "owner")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, Owner())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class QuotaAvailableBytes(
|
||||
val quotaAvailableBytes: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-available-bytes")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): QuotaAvailableBytes? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return QuotaAvailableBytes(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class QuotaUsedBytes(
|
||||
val quotaUsedBytes: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-used-bytes")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): QuotaUsedBytes? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return QuotaUsedBytes(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class ResourceType: Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
|
||||
val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection") // WebDAV
|
||||
val PRINCIPAL = Property.Name(XmlUtils.NS_WEBDAV, "principal") // WebDAV ACL
|
||||
val ADDRESSBOOK = Property.Name(XmlUtils.NS_CARDDAV, "addressbook") // CardDAV
|
||||
val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar") // CalDAV
|
||||
val SUBSCRIBED = Property.Name(XmlUtils.NS_CALENDARSERVER, "subscribed")
|
||||
}
|
||||
|
||||
val types = mutableSetOf<Property.Name>()
|
||||
|
||||
override fun toString() = "[${types.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): ResourceType? {
|
||||
val type = ResourceType()
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
|
||||
// use static objects to allow types.contains()
|
||||
var typeName = Property.Name(parser.namespace, parser.name)
|
||||
when (typeName) {
|
||||
COLLECTION -> typeName = COLLECTION
|
||||
PRINCIPAL -> typeName = PRINCIPAL
|
||||
ADDRESSBOOK -> typeName = ADDRESSBOOK
|
||||
CALENDAR -> typeName = CALENDAR
|
||||
SUBSCRIBED -> typeName = SUBSCRIBED
|
||||
}
|
||||
type.types.add(typeName)
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
assert(parser.depth == depth)
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.QuotedStringUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.Response
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class ScheduleTag(
|
||||
rawScheduleTag: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "schedule-tag")
|
||||
|
||||
fun fromResponse(response: Response) =
|
||||
response.header("Schedule-Tag")?.let { ScheduleTag(it) }
|
||||
}
|
||||
|
||||
/* Value: opaque-tag
|
||||
opaque-tag = quoted-string
|
||||
*/
|
||||
val scheduleTag: String? = rawScheduleTag?.let { QuotedStringUtils.decodeQuotedString(it) }
|
||||
|
||||
override fun toString() = scheduleTag ?: "(null)"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
ScheduleTag(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class Source: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "source")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, Source())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.util.logging.Level
|
||||
|
||||
class SupportedAddressData: Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "supported-address-data")
|
||||
|
||||
val ADDRESS_DATA_TYPE = Property.Name(XmlUtils.NS_CARDDAV, "address-data-type")
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
|
||||
}
|
||||
|
||||
val types = mutableSetOf<MediaType>()
|
||||
|
||||
fun hasVCard4() = types.any { "text/vcard; version=4.0".equals(it.toString(), true) }
|
||||
fun hasJCard() = types.any { "application".equals(it.type, true) && "vcard+json".equals(it.subtype, true) }
|
||||
|
||||
override fun toString() = "[${types.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedAddressData? {
|
||||
val supported = SupportedAddressData()
|
||||
|
||||
try {
|
||||
XmlUtils.processTag(parser, ADDRESS_DATA_TYPE) {
|
||||
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
|
||||
var type = contentType
|
||||
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
|
||||
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
|
||||
}
|
||||
}
|
||||
} catch(e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
|
||||
return null
|
||||
}
|
||||
|
||||
return supported
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class SupportedCalendarComponentSet(
|
||||
var supportsEvents: Boolean,
|
||||
var supportsTasks: Boolean,
|
||||
var supportsJournal: Boolean
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
|
||||
val ALLCOMP = Property.Name(XmlUtils.NS_CALDAV, "allcomp")
|
||||
val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp")
|
||||
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedCalendarComponentSet? {
|
||||
/* <!ELEMENT supported-calendar-component-set (comp+)>
|
||||
<!ELEMENT comp ((allprop | prop*), (allcomp | comp*))>
|
||||
<!ATTLIST comp name CDATA #REQUIRED>
|
||||
*/
|
||||
val components = SupportedCalendarComponentSet(false, false, false)
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
|
||||
when (parser.propertyName()) {
|
||||
ALLCOMP -> {
|
||||
components.supportsEvents = true
|
||||
components.supportsTasks = true
|
||||
components.supportsJournal = true
|
||||
}
|
||||
COMP ->
|
||||
when (parser.getAttributeValue(null, "name")?.uppercase()) {
|
||||
"VEVENT" -> components.supportsEvents = true
|
||||
"VTODO" -> components.supportsTasks = true
|
||||
"VJOURNAL" -> components.supportsJournal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.util.logging.Level
|
||||
|
||||
class SupportedCalendarData: Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-data")
|
||||
|
||||
val CALENDAR_DATA_TYPE = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
|
||||
}
|
||||
|
||||
val types = mutableSetOf<MediaType>()
|
||||
|
||||
fun hasJCal() = types.any { "application".equals(it.type, true) && "calendar+json".equals(it.subtype, true) }
|
||||
|
||||
override fun toString() = "[${types.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedCalendarData? {
|
||||
val supported = SupportedCalendarData()
|
||||
|
||||
try {
|
||||
XmlUtils.processTag(parser, CALENDAR_DATA_TYPE) {
|
||||
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
|
||||
var type = contentType
|
||||
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
|
||||
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
|
||||
}
|
||||
}
|
||||
} catch(e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
|
||||
return null
|
||||
}
|
||||
|
||||
return supported
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class SupportedReportSet: Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "supported-report-set")
|
||||
|
||||
val SUPPORTED_REPORT = Property.Name(XmlUtils.NS_WEBDAV, "supported-report")
|
||||
val REPORT = Property.Name(XmlUtils.NS_WEBDAV, "report")
|
||||
|
||||
const val SYNC_COLLECTION = "DAV:sync-collection" // collection synchronization (RFC 6578)
|
||||
|
||||
}
|
||||
|
||||
val reports = mutableSetOf<String>()
|
||||
|
||||
override fun toString() = "[${reports.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedReportSet? {
|
||||
/* <!ELEMENT supported-report-set (supported-report*)>
|
||||
<!ELEMENT supported-report report>
|
||||
<!ELEMENT report ANY>
|
||||
*/
|
||||
|
||||
val supported = SupportedReportSet()
|
||||
XmlUtils.processTag(parser, SUPPORTED_REPORT) {
|
||||
XmlUtils.processTag(parser, REPORT) {
|
||||
parser.nextTag()
|
||||
if (parser.eventType == XmlPullParser.TEXT)
|
||||
supported.reports += parser.text
|
||||
else if (parser.eventType == XmlPullParser.START_TAG)
|
||||
supported.reports += "${parser.namespace}${parser.name}"
|
||||
}
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class SyncToken(
|
||||
val token: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "sync-token")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT sync-token #PCDATA>
|
||||
SyncToken(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.Challenge
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Response.Builder
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class BasicDigestAuthHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testBasic() {
|
||||
var authenticator = BasicDigestAuthHandler(null, "user", "password")
|
||||
val original = Request.Builder()
|
||||
.url("http://example.com")
|
||||
.build()
|
||||
var response = Builder()
|
||||
.request(original)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(401).message("Authentication required")
|
||||
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
|
||||
.build()
|
||||
var request = authenticator.authenticateRequest(original, response)
|
||||
assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization"))
|
||||
|
||||
// special characters: always use UTF-8 (and don't crash on RFC 7617 charset header)
|
||||
authenticator = BasicDigestAuthHandler(null, "username", "paßword")
|
||||
response = response.newBuilder()
|
||||
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\",charset=UTF-8")
|
||||
.build()
|
||||
request = authenticator.authenticateRequest(original, response)
|
||||
assertEquals("Basic dXNlcm5hbWU6cGHDn3dvcmQ=", request!!.header("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestRFCExample() {
|
||||
// use cnonce from example
|
||||
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life")
|
||||
BasicDigestAuthHandler.clientNonce = "0a4f113b"
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
// construct WWW-Authenticate
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "testrealm@host.com"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
|
||||
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
|
||||
))
|
||||
|
||||
val original = Request.Builder()
|
||||
.get()
|
||||
.url("http://www.nowhere.org/dir/index.html")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"Mufasa\""))
|
||||
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
|
||||
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
|
||||
assertTrue(auth.contains("uri=\"/dir/index.html\""))
|
||||
assertTrue(auth.contains("qop=auth"))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("cnonce=\"0a4f113b\""))
|
||||
assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\""))
|
||||
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestRealWorldExamples() {
|
||||
var authenticator = BasicDigestAuthHandler(null, "demo", "demo")
|
||||
BasicDigestAuthHandler.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI="
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
// example 1
|
||||
var authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("nonce", "56212407212c8"),
|
||||
Pair("opaque", "df58bdff8cf60599c939187d0b5c54de")
|
||||
))
|
||||
|
||||
var original = Request.Builder()
|
||||
.method("PROPFIND", null)
|
||||
.url("https://demo.group-office.eu/caldav/")
|
||||
.build()
|
||||
var request = authenticator.digestRequest(original, authScheme)
|
||||
var auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"demo\""))
|
||||
assertTrue(auth.contains("realm=\"Group-Office\""))
|
||||
assertTrue(auth.contains("nonce=\"56212407212c8\""))
|
||||
assertTrue(auth.contains("uri=\"/caldav/\""))
|
||||
assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\""))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("qop=auth"))
|
||||
assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\""))
|
||||
assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\""))
|
||||
|
||||
// example 2
|
||||
authenticator = BasicDigestAuthHandler(null, "test", "test")
|
||||
authScheme = Challenge("digest", mapOf<String?, String>( // lower case
|
||||
Pair("nonce", "87c4c2aceed9abf30dd68c71"),
|
||||
Pair("algorithm", "md5"),
|
||||
Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="),
|
||||
Pair("realm", "ieddy.ru")
|
||||
))
|
||||
original = Request.Builder()
|
||||
.method("OPTIONS", null)
|
||||
.url("https://ieddy.ru/")
|
||||
.build()
|
||||
request = authenticator.digestRequest(original, authScheme)
|
||||
auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it
|
||||
assertTrue(auth.contains("username=\"test\""))
|
||||
assertTrue(auth.contains("realm=\"ieddy.ru\""))
|
||||
assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\""))
|
||||
assertTrue(auth.contains("uri=\"/\""))
|
||||
assertFalse(auth.contains("cnonce="))
|
||||
assertFalse(auth.contains("nc=00000001"))
|
||||
assertFalse(auth.contains("qop="))
|
||||
assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\""))
|
||||
assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestMD5Sess() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "admin", "12345")
|
||||
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "MD5-sess Example"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("algorithm", "MD5-sess"),
|
||||
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
|
||||
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
|
||||
))
|
||||
|
||||
/* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk =
|
||||
4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686
|
||||
A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed
|
||||
|
||||
h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") =
|
||||
af2a72145775cfd08c36ad2676e89446
|
||||
*/
|
||||
|
||||
val original = Request.Builder()
|
||||
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
|
||||
.url("http://example.com/plain.txt")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"admin\""))
|
||||
assertTrue(auth.contains("realm=\"MD5-sess Example\""))
|
||||
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
|
||||
assertTrue(auth.contains("uri=\"/plain.txt\""))
|
||||
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("qop=auth"))
|
||||
assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\""))
|
||||
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestMD5AuthInt() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "admin", "12435")
|
||||
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "AuthInt Example"),
|
||||
Pair("qop", "auth-int"),
|
||||
Pair("nonce", "367sj3265s5"),
|
||||
Pair("opaque", "87aaxcval4gba36")
|
||||
))
|
||||
|
||||
/* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34
|
||||
|
||||
h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9
|
||||
A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0
|
||||
|
||||
h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) =
|
||||
81d07cb3b8d412b34144164124c970cb
|
||||
*/
|
||||
|
||||
val original = Request.Builder()
|
||||
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
|
||||
.url("http://example.com/plain.txt")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"admin\""))
|
||||
assertTrue(auth.contains("realm=\"AuthInt Example\""))
|
||||
assertTrue(auth.contains("nonce=\"367sj3265s5\""))
|
||||
assertTrue(auth.contains("uri=\"/plain.txt\""))
|
||||
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("qop=auth-int"))
|
||||
assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\""))
|
||||
assertTrue(auth.contains("opaque=\"87aaxcval4gba36\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestLegacy() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife")
|
||||
|
||||
// construct WWW-Authenticate
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "testrealm@host.com"),
|
||||
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
|
||||
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
|
||||
))
|
||||
|
||||
val original = Request.Builder()
|
||||
.get()
|
||||
.url("http://www.nowhere.org/dir/index.html")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"Mufasa\""))
|
||||
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
|
||||
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
|
||||
assertTrue(auth.contains("uri=\"/dir/index.html\""))
|
||||
assertFalse(auth.contains("qop="))
|
||||
assertFalse(auth.contains("nc="))
|
||||
assertFalse(auth.contains("cnonce="))
|
||||
assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\""))
|
||||
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIncompleteAuthenticationRequests() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
|
||||
|
||||
val original = Request.Builder()
|
||||
.get()
|
||||
.url("http://www.nowhere.org/dir/index.html")
|
||||
.build()
|
||||
|
||||
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>())))
|
||||
|
||||
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office")
|
||||
))))
|
||||
|
||||
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office"),
|
||||
Pair("qop", "auth")
|
||||
))))
|
||||
|
||||
assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("nonce", "56212407212c8")
|
||||
))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthenticateNull() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
|
||||
// must not crash (route may be null)
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url("http://example.com")
|
||||
.build()
|
||||
val response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_2)
|
||||
.code(200).message("OK")
|
||||
.build()
|
||||
authenticator.authenticate(null, response)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class DavCollectionTest {
|
||||
|
||||
private val sampleText = "SAMPLE RESPONSE"
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
private val mockServer = MockWebServer()
|
||||
private fun sampleUrl() = mockServer.url("/dav/")
|
||||
|
||||
@Before
|
||||
fun startServer() = mockServer.start()
|
||||
|
||||
@After
|
||||
fun stopServer() = mockServer.shutdown()
|
||||
|
||||
|
||||
/**
|
||||
* Test sample response for an initial sync-collection report from RFC 6578 3.8.
|
||||
*/
|
||||
@Test
|
||||
fun testInitialSyncCollectionReport() {
|
||||
val url = sampleUrl()
|
||||
val collection = DavCollection(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
|
||||
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
" <D:multistatus xmlns:D=\"DAV:\">\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href\n" +
|
||||
" >${sampleUrl()}test.doc</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
|
||||
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\">\n" +
|
||||
" <R:BoxType>Box type A</R:BoxType>\n" +
|
||||
" </R:bigbox>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href\n" +
|
||||
" >${sampleUrl()}vcard.vcf</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href\n" +
|
||||
" >${sampleUrl()}calendar.ics</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00003-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:sync-token>http://example.com/ns/sync/1234</D:sync-token>\n" +
|
||||
" </D:multistatus>")
|
||||
)
|
||||
var nrCalled = 0
|
||||
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
|
||||
when (response.href) {
|
||||
url.resolve("/dav/test.doc") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00001-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/vcard.vcf") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00002-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/calendar.ics") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00003-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(3, nrCalled)
|
||||
assertEquals("http://example.com/ns/sync/1234", result.filterIsInstance(SyncToken::class.java).first().token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sample response for an initial sync-collection report with truncation from RFC 6578 3.10.
|
||||
*/
|
||||
@Test
|
||||
fun testInitialSyncCollectionReportWithTruncation() {
|
||||
val url = sampleUrl()
|
||||
val collection = DavCollection(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
|
||||
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
" <D:multistatus xmlns:D=\"DAV:\">\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}test.doc</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}vcard.vcf</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}removed.txt</D:href>\n" +
|
||||
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
|
||||
" </D:response>" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}</D:href>\n" +
|
||||
" <D:status>HTTP/1.1 507 Insufficient Storage</D:status>\n" +
|
||||
" <D:error><D:number-of-matches-within-limits/></D:error>\n" +
|
||||
" </D:response>" +
|
||||
" <D:sync-token>http://example.com/ns/sync/1233</D:sync-token>\n" +
|
||||
" </D:multistatus>")
|
||||
)
|
||||
var nrCalled = 0
|
||||
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
|
||||
when (response.href) {
|
||||
url.resolve("/dav/test.doc") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00001-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/vcard.vcf") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00002-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/removed.txt") -> {
|
||||
assertFalse(response.isSuccess())
|
||||
assertEquals(404, response.status?.code)
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/") -> {
|
||||
assertFalse(response.isSuccess())
|
||||
assertEquals(507, response.status?.code)
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
nrCalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance(SyncToken::class.java).first().token)
|
||||
assertEquals(4, nrCalled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sample response for a sync-collection report with unsupported limit from RFC 6578 3.12.
|
||||
*/
|
||||
@Test
|
||||
fun testSyncCollectionReportWithUnsupportedLimit() {
|
||||
val url = sampleUrl()
|
||||
val collection = DavCollection(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(507)
|
||||
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
|
||||
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
" <D:error xmlns:D=\"DAV:\">\n" +
|
||||
" <D:number-of-matches-within-limits/>\n" +
|
||||
" </D:error>")
|
||||
)
|
||||
|
||||
try {
|
||||
collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(507, e.code)
|
||||
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "number-of-matches-within-limits") })
|
||||
assertEquals(1, e.errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPost() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavCollection(httpClient, url)
|
||||
|
||||
// 201 Created
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_CREATED))
|
||||
var called = false
|
||||
dav.post(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
|
||||
assertEquals("POST", mockServer.takeRequest().method)
|
||||
assertEquals(response.request.url, dav.location)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4jvm.property.DisplayName
|
||||
import at.bitfire.dav4jvm.property.GetContentType
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class DavResourceTest {
|
||||
|
||||
private val sampleText = "SAMPLE RESPONSE"
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
mockServer.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
private fun sampleUrl() = mockServer.url("/dav/")
|
||||
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val url = sampleUrl()
|
||||
val destination = url.resolve("test")!!
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 201 Created, resulted in the creation of a new resource
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_CREATED))
|
||||
var called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.copy(destination, false) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("COPY", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertNull(rq.getHeader("Overwrite"))
|
||||
|
||||
// no preconditions, 204 No content, resource successfully copied to a preexisting
|
||||
// destination resource
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.copy(destination, true) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("COPY", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertEquals("F", rq.getHeader("Overwrite"))
|
||||
|
||||
/* NEGATIVE TEST CASES */
|
||||
|
||||
// 207 multi-status (e.g. errors on some of resources affected by
|
||||
// the COPY prevented the operation from taking place)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207))
|
||||
try {
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.copy(destination, false) { called = true }
|
||||
fail("Expected HttpException")
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 204 No Content
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
var called = false
|
||||
dav.delete {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("DELETE", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertNull(rq.getHeader("If-Match"))
|
||||
|
||||
// precondition: If-Match / If-Schedule-Tag-Match, 200 OK
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody("Resource has been deleted."))
|
||||
called = false
|
||||
dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("\"DeleteOnlyThisETag\"", rq.getHeader("If-Match"))
|
||||
assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.getHeader("If-Schedule-Tag-Match"))
|
||||
|
||||
// 302 Moved Temporarily
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
|
||||
.setHeader("Location", "/new-location")
|
||||
)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
called = false
|
||||
dav.delete(null) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
/* NEGATIVE TEST CASES */
|
||||
|
||||
// 207 multi-status (e.g. single resource couldn't be deleted when DELETEing a collection)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207))
|
||||
try {
|
||||
called = false
|
||||
dav.delete(null) { called = true }
|
||||
fail("Expected HttpException")
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFollowRedirects_302() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
var i = 0
|
||||
dav.followRedirects {
|
||||
if (i++ == 0)
|
||||
okhttp3.Response.Builder()
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(302)
|
||||
.message("Found")
|
||||
.header("Location", "http://to.com/")
|
||||
.request(Request.Builder()
|
||||
.get()
|
||||
.url("http://from.com/")
|
||||
.build())
|
||||
.body("New location!".toResponseBody())
|
||||
.build()
|
||||
else
|
||||
okhttp3.Response.Builder()
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(204)
|
||||
.message("No Content")
|
||||
.request(Request.Builder()
|
||||
.get()
|
||||
.url("http://to.com/")
|
||||
.build())
|
||||
.build()
|
||||
}.let { response ->
|
||||
assertEquals(204, response.code)
|
||||
assertEquals("http://to.com/".toHttpUrl(), dav.location)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testFollowRedirects_HttpsToHttp() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.followRedirects {
|
||||
okhttp3.Response.Builder()
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(302)
|
||||
.message("Found")
|
||||
.header("Location", "http://to.com/")
|
||||
.request(Request.Builder()
|
||||
.get()
|
||||
.url("https://from.com/")
|
||||
.build())
|
||||
.body("New location!".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGet() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// 200 OK
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setHeader("ETag", "W/\"My Weak ETag\"")
|
||||
.setHeader("Content-Type", "application/x-test-result")
|
||||
.setBody(sampleText))
|
||||
var called = false
|
||||
dav.get("*/*", null) { response ->
|
||||
called = true
|
||||
assertEquals(sampleText, response.body!!.string())
|
||||
|
||||
val eTag = GetETag.fromResponse(response)
|
||||
assertEquals("My Weak ETag", eTag!!.eTag)
|
||||
assertTrue(eTag.weak!!)
|
||||
assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body!!.contentType()!!).type)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("GET", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals("*/*", rq.getHeader("Accept"))
|
||||
|
||||
// 302 Moved Temporarily + 200 OK
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
|
||||
.setHeader("Location", "/target")
|
||||
.setBody("This resource was moved."))
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setHeader("ETag", "\"StrongETag\"")
|
||||
.setBody(sampleText))
|
||||
called = false
|
||||
dav.get("*/*", null) { response ->
|
||||
called = true
|
||||
assertEquals(sampleText, response.body!!.string())
|
||||
val eTag = GetETag(response.header("ETag"))
|
||||
assertEquals("StrongETag", eTag.eTag)
|
||||
assertFalse(eTag.weak!!)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
mockServer.takeRequest()
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("GET", rq.method)
|
||||
assertEquals("/target", rq.path)
|
||||
|
||||
// 200 OK without ETag in response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(sampleText))
|
||||
called = false
|
||||
dav.get("*/*", null) { response ->
|
||||
called = true
|
||||
assertNull(response.header("ETag"))
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetRange_Ok() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_PARTIAL))
|
||||
var called = false
|
||||
dav.getRange("*/*", 100, 342) { response ->
|
||||
assertEquals("bytes=100-441", response.request.header("Range"))
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMove() {
|
||||
val url = sampleUrl()
|
||||
val destination = url.resolve("test")!!
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 201 Created, new URL mapping at the destination
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_CREATED))
|
||||
var called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.move(destination, false) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
assertEquals(destination, dav.location)
|
||||
}
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("MOVE", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertNull(rq.getHeader("Overwrite"))
|
||||
|
||||
// no preconditions, 204 No content, URL already mapped, overwrite
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.move(destination, true) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
assertEquals(destination, dav.location)
|
||||
}
|
||||
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("MOVE", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertEquals("F", rq.getHeader("Overwrite"))
|
||||
|
||||
/* NEGATIVE TEST CASES */
|
||||
|
||||
// 207 multi-status (e.g. errors on some of resources affected by
|
||||
// the MOVE prevented the operation from taking place)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207))
|
||||
try {
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.move(destination, false) { called = true }
|
||||
fail("Expected HttpException")
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptions() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setHeader("DAV", " 1, 2 ,3,hyperactive-access"))
|
||||
var called = false
|
||||
dav.options { davCapabilities, _ ->
|
||||
called = true
|
||||
assertTrue(davCapabilities.contains("1"))
|
||||
assertTrue(davCapabilities.contains("2"))
|
||||
assertTrue(davCapabilities.contains("3"))
|
||||
assertTrue(davCapabilities.contains("hyperactive-access"))
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
called = false
|
||||
dav.options { davCapabilities, _ ->
|
||||
called = true
|
||||
assertTrue(davCapabilities.isEmpty())
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPropfindAndMultiStatus() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/*** NEGATIVE TESTS ***/
|
||||
|
||||
// test for non-multi-status responses:
|
||||
// * 500 Internal Server Error
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR))
|
||||
var called = false
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected HttpException")
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
// * 200 OK (instead of 207 Multi-Status)
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// test for invalid multi-status responses:
|
||||
// * non-XML response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html></html>"))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// * malformed XML response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<malformed-xml>"))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// * response without <multistatus> root element
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<test></test>"))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// * multi-status response with invalid <status> in <response>
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <status>Invalid Status Line</status>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(500, response.status?.code)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// * multi-status response with <response>/<status> element indicating failure
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <status>HTTP/1.1 403 Forbidden</status>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(403, response.status?.code)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// * multi-status response with invalid <status> in <propstat>
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" </prop>" +
|
||||
" <status>Invalid Status Line</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
called = true
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertTrue(response.properties.filterIsInstance(ResourceType::class.java).isEmpty())
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
|
||||
/*** POSITIVE TESTS ***/
|
||||
|
||||
// multi-status response without <response> elements
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'></multistatus>"))
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ ->
|
||||
fail("Shouldn't be called")
|
||||
}
|
||||
|
||||
// multi-status response with <response>/<status> element indicating success
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
called = true
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(0, response.properties.size)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// multi-status response with <response>/<propstat> element
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype></resourcetype>" +
|
||||
" <displayname>My DAV Collection</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
|
||||
called = true
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// multi-status response for collection with several members; incomplete (not all <resourcetype>s listed)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>" + url.toString() + "</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" <displayname>My DAV Collection</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>/dav/subcollection</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" <displayname>A Subfolder</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>/dav/uid@host:file</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Absolute path with @ and :</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>relative-uid@host.file</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Relative path with @</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>relative:colon.vcf</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Relative path with colon</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>/something-very/else</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Not requested</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
var nrCalled = 0
|
||||
dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation ->
|
||||
when (response.href) {
|
||||
url.resolve("/dav/") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
|
||||
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/subcollection/") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
|
||||
assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/uid@host:file") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/relative-uid@host.file") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/relative:colon.vcf") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(4, nrCalled)
|
||||
|
||||
|
||||
/*** SPECIAL CASES ***/
|
||||
|
||||
// same property is sent as 200 OK and 404 Not Found in same <response> (seen in iCloud)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>" + url.toString() + "</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" <displayname>My DAV Collection</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype/>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 404 Not Found</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
|
||||
called = true
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(url.resolve("/dav/"), response.href)
|
||||
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
|
||||
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// multi-status response with <propstat> that doesn't contain <status> (=> assume 200 OK)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Without Status</displayname>" +
|
||||
" </prop>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, DisplayName.NAME) { response, _ ->
|
||||
called = true
|
||||
assertEquals(200, response.propstat.first().status.code)
|
||||
assertEquals("Without Status", response[DisplayName::class.java]?.displayName)
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProppatch() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
// multi-status response with <response>/<propstat> elements
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:s='sample'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <s:setThis>Some Value</s:setThis>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <s:removeThis/>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 404 Not Found</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
var called = false
|
||||
dav.proppatch(
|
||||
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
|
||||
removeProperties = listOf(Property.Name("sample", "removeThis"))
|
||||
) { response, hrefRelation ->
|
||||
called = true
|
||||
assertEquals(Response.HrefRelation.SELF, hrefRelation)
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProppatch_createProppatchXml() {
|
||||
val xml = DavResource.createProppatchXml(
|
||||
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
|
||||
removeProperties = listOf(Property.Name("sample", "removeThis"))
|
||||
)
|
||||
assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
|
||||
"<d:propertyupdate xmlns:d=\"DAV:\">" +
|
||||
"<d:set><d:prop><n1:setThis xmlns:n1=\"sample\">Some Value</n1:setThis></d:prop></d:set>" +
|
||||
"<d:remove><d:prop><n2:removeThis xmlns:n2=\"sample\" /></d:prop></d:remove>" +
|
||||
"</d:propertyupdate>", xml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPut() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 201 Created
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_CREATED)
|
||||
.setHeader("ETag", "W/\"Weak PUT ETag\""))
|
||||
var called = false
|
||||
dav.put(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
|
||||
called = true
|
||||
val eTag = GetETag.fromResponse(response)!!
|
||||
assertEquals("Weak PUT ETag", eTag.eTag)
|
||||
assertTrue(eTag.weak!!)
|
||||
assertEquals(response.request.url, dav.location)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("PUT", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertNull(rq.getHeader("If-Match"))
|
||||
assertNull(rq.getHeader("If-None-Match"))
|
||||
|
||||
// precondition: If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
|
||||
.setHeader("Location", "/target"))
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
called = false
|
||||
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), ifNoneMatch = true) { response ->
|
||||
called = true
|
||||
assertEquals(url.resolve("/target"), response.request.url)
|
||||
val eTag = GetETag.fromResponse(response)
|
||||
assertNull("Weak PUT ETag", eTag?.eTag)
|
||||
assertNull(eTag?.weak)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
mockServer.takeRequest()
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("PUT", rq.method)
|
||||
assertEquals("*", rq.getHeader("If-None-Match"))
|
||||
|
||||
// precondition: If-Match, 412 Precondition Failed
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_PRECON_FAILED))
|
||||
called = false
|
||||
try {
|
||||
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), "ExistingETag") {
|
||||
called = true
|
||||
}
|
||||
fail("Expected PreconditionFailedException")
|
||||
} catch(_: PreconditionFailedException) {}
|
||||
assertFalse(called)
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("\"ExistingETag\"", rq.getHeader("If-Match"))
|
||||
assertNull(rq.getHeader("If-None-Match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearch() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Found something</displayname>" +
|
||||
" </prop>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
var called = false
|
||||
dav.search("<TEST/>") { response, hrefRelation, ->
|
||||
assertEquals(Response.HrefRelation.SELF, hrefRelation)
|
||||
assertEquals("Found something", response[DisplayName::class.java]?.displayName)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
val rq = mockServer.takeRequest()
|
||||
assertEquals("SEARCH", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals("<TEST/>", rq.body.readString(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
|
||||
/** test helpers **/
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testAssertMultiStatus_NoBody_NoXML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testAssertMultiStatus_NoBody_XML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "text/xml")
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_NonXML_ButContentIsXML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "application/octet-stream")
|
||||
.body("<?xml version=\"1.0\"><test/>".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_NonXML_ReallyNotXML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "text/plain")
|
||||
.body("Some error occurred".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testAssertMultiStatus_Not207() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(403).message("Multi-Status")
|
||||
.addHeader("Content-Type", "application/xml")
|
||||
.body("".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_Ok_ApplicationXml() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "application/xml")
|
||||
.body("".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_Ok_TextXml() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "text/xml")
|
||||
.body("".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ErrorTest {
|
||||
|
||||
@Test
|
||||
fun testEquals() {
|
||||
val errors = listOf(Error(Property.Name("DAV:", "valid-sync-token")))
|
||||
assertTrue(errors.contains(Error.VALID_SYNC_TOKEN))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class HttpUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testFilename() {
|
||||
assertEquals("", HttpUtils.fileName("https://example.com".toHttpUrl()))
|
||||
assertEquals("", HttpUtils.fileName("https://example.com/".toHttpUrl()))
|
||||
assertEquals("file1", HttpUtils.fileName("https://example.com/file1".toHttpUrl()))
|
||||
assertEquals("dir1", HttpUtils.fileName("https://example.com/dir1/".toHttpUrl()))
|
||||
assertEquals("file2", HttpUtils.fileName("https://example.com/dir1/file2".toHttpUrl()))
|
||||
assertEquals("dir2", HttpUtils.fileName("https://example.com/dir1/dir2/".toHttpUrl()))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Covers what Agendula changed while vendoring, and the two upstream defects
|
||||
* `docs/SYNC.md` says we would inherit. Everything else in this directory is
|
||||
* upstream's own suite, unmodified. See `dav/PROVENANCE.md`.
|
||||
*/
|
||||
class LocalChangesTest {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@Before fun startServer() = mockServer.start()
|
||||
@After fun stopServer() = mockServer.shutdown()
|
||||
|
||||
private fun redirect(code: Int, to: String) = MockResponse()
|
||||
.setResponseCode(code)
|
||||
.setHeader("Location", to)
|
||||
|
||||
// -- change 3: dav4jvm#209, permanent redirects never reached the caller ---
|
||||
|
||||
@Test
|
||||
fun permanentRedirectIsReportedSoTheCallerCanPersistIt() {
|
||||
mockServer.enqueue(redirect(301, "/moved/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a308IsPermanentToo() {
|
||||
mockServer.enqueue(redirect(308, "/moved/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTemporaryRedirectIsNotPersisted() {
|
||||
mockServer.enqueue(redirect(302, "/elsewhere/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertNull(resource.permanentLocation)
|
||||
// The in-flight location still moves, as it must.
|
||||
assertEquals(mockServer.url("/elsewhere/"), resource.location)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun permanentLocationDescribesOnlyTheRequestJustMade() {
|
||||
// DavResource objects are reused. A value left over from an earlier
|
||||
// request would have the caller persist a URL that a later one replaced,
|
||||
// and every sync after that 404s.
|
||||
mockServer.enqueue(redirect(301, "/moved/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
|
||||
resource.head { }
|
||||
assertNull(resource.permanentLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTemporaryHopEndsThePermanentChain() {
|
||||
// 301 -> 302 means "the resource moved to /moved/, and /moved/ is being
|
||||
// served from /temp/ for now". Persisting /temp/ would be wrong.
|
||||
mockServer.enqueue(redirect(301, "/moved/"))
|
||||
mockServer.enqueue(redirect(302, "/temp/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
assertEquals(mockServer.url("/temp/"), resource.location)
|
||||
}
|
||||
|
||||
// -- the "303 is not handled" claim, checked rather than assumed -----------
|
||||
|
||||
@Test
|
||||
fun a303IsFollowed() {
|
||||
// `docs/SYNC.md` lists "handles 301/302/307/308 but not 303" as a defect
|
||||
// we would inherit. It does not hold for this version: OkHttp's
|
||||
// Response.isRedirect includes HTTP_SEE_OTHER, and followRedirects re-sends
|
||||
// the same method, which is what RFC 6764 section 5 asks for in discovery.
|
||||
mockServer.enqueue(redirect(303, "/other/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/other/"), resource.location)
|
||||
}
|
||||
|
||||
// -- change 1: commons-lang3 removed from date parsing --------------------
|
||||
|
||||
/**
|
||||
* Runs [block] with a deliberately non-UTC default zone.
|
||||
*
|
||||
* The defect change 2 fixes *is* "the formatter used the default zone", so a
|
||||
* test that does not move the default zone passes identically before and
|
||||
* after the fix — and CI containers default to UTC. Without this the whole
|
||||
* change is pinned by nothing.
|
||||
*/
|
||||
private fun <T> inNonUtcZone(block: () -> T): T {
|
||||
val previous = TimeZone.getDefault()
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")) // UTC+05:30
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
TimeZone.setDefault(previous)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatDateEmitsGmtRatherThanLocalTimeLabelledGmt() = inNonUtcZone {
|
||||
assertEquals(
|
||||
"Wed, 21 Oct 2015 07:28:00 GMT",
|
||||
HttpUtils.formatDate(java.util.Date(1445412480000L)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateAcceptsTheRfc7231Format() = inNonUtcZone {
|
||||
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateHonoursAnExplicitOffsetInsteadOfMatchingGmtAsAPrefix() {
|
||||
// Pattern 1 ends in the *literal* 'GMT', and SimpleDateFormat.parse is
|
||||
// happy with a prefix — so without a full-consumption check the "+02:00"
|
||||
// is discarded and the result is two hours out, which is the same class of
|
||||
// error change 2 set out to remove.
|
||||
assertEquals(1445405280000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT+02:00")!!.time)
|
||||
assertEquals(1445430480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT-05:00")!!.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateRejectsTrailingGarbageRatherThanMatchingAPrefix() {
|
||||
assertNull(HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT and then some"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateAcceptsTheObsoleteFormats() {
|
||||
// RFC 850, and ANSI C asctime() — both still emitted in the wild.
|
||||
assertTrue(HttpUtils.parseDate("Wednesday, 21-Oct-15 07:28:00 GMT") != null)
|
||||
assertTrue(HttpUtils.parseDate("Wed Oct 21 07:28:00 2015") != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateReturnsNullRatherThanThrowing() {
|
||||
assertNull(HttpUtils.parseDate("not a date at all"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateIsIndependentOfTheDefaultLocale() {
|
||||
// SimpleDateFormat defaults to the platform locale, which would reject
|
||||
// English month names on a Turkish or German device.
|
||||
val locale = Locale.getDefault()
|
||||
val zone = TimeZone.getDefault()
|
||||
try {
|
||||
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Istanbul"))
|
||||
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
|
||||
} finally {
|
||||
Locale.setDefault(locale)
|
||||
TimeZone.setDefault(zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class QuotedStringUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testAsQuotedString() {
|
||||
assertEquals("\"\"", QuotedStringUtils.asQuotedString(""))
|
||||
assertEquals("\"\\\"\"", QuotedStringUtils.asQuotedString("\""))
|
||||
assertEquals("\"\\\\\"", QuotedStringUtils.asQuotedString("\\"))
|
||||
}
|
||||
|
||||
fun testDecodeQuotedString() {
|
||||
assertEquals("\"", QuotedStringUtils.decodeQuotedString("\""))
|
||||
assertEquals("\\", QuotedStringUtils.decodeQuotedString("\"\\\""))
|
||||
assertEquals("\"test", QuotedStringUtils.decodeQuotedString("\"test"))
|
||||
assertEquals("test", QuotedStringUtils.decodeQuotedString("test"))
|
||||
assertEquals("", QuotedStringUtils.decodeQuotedString("\"\""))
|
||||
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"test\""))
|
||||
assertEquals("test\\", QuotedStringUtils.decodeQuotedString("\"test\\\""))
|
||||
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"t\\e\\st\""))
|
||||
assertEquals("12\"34", QuotedStringUtils.decodeQuotedString("\"12\\\"34\""))
|
||||
assertEquals("1234\"", QuotedStringUtils.decodeQuotedString("\"1234\\\"\""))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class UrlUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testEquals() {
|
||||
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("http://host:80/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("https://HOST:443/resource".toHttpUrlOrNull()!!, "https://host/resource".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("https://host:443/my@dav/".toHttpUrlOrNull()!!, "https://host/my%40dav/".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource#frag1".toHttpUrlOrNull()!!))
|
||||
|
||||
// should work, but currently doesn't (see MR #5)
|
||||
// assertTrue(UrlUtils.equals(HttpUrl.parse("https://host/%5bresource%5d/")!!, HttpUrl.parse("https://host/[resource]/")!!))
|
||||
|
||||
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource/".toHttpUrlOrNull()!!))
|
||||
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host:81/resource".toHttpUrlOrNull()!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHostToDomain() {
|
||||
assertNull(UrlUtils.hostToDomain(null))
|
||||
assertEquals("", UrlUtils.hostToDomain("."))
|
||||
assertEquals("com", UrlUtils.hostToDomain("com"))
|
||||
assertEquals("com", UrlUtils.hostToDomain("com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("example.com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain(".example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain(".example.com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOmitTrailingSlash() {
|
||||
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWithTrailingSlash() {
|
||||
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
|
||||
class XmlUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testProcessTagRoot() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test></test>"))
|
||||
// now on START_DOCUMENT [0]
|
||||
|
||||
var processed = false
|
||||
XmlUtils.processTag(parser, Property.Name("", "test")) {
|
||||
processed = true
|
||||
}
|
||||
assertTrue(processed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProcessTagDepth1() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<root><test></test></root>"))
|
||||
parser.next() // now on START_TAG <root>
|
||||
|
||||
var processed = false
|
||||
XmlUtils.processTag(parser, Property.Name("", "test")) {
|
||||
processed = true
|
||||
}
|
||||
assertTrue(processed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadText() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<root><test>Test 1</test><test><garbage/>Test 2</test></root>"))
|
||||
parser.next()
|
||||
parser.next() // now on START_TAG <test>
|
||||
|
||||
assertEquals("Test 1", XmlUtils.readText(parser))
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
parser.next()
|
||||
|
||||
assertEquals("Test 2", XmlUtils.readText(parser))
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadTextCDATA() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test><![CDATA[Test 1</test><test><garbage/>Test 2]]></test>"))
|
||||
parser.next() // now on START_TAG <test>
|
||||
|
||||
assertEquals("Test 1</test><test><garbage/>Test 2", XmlUtils.readText(parser))
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadTextPropertyRoot() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<root><entry>Test 1</entry><entry>Test 2</entry></root>"))
|
||||
parser.next() // now on START_TAG <root>
|
||||
|
||||
val entries = mutableListOf<String>()
|
||||
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
|
||||
assertEquals("Test 1", entries[0])
|
||||
assertEquals("Test 2", entries[1])
|
||||
|
||||
parser.next() // END_TAG </root>
|
||||
assertEquals(XmlPullParser.END_DOCUMENT, parser.eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadTextPropertyListDepth1() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test><entry>Test 1</entry><entry>Test 2</entry></test>"))
|
||||
parser.next() // now on START_TAG <test> [1]
|
||||
|
||||
val entries = mutableListOf<String>()
|
||||
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
|
||||
assertEquals("Test 1", entries[0])
|
||||
assertEquals("Test 2", entries[1])
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
assertEquals("test", parser.name)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.io.ObjectOutputStream
|
||||
|
||||
class DavExceptionTest {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
private val mockServer = MockWebServer()
|
||||
private fun sampleUrl() = mockServer.url("/dav/")
|
||||
|
||||
@Before
|
||||
fun startServer() = mockServer.start()
|
||||
|
||||
@After
|
||||
fun stopServer() = mockServer.shutdown()
|
||||
|
||||
|
||||
/**
|
||||
* Test truncation of a too large plain text request in [DavException].
|
||||
*/
|
||||
@Test
|
||||
fun testRequestLargeTextError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
val builder = StringBuilder()
|
||||
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE+100) { '*' })
|
||||
val body = builder.toString()
|
||||
|
||||
val e = DavException("Error with large request body", null, Response.Builder()
|
||||
.request(Request.Builder()
|
||||
.url("http://example.com")
|
||||
.post(body.toRequestBody("text/plain".toMediaType()))
|
||||
.build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(204)
|
||||
.message("No Content")
|
||||
.build())
|
||||
|
||||
assertTrue(e.errors.isEmpty())
|
||||
assertEquals(
|
||||
body.substring(0, DavException.MAX_EXCERPT_SIZE),
|
||||
e.requestBody
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a large HTML response which has a multi-octet UTF-8 character
|
||||
* exactly at the cut-off position.
|
||||
*/
|
||||
@Test
|
||||
fun testResponseLargeTextError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
val builder = StringBuilder()
|
||||
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1) { '*' })
|
||||
builder.append("\u03C0") // Pi
|
||||
val body = builder.toString()
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody(body))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(e.code, 404)
|
||||
assertTrue(e.errors.isEmpty())
|
||||
assertEquals(
|
||||
body.substring(0, DavException.MAX_EXCERPT_SIZE-1),
|
||||
e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResponseNonTextError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(403)
|
||||
.setHeader("Content-Type", "application/octet-stream")
|
||||
.setBody("12345"))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(e.code, 403)
|
||||
assertTrue(e.errors.isEmpty())
|
||||
assertNull(e.responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(500)
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.setBody("12345"))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected DavException")
|
||||
} catch (e: DavException) {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val oos = ObjectOutputStream(baos)
|
||||
oos.writeObject(e)
|
||||
|
||||
val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray()))
|
||||
val e2 = ois.readObject() as HttpException
|
||||
assertEquals(500, e2.code)
|
||||
assertTrue(e2.responseBody!!.contains("12345"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test precondition XML element (sample from RFC 4918 16)
|
||||
*/
|
||||
@Test
|
||||
fun testXmlError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
val body = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
"<D:error xmlns:D=\"DAV:\">\n" +
|
||||
" <D:lock-token-submitted>\n" +
|
||||
" <D:href>/workspace/webdav/</D:href>\n" +
|
||||
" </D:lock-token-submitted>\n" +
|
||||
"</D:error>\n"
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(423)
|
||||
.setHeader("Content-Type", "application/xml; charset=\"utf-8\"")
|
||||
.setBody(body))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(e.code, 423)
|
||||
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "lock-token-submitted") })
|
||||
assertEquals(body, e.responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HttpExceptionTest {
|
||||
|
||||
private val responseMessage = "Unknown error"
|
||||
|
||||
@Test
|
||||
fun testHttpFormatting() {
|
||||
val request = Request.Builder()
|
||||
.post("REQUEST\nBODY".toRequestBody("text/something".toMediaType()))
|
||||
.url("http://example.com")
|
||||
.build()
|
||||
|
||||
val response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(500)
|
||||
.message(responseMessage)
|
||||
.body("SERVER\r\nRESPONSE".toResponseBody("text/something-other".toMediaType()))
|
||||
.build()
|
||||
val e = HttpException(response)
|
||||
assertTrue(e.message!!.contains("500"))
|
||||
assertTrue(e.message!!.contains(responseMessage))
|
||||
assertTrue(e.requestBody!!.contains("REQUEST\nBODY"))
|
||||
assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
class ServiceUnavailableExceptionTest {
|
||||
|
||||
val response503 = Response.Builder()
|
||||
.request(Request.Builder()
|
||||
.url("http://www.example.com")
|
||||
.get()
|
||||
.build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(503).message("Try later")
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun testRetryAfter_NoTime() {
|
||||
val e = ServiceUnavailableException(response503)
|
||||
assertNull(e.retryAfter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRetryAfter_Seconds() {
|
||||
val response = response503.newBuilder()
|
||||
.header("Retry-After", "120")
|
||||
.build()
|
||||
val e = ServiceUnavailableException(response)
|
||||
assertNotNull(e.retryAfter)
|
||||
assertTrue(withinTimeRange(e.retryAfter!!, 120))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRetryAfter_Date() {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MINUTE, 30)
|
||||
val response = response503.newBuilder()
|
||||
.header("Retry-After", HttpUtils.formatDate(cal.time))
|
||||
.build()
|
||||
val e = ServiceUnavailableException(response)
|
||||
assertNotNull(e.retryAfter)
|
||||
assertTrue(withinTimeRange(e.retryAfter!!, 30*60))
|
||||
}
|
||||
|
||||
|
||||
private fun withinTimeRange(d: Date, seconds: Int): Boolean {
|
||||
val msCheck = d.time
|
||||
val msShouldBe = Date().time + seconds*1000
|
||||
// assume max. 5 seconds difference for test running
|
||||
return abs(msCheck - msShouldBe) < 5000
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CalendarDescriptionTest: PropertyTest() {
|
||||
|
||||
@Test
|
||||
fun testCalendarDescription() {
|
||||
val results = parseProperty("<calendar-description xmlns=\"urn:ietf:params:xml:ns:caldav\">My Calendar</calendar-description>")
|
||||
val result = results.first() as CalendarDescription
|
||||
assertEquals("My Calendar", result.description)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class GetETagTest: PropertyTest() {
|
||||
|
||||
@Test
|
||||
fun testGetETag_NoText() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\"><invalid/></getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertNull(getETag.eTag)
|
||||
assertNull(getETag.weak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Strong() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">\"Correct strong ETag\"</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Correct strong ETag", getETag.eTag)
|
||||
assertFalse(getETag.weak!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Strong_NoQuotes() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">Strong ETag without quotes</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Strong ETag without quotes", getETag.eTag)
|
||||
assertFalse(getETag.weak!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Weak() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">W/\"Correct weak ETag\"</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Correct weak ETag", getETag.eTag)
|
||||
assertTrue(getETag.weak!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Weak_NoQuotes() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">W/Weak ETag without quotes</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Weak ETag without quotes", getETag.eTag)
|
||||
assertTrue(getETag.weak!!)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OwnerTest: PropertyTest() {
|
||||
|
||||
@Test
|
||||
fun testOwner_PlainText() {
|
||||
val results = parseProperty("<owner xmlns=\"DAV:\">https://example.com</owner>")
|
||||
val owner = results.first() as Owner
|
||||
assertNull(owner.href)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOwner_PlainTextAndHref() {
|
||||
val results = parseProperty("<owner xmlns=\"DAV:\">Principal Name. <href>mailto:owner@example.com</href> (test)</owner>")
|
||||
val owner = results.first() as Owner
|
||||
assertEquals("mailto:owner@example.com", owner.href)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOwner_Href() {
|
||||
val results = parseProperty("<owner xmlns=\"DAV:\"><href>https://example.com</href></owner>")
|
||||
val owner = results.first() as Owner
|
||||
assertEquals("https://example.com", owner.href)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import java.io.StringReader
|
||||
|
||||
open class PropertyTest {
|
||||
|
||||
companion object {
|
||||
|
||||
fun parseProperty(s: String): List<Property> {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test>$s</test>"))
|
||||
parser.nextTag() // move into <test>
|
||||
return Property.parse(parser)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+42
-6
@@ -56,7 +56,8 @@ Nextcloud/Radicale/Baïkal in chunk 5.
|
||||
| # | Chunk | Rests on | The thing it must not get wrong |
|
||||
|---|---|---|---|
|
||||
| 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle |
|
||||
| 2 | **Auth, discovery, account** | 1 (nothing hard) | Never send a credential into an unvalidated redirect chain |
|
||||
| 2a | **Vendored DAV foundation** | nothing | Vendor a tree we can actually maintain, and know which of its defects are real |
|
||||
| 2b | **Auth, discovery, account** | 2a | Never send a credential into an unvalidated redirect chain |
|
||||
| 3 | **Engine core** — read, write, conflicts | 1, 2 | A failed resource must not fail the collection |
|
||||
| 4 | **Incremental sync + scheduling** | 3 | Never persist a sync token before the bodies it covers are applied |
|
||||
| 5 | **Hardening, compliance, real servers** | 1–4 | Ship no licence violation, and no retry loop on a dead app password |
|
||||
@@ -172,7 +173,34 @@ and no fixture loses an unknown property.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2 — auth, discovery, account
|
||||
## Chunk 2a — the vendored DAV foundation
|
||||
|
||||
⚠️ **Split out of chunk 2 while building it.** Vendoring is a self-contained
|
||||
piece with its own verification — upstream's suite has to pass unmodified before
|
||||
anything is built on top — and bundling it with discovery and auth would have
|
||||
produced one commit nobody could review.
|
||||
|
||||
**Version:** dav4jvm **2.2.1**, the last OkHttp release. 3.0.0 deleted the OkHttp
|
||||
package for Ktor and 4.x requires Java 21, and `SYNC.md`'s entire auth section is
|
||||
written in OkHttp terms — preemptive Basic via an `Interceptor`, OkHttp stripping
|
||||
`Authorization` on cross-host redirects, `BasicDigestAuthHandler` because OkHttp
|
||||
has no Digest. Taking 4.x would have invalidated all of it. A plain JVM module,
|
||||
not an Android library: the tree has zero Android imports and must keep it that
|
||||
way, or the floret-kit extraction stops being a file move.
|
||||
|
||||
Full record in [`dav/PROVENANCE.md`](../dav/PROVENANCE.md). Of the two defects
|
||||
`SYNC.md` said we would inherit, **one was not real** — 2.2.1 does follow 303 —
|
||||
and the other (`dav4jvm#209`, permanent redirects never reaching the caller) is
|
||||
fixed. A third, which the audit did not have, was found by writing the tests
|
||||
upstream never wrote: **HTTP dates were parsed and formatted in the device's
|
||||
local zone**, putting every `getlastmodified` out by the local UTC offset.
|
||||
|
||||
**Done when:** upstream's 63 tests pass unmodified, `commons-lang3` and the xpp3
|
||||
runtime dependency are gone, and `:app` links against the module.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2b — auth, discovery, account
|
||||
|
||||
**Goal:** the app can add a CalDAV account and list its VTODO collections. No
|
||||
syncing yet.
|
||||
@@ -438,10 +466,18 @@ attribution screen ships, and a `releaseTest` build syncs on a real device.
|
||||
## Definition of done, every chunk
|
||||
|
||||
1. It builds: `./gradlew :app:assembleDebug`.
|
||||
2. Unit tests pass: `./gradlew :app:testDebugUnitTest`.
|
||||
3. `/code-review high` on the chunk's diff, with the findings either fixed or
|
||||
answered in the commit message.
|
||||
4. One commit per chunk, referencing the chunk number. No release, no tag.
|
||||
2. Unit tests pass: `./gradlew :app:testDebugUnitTest :dav:test`. ⚠️ Name
|
||||
`:dav:test` explicitly — `testDebugUnitTest` is an Android-variant task and
|
||||
does not exist on a plain JVM module, so the vendored suite would otherwise be
|
||||
compiled by nobody and run by nobody.
|
||||
3. Lint passes: `./gradlew :app:lintDebug`. Added after chunk 1 shipped a literal
|
||||
byte-order mark that builds and tests both accepted and CI's lint step would
|
||||
have rejected.
|
||||
4. `/code-review high` on the chunk's diff, with the findings either fixed or
|
||||
answered in the commit message. For a chunk that vendors code, scope the
|
||||
review to the files we wrote — reviewing thousands of lines of verbatim
|
||||
upstream is noise, and its defects belong in `PROVENANCE.md`.
|
||||
5. One commit per chunk, referencing the chunk number. No release, no tag.
|
||||
|
||||
Chunk 2 additionally does not count as done until the account-add UI has had an
|
||||
**on-device review with an explicit go-ahead** — `CLAUDE.md`'s rule, and this is
|
||||
|
||||
@@ -33,6 +33,16 @@ androidxTestRules = "1.7.0"
|
||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||
glance = "1.1.1"
|
||||
|
||||
# --- :dav (vendored dav4jvm) -------------------------------------------------
|
||||
# dav4jvm 2.2.1 is the last OkHttp release; 3.0.0 deleted the OkHttp package for
|
||||
# Ktor and 4.x additionally requires Java 21 bytecode, which we do not target.
|
||||
# See dav/PROVENANCE.md. OkHttp 4.12.0 is what the vendored tree was written
|
||||
# against and what DAVx5 ships.
|
||||
okhttp = "4.12.0"
|
||||
# org.xmlpull.v1 ships in the Android framework, so xpp3 is compileOnly +
|
||||
# testImplementation and never reaches the APK.
|
||||
xpp3 = "1.1.6"
|
||||
|
||||
# --- :provider (vendored dmfs task provider) ---------------------------------
|
||||
# Versions the upstream 1.4.2 source was written against. These are its runtime
|
||||
# dependencies, not ours — nothing above the data layer touches them, and they
|
||||
@@ -125,6 +135,11 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material
|
||||
# Android tests - GrantPermissionRule
|
||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||
|
||||
# :dav — vendored dav4jvm (see dav/PROVENANCE.md)
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||
xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
|
||||
|
||||
# :provider — vendored dmfs task provider (see provider/PROVENANCE.md)
|
||||
dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" }
|
||||
dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" }
|
||||
|
||||
@@ -26,4 +26,5 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "Agendula"
|
||||
include(":app")
|
||||
include(":dav")
|
||||
includeBuild("floret-kit")
|
||||
|
||||
Reference in New Issue
Block a user