diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..8fb2370 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,7 @@ +You can view the list of people who have contributed to the code base in the version control history: +https://github.com/bitfireAT/davx5-ose/graphs/contributors + +Translators are not mentioned in the history explicitly. +The list of translators can be found in the About screen. + +Every contribution is welcome. There are many other forms of contributing besides writing code! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..010673d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,117 @@ + +**Thank you for your interest in contributing to DAVx⁵!** + + +# Licensing + +All work in this repository is [licensed under the GPLv3](LICENSE). + +We (bitfire.at, initial and main contributors) are also asking you to give us +permission to use your contribution for related non-open source projects +like [Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5). + +If you send us a pull request, our CLA bot will ask you to sign the +Contributor's License Agreement so that we can use your contribution. + + +# Copyright + +Make sure that every file that contains significant work (at least every code file) +starts with the copyright header: + +``` +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +``` + +You can set this in Android Studio: + +1. Settings / Editor / Copyright / Copyright Profiles +2. Paste the text above (without the stars). +3. Set Formatting so that the preview exactly looks like above; one blank line after the block. +4. Set this copyright profile as the default profile for the project. +5. Apply copyright: right-click in file tree / Update copyright. + + +# Style guide + +Please adhere to the [Kotlin style guide](https://developer.android.com/kotlin/style-guide) and +the following hints to make the source code uniform. + +**Have a look at similar files and copy their style if you're not certain.** + +Sample file (pay attention to blank lines and other formatting): + +``` + + +class MyClass(int arg1) : SuperClass() { + + companion object { + + const val CONSTANT_STRING = "Constant String"; + + fun staticMethod() { // Use static methods when you don't need the object context. + // … + } + + } + + var someProperty: String = "12345" + var someRelatedProperty: Int = 12345 + + init { + // constructor + } + + + /** + * Use KDoc to document important methods. Don't use it dogmatically, but writing proper documentation + * (not just the method name with spaces) helps you to re-think what the method shall really do. + */ + fun aFun1() { // Group methods by some logic (for instance, the order in which they will be called) + } // and alphabetically within a group. + + fun anotherFun() { + // … + } + + + fun somethingCompletelyDifferent() { // two blank lines to separate groups + } + + fun helperForSomethingCompletelyDifferent() { + someCall(arg1, arg2, arg3, arg4) // function calls: stick to one line unless it becomes confusing + } + + + class Model( // two blank lines before inner classes + someArgument: SomeLongClass, // arguments in multiple lines when they're too long for one line + anotherArgument: AnotherLongType, + thirdArgument: AnotherLongTypeName + ) : ViewModel() { + + fun abc() { + } + + } + +} +``` + +In general, use one blank line to separate things within one group of things, and two blank lines +to separate groups. In rare cases, when methods are tightly coupled and are only helpers for another +method, they may follow the calling method without separating blank lines. + +## Tests + +Test classes should be in the appropriate directory (see existing tests) and in the same package as the +tested class. Tests are usually be named like `methodToBeTested_Condition()`, see +[Test apps on Android](https://developer.android.com/training/testing/). + + +# Authors + +If you make significant contributions, feel free to add yourself to the [AUTHORS file](AUTHORS). + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index cdffa2b..f1bcccb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ -# davx5 -Contact and Calendar Synchronisation Android \ No newline at end of file +[![Website](https://img.shields.io/website?style=flat-square&up_color=%237cb342&url=https%3A%2F%2Fwww.davx5.com)](https://www.davx5.com/) +[![F-Droid](https://img.shields.io/f-droid/v/at.bitfire.davdroid?style=flat-square)](https://f-droid.org/packages/at.bitfire.davdroid/) +[![License](https://img.shields.io/github/license/bitfireAT/davx5-ose?style=flat-square)](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE) +[![Follow @davx5app@fosstodon.org](https://img.shields.io/mastodon/follow/109598783742737223?domain=https%3A%2F%2Ffosstodon.org&style=flat-square)](https://fosstodon.org/@davx5app) +[![Development tests](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml/badge.svg)](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml) + +![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) + + +DAVx⁵ +======== + +Please see the [DAVx⁵ Web site](https://www.davx5.com) for +comprehensive information about DAVx⁵, including a list of services it has been tested with. + +DAVx⁵ is licensed under the [GPLv3 License](LICENSE). + +News and updates: + +* [@davx5app@fosstodon.org](https://fosstodon.org/@davx5app) on Mastodon + +**Help, feature requests, bug reports: [DAVx⁵ discussions](https://github.com/bitfireAT/davx5-ose/discussions)** + +Parts of DAVx⁵ have been outsourced into these libraries: + +* [cert4android](https://github.com/bitfireAT/cert4android) – custom certificate management +* [dav4jvm](https://github.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework +* [synctools](https://github.com/bitfireAT/synctools) – iCalendar/vCard/Tasks processing and content provider access + +**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate) +or [purchasing it](https://www.davx5.com/download).** + + +USED THIRD-PARTY LIBRARIES +========================== + +The most important libraries which are used by DAVx⁵ (alphabetically): + +* [dnsjava](https://github.com/dnsjava/dnsjava) – [BSD License](https://github.com/dnsjava/dnsjava/blob/master/LICENSE) +* [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](https://github.com/mangstadt/ez-vcard/blob/master/LICENSE) +* [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](https://github.com/ical4j/ical4j/blob/develop/LICENSE.txt) +* [okhttp](https://square.github.io/okhttp) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license) + +See _About / Libraries_ in the app for all used libraries and their licenses. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7161044 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities using our [secure support form](https://www.davx5.com/support) or via email to support-en@davx5.com. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..6eeec87 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +build +target diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..45fd005 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,227 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + + alias(libs.plugins.mikepenz.aboutLibraries.android) +} + +// Android configuration +android { + compileSdk = 36 + + defaultConfig { + applicationId = "at.bitfire.davdroid" + + versionCode = 405050004 + versionName = "4.5.5" + + base.archivesName = "davx5-ose-$versionName" + + minSdk = 24 // Android 7.0 + targetSdk = 36 // Android 16 + + buildConfigField("boolean", "customCertsUI", "true") + + testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner" + } + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + compileOptions { + // required for + // - dnsjava 3.x: java.nio.file.Path + // - ical4android: time API + isCoreLibraryDesugaringEnabled = true + } + + buildFeatures { + buildConfig = true + compose = true + } + + // Java namespace for our classes (not to be confused with Android package ID) + namespace = "at.bitfire.davdroid" + + flavorDimensions += "distribution" + productFlavors { + create("ose") { + dimension = "distribution" + versionNameSuffix = "-ose" + } + } + + sourceSets { + getByName("androidTest") { + assets.srcDir("$projectDir/schemas") + } + } + + signingConfigs { + create("bitfire") { + storeFile = file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null") + storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules-release.pro") + + isShrinkResources = true + + signingConfig = signingConfigs.findByName("bitfire") + } + } + + lint { + disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos") + } + + androidResources { + generateLocaleConfig = true + } + + packaging { + resources { + // multiple (test) dependencies have LICENSE files at same location + merges += arrayOf("META-INF/LICENSE*") + } + } + + @Suppress("UnstableApiUsage") + testOptions { + managedDevices { + localDevices { + create("virtual") { + device = "Pixel 3" + // TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525 + // Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + } + } +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +aboutLibraries { + export { + // exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994] + excludeFields.add("generated") + } +} + +dependencies { + // core + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines) + coreLibraryDesugaring(libs.android.desugaring) + + // Hilt + implementation(libs.hilt.android.base) + ksp(libs.androidx.hilt.compiler) + ksp(libs.hilt.android.compiler) + + // support libs + implementation(libs.androidx.activityCompose) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.browser) + implementation(libs.androidx.core) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.base) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.paging) + implementation(libs.androidx.paging.compose) + implementation(libs.androidx.preference) + implementation(libs.androidx.security) + implementation(libs.androidx.work.base) + + // Jetpack Compose + implementation(libs.compose.accompanist.permissions) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.material3) + implementation(libs.compose.materialIconsExtended) + debugImplementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.toolingPreview) + + // Glance Widgets + implementation(libs.glance.base) + implementation(libs.glance.material) + + // Jetpack Room + implementation(libs.room.runtime) + implementation(libs.room.base) + implementation(libs.room.paging) + ksp(libs.room.compiler) + + // own libraries + implementation(libs.bitfire.cert4android) + implementation(libs.bitfire.dav4jvm) { + exclude(group="junit") + exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation + } + implementation(libs.bitfire.synctools) { + exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code + exclude(group = "junit") + } + + // third-party libs + @Suppress("RedundantSuppression") + implementation(libs.dnsjava) + implementation(libs.guava) + implementation(libs.mikepenz.aboutLibraries.m3) + implementation(libs.okhttp.base) + implementation(libs.okhttp.brotli) + implementation(libs.okhttp.logging) + implementation(libs.openid.appauth) + implementation(libs.unifiedpush) { + // UnifiedPush connector seems to be using a workaround by importing this library. + // Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged. + // See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59 + exclude(group = "com.google.crypto.tink", module = "tink") + } + implementation(libs.unifiedpush.fcm) + + // force some versions for compatibility with our minSdk level (see version catalog for details) + implementation(libs.commons.codec) + implementation(libs.commons.lang) + + // for tests + androidTestImplementation(libs.androidx.arch.core.testing) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.work.testing) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.okhttp.mockwebserver) + androidTestImplementation(libs.room.testing) + + testImplementation(libs.bitfire.dav4jvm) + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.okhttp.mockwebserver) +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..abd3b91 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro new file mode 100644 index 0000000..71fc87f --- /dev/null +++ b/app/proguard-rules-release.pro @@ -0,0 +1,31 @@ + +# R8 usage for DAVx⁵: +# shrinking yes (only in release builds) +# optimization yes (on by R8 defaults) +# full-mode no (see gradle.properties) +# obfuscation no (open-source) + +-dontobfuscate +-printusage build/reports/r8-usage.txt + +# keep rules +-keep class at.bitfire.** { *; } # all DAVx5 code is required +-keep class org.xmlpull.** { *; } + +# Additional rules which are now required since missing classes can't be ignored in R8 anymore. +# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning] +-dontwarn org.xmlpull.** + +# dnsjava +-dontwarn com.sun.jna.** +-dontwarn lombok.** +-dontwarn javax.naming.NamingException +-dontwarn javax.naming.directory.** +-dontwarn sun.net.spi.nameservice.NameService +-dontwarn sun.net.spi.nameservice.NameServiceDescriptor +-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider + +# okhttp +# https://github.com/bitfireAT/davx5/issues/711 / https://github.com/square/okhttp/issues/8574 +-keep class okhttp3.internal.idn.IdnaMappingTable { *; } +-keep class okhttp3.internal.idn.IdnaMappingTableInstanceKt{ *; } diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/10.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/10.json new file mode 100644 index 0000000..f1cee14 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/10.json @@ -0,0 +1,398 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "6fcabe50cbd00a4215dbe536a565dd2a", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fcabe50cbd00a4215dbe536a565dd2a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json new file mode 100644 index 0000000..77f44ee --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/11.json @@ -0,0 +1,536 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "223aa7f0fd53730921ca212a663585d8", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '223aa7f0fd53730921ca212a663585d8')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/12.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/12.json new file mode 100644 index 0000000..ea96701 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/12.json @@ -0,0 +1,615 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "67fafceecee2d97cac6a62d46fa2c3e2", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67fafceecee2d97cac6a62d46fa2c3e2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/13.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/13.json new file mode 100644 index 0000000..7d37106 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/13.json @@ -0,0 +1,640 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "0a6a9705ff471acd766ab96e3edf8ac3", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0a6a9705ff471acd766ab96e3edf8ac3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/14.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/14.json new file mode 100644 index 0000000..9dc16cd --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/14.json @@ -0,0 +1,669 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "9a0eb47f27473eab254db568081a4585", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_ownerId_type", + "unique": false, + "columnNames": [ + "ownerId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)" + }, + { + "name": "index_collection_pushTopic_type", + "unique": false, + "columnNames": [ + "pushTopic", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + }, + { + "name": "index_webdav_document_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a0eb47f27473eab254db568081a4585')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json new file mode 100644 index 0000000..17e8d8e --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/15.json @@ -0,0 +1,675 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "ab1cb6057d8e050f6648bea46ae0943d", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionExpires", + "columnName": "pushSubscriptionExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_ownerId_type", + "unique": false, + "columnNames": [ + "ownerId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)" + }, + { + "name": "index_collection_pushTopic_type", + "unique": false, + "columnNames": [ + "pushTopic", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + }, + { + "name": "index_webdav_document_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab1cb6057d8e050f6648bea46ae0943d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/16.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/16.json new file mode 100644 index 0000000..259bc4e --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/16.json @@ -0,0 +1,675 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "2ff7560d957e03a78b4b7de88aa9593b", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezoneId", + "columnName": "timezoneId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionExpires", + "columnName": "pushSubscriptionExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_ownerId_type", + "unique": false, + "columnNames": [ + "ownerId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)" + }, + { + "name": "index_collection_pushTopic_type", + "unique": false, + "columnNames": [ + "pushTopic", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + }, + { + "name": "index_webdav_document_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ff7560d957e03a78b4b7de88aa9593b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/17.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/17.json new file mode 100644 index 0000000..f1a3e67 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/17.json @@ -0,0 +1,648 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "cd15d368408570cc2e57252816869de2", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "timezoneId", + "columnName": "timezoneId", + "affinity": "TEXT" + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT" + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushVapidKey", + "columnName": "pushVapidKey", + "affinity": "TEXT" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT" + }, + { + "fieldPath": "pushSubscriptionExpires", + "columnName": "pushSubscriptionExpires", + "affinity": "INTEGER" + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_ownerId_type", + "unique": false, + "columnNames": [ + "ownerId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)" + }, + { + "name": "index_collection_pushTopic_type", + "unique": false, + "columnNames": [ + "pushTopic", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER" + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER" + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + }, + { + "name": "index_webdav_document_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd15d368408570cc2e57252816869de2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/18.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/18.json new file mode 100644 index 0000000..2246bcf --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/18.json @@ -0,0 +1,648 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "6a0f7e1553e1f621ae7913ea14370fd0", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "timezoneId", + "columnName": "timezoneId", + "affinity": "TEXT" + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pushTopic", + "columnName": "pushTopic", + "affinity": "TEXT" + }, + { + "fieldPath": "supportsWebPush", + "columnName": "supportsWebPush", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushVapidKey", + "columnName": "pushVapidKey", + "affinity": "TEXT" + }, + { + "fieldPath": "pushSubscription", + "columnName": "pushSubscription", + "affinity": "TEXT" + }, + { + "fieldPath": "pushSubscriptionExpires", + "columnName": "pushSubscriptionExpires", + "affinity": "INTEGER" + }, + { + "fieldPath": "pushSubscriptionCreated", + "columnName": "pushSubscriptionCreated", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_ownerId_type", + "unique": false, + "columnNames": [ + "ownerId", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)" + }, + { + "name": "index_collection_pushTopic_type", + "unique": false, + "columnNames": [ + "pushTopic", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "principal", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "principal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_principal_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `dataType` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dataType", + "columnName": "dataType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_syncstats_collectionId_dataType", + "unique": true, + "columnNames": [ + "collectionId", + "dataType" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_dataType` ON `${TABLE_NAME}` (`collectionId`, `dataType`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_document", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mountId", + "columnName": "mountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDirectory", + "columnName": "isDirectory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "mayBind", + "columnName": "mayBind", + "affinity": "INTEGER" + }, + { + "fieldPath": "mayUnbind", + "columnName": "mayUnbind", + "affinity": "INTEGER" + }, + { + "fieldPath": "mayWriteContent", + "columnName": "mayWriteContent", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotaAvailable", + "columnName": "quotaAvailable", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotaUsed", + "columnName": "quotaUsed", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_webdav_document_mountId_parentId_name", + "unique": true, + "columnNames": [ + "mountId", + "parentId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)" + }, + { + "name": "index_webdav_document_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)" + } + ], + "foreignKeys": [ + { + "table": "webdav_mount", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "webdav_document", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "webdav_mount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6a0f7e1553e1f621ae7913ea14370fd0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/8.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/8.json new file mode 100644 index 0000000..681fb49 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/8.json @@ -0,0 +1,298 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "b8699ef3cc4c62e8851df4360fb69e00", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b8699ef3cc4c62e8851df4360fb69e00')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/at.bitfire.davdroid.db.AppDatabase/9.json b/app/schemas/at.bitfire.davdroid.db.AppDatabase/9.json new file mode 100644 index 0000000..a2a0035 --- /dev/null +++ b/app/schemas/at.bitfire.davdroid.db.AppDatabase/9.json @@ -0,0 +1,366 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "7e4bfdf7f9fa3529c333cf9485f8cf50", + "entities": [ + { + "tableName": "service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_service_accountName_type", + "unique": true, + "columnNames": [ + "accountName", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "homeset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personal", + "columnName": "personal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privBind", + "columnName": "privBind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_homeset_serviceId_url", + "unique": true, + "columnNames": [ + "serviceId", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "serviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homeSetId", + "columnName": "homeSetId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privWriteContent", + "columnName": "privWriteContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privUnbind", + "columnName": "privUnbind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceReadOnly", + "columnName": "forceReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "supportsVEVENT", + "columnName": "supportsVEVENT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVTODO", + "columnName": "supportsVTODO", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "supportsVJOURNAL", + "columnName": "supportsVJOURNAL", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_collection_serviceId_type", + "unique": false, + "columnNames": [ + "serviceId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)" + }, + { + "name": "index_collection_homeSetId_type", + "unique": false, + "columnNames": [ + "homeSetId", + "type" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)" + }, + { + "name": "index_collection_url", + "unique": false, + "columnNames": [ + "url" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "service", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serviceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "homeset", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "homeSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "syncstats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_syncstats_collectionId_authority", + "unique": true, + "columnNames": [ + "collectionId", + "authority" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)" + } + ], + "foreignKeys": [ + { + "table": "collection", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e4bfdf7f9fa3529c333cf9485f8cf50')" + ] + } +} \ No newline at end of file diff --git a/app/src/.gitignore b/app/src/.gitignore new file mode 100644 index 0000000..213f776 --- /dev/null +++ b/app/src/.gitignore @@ -0,0 +1 @@ +espressoTest diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..8278e3a --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ExternalLibrariesTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ExternalLibrariesTest.kt new file mode 100644 index 0000000..0f9ba03 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/ExternalLibrariesTest.kt @@ -0,0 +1,30 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid + +import android.util.Xml +import at.bitfire.dav4jvm.XmlUtils +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExternalLibrariesTest { + + @Test + fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() { + val parser = XmlUtils.newPullParser() + assertTrue(parser.getFeature(Xml.FEATURE_RELAXED)) + } + + @Test + fun testOkhttpHttpUrl_PublicSuffixList() { + // HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList. + // In Android, loading the PublicSuffixList is done over AndroidX startup. + // This test verifies that everything is working. + assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/HiltTestRunner.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/HiltTestRunner.kt new file mode 100644 index 0000000..42de9d0 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/HiltTestRunner.kt @@ -0,0 +1,42 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid + +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Bundle +import androidx.test.runner.AndroidJUnitRunner +import at.bitfire.davdroid.di.TestCoroutineDispatchersModule +import at.bitfire.davdroid.test.BuildConfig +import at.bitfire.synctools.log.LogcatHandler +import dagger.hilt.android.testing.HiltTestApplication +import java.util.logging.Level +import java.util.logging.Logger + +@Suppress("unused") +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader, name: String, context: Context): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) + + override fun onCreate(arguments: Bundle?) { + super.onCreate(arguments) + + // set root logger to adb Logcat + val rootLogger = Logger.getLogger("") + rootLogger.level = Level.ALL + rootLogger.handlers.forEach { rootLogger.removeHandler(it) } + rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID)) + + // MockK requirements + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]") + + // set main dispatcher for tests (especially runTest) + TestCoroutineDispatchersModule.initMainDispatcher() + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/TestUtils.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/TestUtils.kt new file mode 100644 index 0000000..28961cb --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/TestUtils.kt @@ -0,0 +1,58 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid + +import android.content.Context +import android.util.Log +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkerFactory +import androidx.work.testing.WorkManagerTestInitHelper +import org.junit.Assert.assertTrue +import kotlin.math.abs + +object TestUtils { + + fun assertWithin(expected: Long, actual: Long, tolerance: Long) { + val absDifference = abs(expected - actual) + assertTrue( + "$actual not within ($expected ± $tolerance)", + absDifference <= tolerance + ) + } + + /** + * Initializes WorkManager for instrumentation tests. + */ + fun setUpWorkManager(context: Context, workerFactory: WorkerFactory? = null) { + val config = Configuration.Builder().setMinimumLoggingLevel(Log.DEBUG) + if (workerFactory != null) + config.setWorkerFactory(workerFactory) + WorkManagerTestInitHelper.initializeTestWorkManager(context, config.build()) + } + + fun workInStates(context: Context, workerName: String, states: List): Boolean = + WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder + .fromUniqueWorkNames(listOf(workerName)) + .addStates(states) + .build() + ).get().isNotEmpty() + + fun workScheduledOrRunning(context: Context, workerName: String): Boolean = + workInStates(context, workerName, listOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING + )) + + fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean = + workInStates(context, workerName, listOf( + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING, + WorkInfo.State.SUCCEEDED + )) + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/AppDatabaseTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/AppDatabaseTest.kt new file mode 100644 index 0000000..8a257cd --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/AppDatabaseTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import android.content.Context +import androidx.room.Room +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class AppDatabaseTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec> + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration> + + @Before + fun setup() { + hiltRule.inject() + } + + + /** + * Creates a database with schema version 8 (the first exported one) and then migrates it to the latest version. + */ + @Test + fun testAllMigrations() { + // Create DB with v8 + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + listOf(), // no auto migrations until v8 + FrameworkSQLiteOpenHelperFactory() + ).createDatabase(TEST_DB, 8).close() + + // open and migrate (to current version) database + Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB) + // manual migrations + .addMigrations(*manualMigrations.toTypedArray()) + // auto-migrations that need to be specified explicitly + .apply { + for (spec in autoMigrations) + addAutoMigrationSpec(spec) + } + .build() + .openHelper.writableDatabase // this will run all migrations + .close() + } + + + companion object { + const val TEST_DB = "test" + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt new file mode 100644 index 0000000..56166c8 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import android.security.NetworkSecurityPolicy +import androidx.test.filters.SmallTest +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.network.HttpClient +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class CollectionTest { + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + private lateinit var httpClient: HttpClient + private val server = MockWebServer() + + @Before + fun setup() { + hiltRule.inject() + + httpClient = httpClientBuilder.build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + } + + @After + fun teardown() { + httpClient.close() + } + + + @Test + @SmallTest + fun testFromDavResponseAddressBook() { + // r/w address book + server.enqueue(MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " /" + + " " + + " " + + " My Contacts" + + " My Contacts Description" + + " " + + "" + + "")) + + lateinit var info: Collection + DavResource(httpClient.okHttpClient, server.url("/")) + .propfind(0, ResourceType.NAME) { response, _ -> + info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() + } + assertEquals(Collection.TYPE_ADDRESSBOOK, info.type) + assertTrue(info.privWriteContent) + assertTrue(info.privUnbind) + assertNull(info.supportsVEVENT) + assertNull(info.supportsVTODO) + assertNull(info.supportsVJOURNAL) + assertEquals("My Contacts", info.displayName) + assertEquals("My Contacts Description", info.description) + } + + @Test + @SmallTest + fun testFromDavResponseCalendar_FullTimezone() { + // read-only calendar, no display name + server.enqueue(MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " /" + + " " + + " " + + " " + + " My Calendar" + + " BEGIN:VCALENDAR\n" + + "PRODID:-//Example Corp.//CalDAV Client//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VTIMEZONE\n" + + "TZID:US-Eastern\n" + + "LAST-MODIFIED:19870101T000000Z\n" + + "BEGIN:STANDARD\n" + + "DTSTART:19671029T020000\n" + + "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" + + "TZOFFSETFROM:-0400\n" + + "TZOFFSETTO:-0500\n" + + "TZNAME:Eastern Standard Time (US & Canada)\n" + + "END:STANDARD\n" + + "BEGIN:DAYLIGHT\n" + + "DTSTART:19870405T020000\n" + + "RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" + + "TZOFFSETFROM:-0500\n" + + "TZOFFSETTO:-0400\n" + + "TZNAME:Eastern Daylight Time (US & Canada)\n" + + "END:DAYLIGHT\n" + + "END:VTIMEZONE\n" + + "END:VCALENDAR\n" + + "" + + " #ff0000" + + " " + + "" + + "")) + + lateinit var info: Collection + DavResource(httpClient.okHttpClient, server.url("/")) + .propfind(0, ResourceType.NAME) { response, _ -> + info = Collection.fromDavResponse(response)!! + } + assertEquals(Collection.TYPE_CALENDAR, info.type) + assertFalse(info.privWriteContent) + assertFalse(info.privUnbind) + assertNull(info.displayName) + assertEquals("My Calendar", info.description) + assertEquals(0xFFFF0000.toInt(), info.color) + assertEquals("US-Eastern", info.timezoneId) + assertTrue(info.supportsVEVENT!!) + assertTrue(info.supportsVTODO!!) + assertTrue(info.supportsVJOURNAL!!) + } + + @Test + @SmallTest + fun testFromDavResponseCalendar_OnlyTzId() { + // read-only calendar, no display name + server.enqueue(MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " /" + + " " + + " " + + " " + + " My Calendar" + + " US-Eastern" + + " #ff0000" + + " " + + "" + + "")) + + lateinit var info: Collection + DavResource(httpClient.okHttpClient, server.url("/")) + .propfind(0, ResourceType.NAME) { response, _ -> + info = Collection.fromDavResponse(response)!! + } + assertEquals(Collection.TYPE_CALENDAR, info.type) + assertFalse(info.privWriteContent) + assertFalse(info.privUnbind) + assertNull(info.displayName) + assertEquals("My Calendar", info.description) + assertEquals(0xFFFF0000.toInt(), info.color) + assertEquals("US-Eastern", info.timezoneId) + assertTrue(info.supportsVEVENT!!) + assertTrue(info.supportsVTODO!!) + assertTrue(info.supportsVJOURNAL!!) + } + + @Test + @SmallTest + fun testFromDavResponseWebcal() { + // Webcal subscription + server.enqueue(MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " /webcal1" + + " " + + " Sample Subscription" + + " " + + " webcals://example.com/1.ics" + + " " + + "" + + "")) + + lateinit var info: Collection + DavResource(httpClient.okHttpClient, server.url("/")) + .propfind(0, ResourceType.NAME) { response, _ -> + info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() + } + assertEquals(Collection.TYPE_WEBCAL, info.type) + assertEquals("Sample Subscription", info.displayName) + assertEquals("https://example.com/1.ics".toHttpUrl(), info.source) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/HomeSetDaoTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/HomeSetDaoTest.kt new file mode 100644 index 0000000..10896dd --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/HomeSetDaoTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class HomeSetDaoTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var db: AppDatabase + lateinit var dao: HomeSetDao + var serviceId: Long = 0 + + @Before + fun setUp() { + hiltRule.inject() + dao = db.homeSetDao() + + serviceId = db.serviceDao().insertOrReplace( + Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null) + ) + } + + @After + fun tearDown() { + db.serviceDao().deleteAll() + } + + + @Test + fun testInsertOrUpdate() { + // should insert new row or update (upsert) existing row - without changing its key! + val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl()) + val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1) + assertEquals(1L, insertId1) + assertEquals(entry1.copy(id = 1L), dao.getById(1)) + + val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry") + val updateId1 = dao.insertOrUpdateByUrlBlocking(updatedEntry1) + assertEquals(1L, updateId1) + assertEquals(updatedEntry1.copy(id = 1L), dao.getById(1)) + + val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl()) + val insertId2 = dao.insertOrUpdateByUrlBlocking(entry2) + assertEquals(2L, insertId2) + assertEquals(entry2.copy(id = 2L), dao.getById(2)) + } + + @Test + fun testInsertOrUpdate_TransactionSafe() { + runBlocking(Dispatchers.IO) { + for (i in 0..9999) + launch { + dao.insertOrUpdateByUrlBlocking( + HomeSet( + id = 0, + serviceId = serviceId, + url = "https://example.com/".toHttpUrl(), + personal = true + ) + ) + } + } + assertEquals(1, dao.getByService(serviceId).size) + } + + @Test + fun testDelete() { + // should delete row with given primary key (id) + val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl()) + + val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1) + assertEquals(1L, insertId1) + assertEquals(entry1, dao.getById(1L)) + + dao.delete(entry1) + assertEquals(null, dao.getById(1L)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/MemoryDbModule.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/MemoryDbModule.kt new file mode 100644 index 0000000..fce8294 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/MemoryDbModule.kt @@ -0,0 +1,41 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import android.content.Context +import androidx.room.Room +import androidx.room.migration.AutoMigrationSpec +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [ SingletonComponent::class ], + replaces = [ + AppDatabase.AppDatabaseModule::class + ] +) +class MemoryDbModule { + + @Provides + @Singleton + fun inMemoryDatabase( + autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>, + @ApplicationContext context: Context + ): AppDatabase = + Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + // auto-migration specs that need to be specified explicitly + .apply { + for (spec in autoMigrations) { + addAutoMigrationSpec(spec) + } + } + .build() + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/PrincipalDaoTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/PrincipalDaoTest.kt new file mode 100644 index 0000000..fd80ab2 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/PrincipalDaoTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import android.database.sqlite.SQLiteConstraintException +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.junit4.MockKRule +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + + +@HiltAndroidTest +class PrincipalDaoTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + @Inject + lateinit var db: AppDatabase + + private lateinit var principalDao: PrincipalDao + private lateinit var service: Service + private val url = "https://example.com/dav/principal".toHttpUrl() + + @Before + fun setUp() { + hiltRule.inject() + principalDao = spyk(db.principalDao()) + + service = Service(id = 1, accountName = "account", type = "webdav") + db.serviceDao().insertOrReplace(service) + } + + @Test + fun insertOrUpdate_insertsIfNotExisting() = runTest { + val principal = Principal(serviceId = service.id, url = url, displayName = "principal") + val id = principalDao.insertOrUpdate(service.id, principal) + assertTrue(id > 0) + + val stored = principalDao.get(id) + assertEquals("principal", stored.displayName) + verify(exactly = 0) { principalDao.update(any()) } + } + + @Test + fun insertOrUpdate_doesNotUpdateIfDisplayNameIsEqual() = runTest { + val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld") + val idOld = principalDao.insertOrUpdate(service.id, principalOld) + + val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalOld") + val idNew = principalDao.insertOrUpdate(service.id, principalNew) + + assertEquals(idOld, idNew) + val stored = principalDao.get(idOld) + assertEquals("principalOld", stored.displayName) + verify(exactly = 0) { principalDao.update(any()) } + } + + @Test + fun insertOrUpdate_updatesIfDisplayNameIsDifferent() = runTest { + val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld") + val idOld = principalDao.insertOrUpdate(service.id, principalOld) + + val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalNew") + val idNew = principalDao.insertOrUpdate(service.id, principalNew) + + assertEquals(idOld, idNew) + + val updated = principalDao.get(idOld) + assertEquals("principalNew", updated.displayName) + verify(exactly = 1) { principalDao.update(any()) } + } + + @Test(expected = SQLiteConstraintException::class) + fun insertOrUpdate_throwsForeignKeyConstraintViolationException() = runTest { + // throws on non-existing service + val url = "https://example.com/dav/principal".toHttpUrl() + val principal1 = Principal(serviceId = 999, url = url, displayName = "p1") + principalDao.insertOrUpdate(999, principal1) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/SyncStatsDaoTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/SyncStatsDaoTest.kt new file mode 100644 index 0000000..3c96277 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/SyncStatsDaoTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.sqlite.SQLiteException +import at.bitfire.davdroid.sync.SyncDataType +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class SyncStatsDaoTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var db: AppDatabase + var collectionId: Long = 0 + + @Before + fun setUp() { + hiltRule.inject() + + val serviceId = db.serviceDao().insertOrReplace(Service( + id = 0, + accountName = "test@example.com", + type = Service.TYPE_CALDAV + )) + collectionId = db.collectionDao().insert(Collection( + id = 0, + serviceId = serviceId, + type = Collection.TYPE_CALENDAR, + url = "https://example.com".toHttpUrl() + )) + } + + @After + fun tearDown() { + db.serviceDao().deleteAll() + } + + @Test + fun testInsertOrReplace_ExistingForeignKey() = runTest { + val dao = db.syncStatsDao() + dao.insertOrReplace( + SyncStats( + id = 0, + collectionId = collectionId, + dataType = SyncDataType.CONTACTS.toString(), + lastSync = System.currentTimeMillis() + ) + ) + } + + @Test(expected = SQLiteException::class) + fun testInsertOrReplace_MissingForeignKey() = runTest { + val dao = db.syncStatsDao() + dao.insertOrReplace( + SyncStats( + id = 0, + collectionId = 12345, + dataType = SyncDataType.CONTACTS.toString(), + lastSync = System.currentTimeMillis() + ) + ) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/WebDavDocumentDaoTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/WebDavDocumentDaoTest.kt new file mode 100644 index 0000000..24cb08e --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/WebDavDocumentDaoTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class WebDavDocumentDaoTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var logger: Logger + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testGetChildren() = runTest { + val mountDao = db.webDavMountDao() + val dao = db.webDavDocumentDao() + + val mount = WebDavMount(id = 1, name = "Test", url = "https://example.com/".toHttpUrl()) + db.webDavMountDao().insert(mount) + + val root = WebDavDocument( + id = 1, + mountId = mount.id, + parentId = null, + name = "Root Document" + ) + dao.insertOrReplace(root) + dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 1", displayName = "DisplayName 2")) + dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 2", displayName = "DisplayName 1")) + dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 3", displayName = "Directory 1", isDirectory = true)) + try { + dao.getChildren(root.id, orderBy = "name DESC").let { result -> + logger.log(Level.INFO, "getChildren single sort Result", result) + + assertEquals(listOf( + "Name 3", + "Name 2", + "Name 1" + ), result.map { it.name }) + } + + dao.getChildren(root.id, orderBy = "isDirectory DESC, name ASC").let { result -> + logger.log(Level.INFO, "getChildren multiple sort Result", result) + + assertEquals(listOf( + "Name 3", + "Name 1", + "Name 2" + ), result.map { it.name }) + } + } finally { + mountDao.deleteAsync(mount) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/AutoMigration16Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/AutoMigration16Test.kt new file mode 100644 index 0000000..38a910a --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/AutoMigration16Test.kt @@ -0,0 +1,83 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR +import at.bitfire.davdroid.db.Service +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@HiltAndroidTest +class AutoMigration16Test: DatabaseMigrationTest(toVersion = 16) { + + @Test + fun testMigrate_WithTimeZone() = testMigration( + prepare = { db -> + val minimalVTimezone = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:DAVx5 + BEGIN:VTIMEZONE + TZID:America/New_York + END:VTIMEZONE + END:VCALENDAR + """.trimIndent() + db.execSQL( + "INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", + arrayOf(1, "test", Service.Companion.TYPE_CALDAV) + ) + db.execSQL( + "INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, minimalVTimezone) + ) + } + ) { db -> + db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor -> + cursor.moveToFirst() + assertEquals("America/New_York", cursor.getString(0)) + } + } + + @Test + fun testMigrate_WithTimeZone_Unparseable() = testMigration( + prepare = { db -> + db.execSQL( + "INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", + arrayOf(1, "test", Service.Companion.TYPE_CALDAV) + ) + db.execSQL( + "INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, "Some Garbage Content") + ) + } + ) { db -> + db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor -> + cursor.moveToFirst() + assertNull(cursor.getString(0)) + } + } + + @Test + fun testMigrate_WithoutTimezone() = testMigration( + prepare = { db -> + db.execSQL( + "INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", + arrayOf(1, "test", Service.Companion.TYPE_CALDAV) + ) + db.execSQL( + "INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false) + ) + } + ) { db -> + db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor -> + cursor.moveToFirst() + assertNull(cursor.getString(0)) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/AutoMigration18Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/AutoMigration18Test.kt new file mode 100644 index 0000000..ab85d2c --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/AutoMigration18Test.kt @@ -0,0 +1,79 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@HiltAndroidTest +class AutoMigration18Test : DatabaseMigrationTest(toVersion = 18) { + + @Test + fun testMigration_AllAuthorities() = testMigration( + prepare = { db -> + // Insert service and collection to respect relation constraints + db.execSQL("INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", arrayOf(1, "test", 1)) + listOf(1L, 2L, 3L).forEach { id -> + db.execSQL( + "INSERT INTO collection (id, serviceId, url, type, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(id, 1, "https://example.com/$id", 1, 1, 1, 0, 1) + ) + } + // Insert some syncstats with authorities and lastSync times + val syncstats = listOf( + Entry(1, 1, "com.android.contacts", 1000), + Entry(2, 1, "com.android.calendar", 1000), + Entry(3, 1, "org.dmfs.tasks", 1000), + Entry(4, 1, "org.tasks.opentasks", 2000), + Entry(5, 1, "at.techbee.jtx.provider", 3000), // highest lastSync for collection 1 + Entry(6, 1, "unknown.authority", 1000), // ignored + + Entry(7, 2, "org.dmfs.tasks", 1000), + Entry(8, 2, "org.tasks.opentasks", 2000), // highest lastSync for collection 2 + + Entry(9, 3, "org.tasks.opentasks", 1000), + ) + syncstats.forEach { (id, collectionId, authority, lastSync) -> + db.execSQL( + "INSERT INTO syncstats (id, collectionId, authority, lastSync) VALUES (?, ?, ?, ?)", + arrayOf(id, collectionId, authority, lastSync) + ) + } + }, + validate = { db -> + db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor -> + val found = mutableListOf() + db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor -> + val idIdx = cursor.getColumnIndex("id") + val colIdx = cursor.getColumnIndex("collectionId") + val typeIdx = cursor.getColumnIndex("dataType") + while (cursor.moveToNext()) + found.add( + Entry(cursor.getInt(idIdx), cursor.getLong(colIdx), cursor.getString(typeIdx)) + ) + } + + // Expect one TASKS row per collection (collections 1, 2, 3) + assertEquals( + listOf( + Entry(1, 1, "CONTACTS"), + Entry(2, 1, "EVENTS"), + Entry(5, 1, "TASKS"), // highest lastSync TASK for collection 1 is JTX Board + Entry(8, 2, "TASKS"), // highest lastSync TASK for collection 2 + Entry(9, 3, "TASKS"), // only TASK for collection 3 + ), found + ) + } + } + ) + + data class Entry( + val id: Int, + val collectionId: Long, + val dataType: String? = null, + val lastSync: Long? = null + ) +} diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/DatabaseMigrationTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/DatabaseMigrationTest.kt new file mode 100644 index 0000000..ed514f6 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/migration/DatabaseMigrationTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.db.AppDatabase +import dagger.hilt.android.testing.HiltAndroidRule +import org.junit.Before +import org.junit.Rule +import javax.inject.Inject + +/** + * Helper for testing the database migration from [toVersion] - 1 to [toVersion]. + * + * @param toVersion The target version to migrate to. + */ +abstract class DatabaseMigrationTest( + private val toVersion: Int +) { + + @Inject + lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec> + + @Inject + lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration> + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + + @Before + fun setup() { + hiltRule.inject() + } + + + /** + * Used for testing the migration process from [toVersion]-1 to [toVersion]. + * + * Note: SQLite's foreign key constraint enforcement is not enabled in tests. We need + * to enable it ourselves using setting "PRAGMA foreign_keys=ON" directly after opening + * a new database connection (works per connection). In tests it's usually more practical + * not to do so, however. In production database connections room enables it for us. + * + * @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1. + * @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion]. + */ + protected fun testMigration( + prepare: (SupportSQLiteDatabase) -> Unit, + validate: (SupportSQLiteDatabase) -> Unit + ) { + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + autoMigrations.toList(), + FrameworkSQLiteOpenHelperFactory() + ) + + // Prepare the database with the initial version. + val dbName = "test" + helper.createDatabase(dbName, version = toVersion - 1).apply { + // We could enable foreign key constraint enforcement here + // by setting "PRAGMA foreign_keys=ON". + prepare(this) + close() + } + + // Re-open the database with the new version and provide all the migrations. + val db = helper.runMigrationsAndValidate( + name = dbName, + version = toVersion, + validateDroppedTables = true, + migrations = manualMigrations.toTypedArray() + ) + + validate(db) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/di/FakeSyncAdapterModule.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/di/FakeSyncAdapterModule.kt new file mode 100644 index 0000000..c09b9f9 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/di/FakeSyncAdapterModule.kt @@ -0,0 +1,20 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import at.bitfire.davdroid.sync.FakeSyncAdapter +import at.bitfire.davdroid.sync.adapter.SyncAdapter +import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn + +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class]) +abstract class FakeSyncAdapterModule { + @Binds + abstract fun provide(impl: FakeSyncAdapter): SyncAdapter +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/di/TestCoroutineDispatchersModule.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/di/TestCoroutineDispatchersModule.kt new file mode 100644 index 0000000..e7e932d --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/di/TestCoroutineDispatchersModule.kt @@ -0,0 +1,59 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import at.bitfire.davdroid.di.TestCoroutineDispatchersModule.standardTestDispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.setMain + +/** + * Provides test dispatchers to be injected instead of the normal ones. + * + * The [standardTestDispatcher] is set as main dispatcher in [at.bitfire.davdroid.HiltTestRunner], + * so that tests can just use [kotlinx.coroutines.test.runTest] without providing [standardTestDispatcher]. + */ +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [CoroutineDispatchersModule::class] +) +object TestCoroutineDispatchersModule { + + private val standardTestDispatcher = StandardTestDispatcher() + + @Provides + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = standardTestDispatcher + + @Provides + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = standardTestDispatcher + + @Provides + @MainDispatcher + fun mainDispatcher(): CoroutineDispatcher = standardTestDispatcher + + @Provides + @SyncDispatcher + fun syncDispatcher(): CoroutineDispatcher = standardTestDispatcher + + /** + * Sets the [standardTestDispatcher] as [Dispatchers.Main] so that test dispatchers + * created in the future use the same scheduler. See [StandardTestDispatcher] docs + * for more information. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun initMainDispatcher() { + Dispatchers.setMain(standardTestDispatcher) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/di/TestTasksAppWatcherModule.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/di/TestTasksAppWatcherModule.kt new file mode 100644 index 0000000..9591f40 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/di/TestTasksAppWatcherModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import at.bitfire.davdroid.startup.StartupPlugin +import at.bitfire.davdroid.startup.TasksAppWatcher +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import dagger.multibindings.Multibinds + +// remove TasksAppWatcherModule from Android tests +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [TasksAppWatcher.TasksAppWatcherModule::class] +) +abstract class TestTasksAppWatcherModule { + // provides empty set of plugins + @Multibinds + abstract fun empty(): Set +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/Android10ResolverTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/Android10ResolverTest.kt new file mode 100644 index 0000000..df9bc95 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/Android10ResolverTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.os.Build +import androidx.test.filters.SdkSuppress +import org.junit.Assert.assertEquals +import org.junit.Test +import org.xbill.DNS.ARecord +import org.xbill.DNS.Lookup +import org.xbill.DNS.Type +import java.net.Inet4Address +import java.net.InetAddress + +class Android10ResolverTest { + + val FQDN_DAVX5 = "www.davx5.com" + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q, maxSdkVersion = 34) + fun testResolveA() { + val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance().first() + + val srvLookup = Lookup(FQDN_DAVX5, Type.A) + srvLookup.setResolver(Android10Resolver()) + val resultGeneric = srvLookup.run() + assertEquals(1, resultGeneric.size) + + val result = resultGeneric.first() as ARecord + assertEquals(www, result.address) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/DnsRecordResolverTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/DnsRecordResolverTest.kt new file mode 100644 index 0000000..7ef071f --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/DnsRecordResolverTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.xbill.DNS.DClass +import org.xbill.DNS.Name +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.TXTRecord +import javax.inject.Inject +import kotlin.random.Random + +@HiltAndroidTest +class DnsRecordResolverTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var dnsRecordResolver: DnsRecordResolver + + @Before + fun setup() { + hiltRule.inject() + } + + + @Test + fun testBestSRVRecord_Empty() { + assertNull(dnsRecordResolver.bestSRVRecord(emptyArray())) + } + + @Test + fun testBestSRVRecord_MultipleRecords_Priority_Different() { + val dns1010 = SRVRecord( + Name.fromString("_caldavs._tcp.example.com."), + DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.") + ) + val dns2010 = SRVRecord( + Name.fromString("_caldavs._tcp.example.com."), + DClass.IN, 3600, 20, 20, 8443, Name.fromString("dav2010.example.com.") + ) + + // lowest priority first + val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns2010)) + assertEquals(dns1010, result) + } + + @Test + fun testBestSRVRecord_MultipleRecords_Priority_Same() { + val dns1010 = SRVRecord( + Name.fromString("_caldavs._tcp.example.com."), + DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.") + ) + val dns1020 = SRVRecord( + Name.fromString("_caldavs._tcp.example.com."), + DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.") + ) + val dns1030 = SRVRecord( + Name.fromString("_caldavs._tcp.example.com."), + DClass.IN, 3600, 10, 30, 8443, Name.fromString("dav1030.example.com.") + ) + val records = arrayOf(dns1010, dns1020, dns1030) + + val randomNumberGenerator = mockk() + for (i in 0..60) { + every { randomNumberGenerator.nextInt(0, 61) } returns i + val expected = when (i) { + in 0..10 -> dns1010 + in 11..30 -> dns1020 + else -> dns1030 + } + assertEquals(expected, dnsRecordResolver.bestSRVRecord(records, randomNumberGenerator)) + } + } + + @Test + fun testBestSRVRecord_OneRecord() { + val dns1010 = SRVRecord( + Name.fromString("_caldavs._tcp.example.com."), + DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.") + ) + val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010)) + assertEquals(dns1010, result) + } + + + @Test + fun testPathsFromTXTRecords_Empty() { + assertTrue(dnsRecordResolver.pathsFromTXTRecords(arrayOf()).isEmpty()) + } + + @Test + fun testPathsFromTXTRecords_OnePath() { + val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf( + TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("something=else", "path=/path1")) + )).toTypedArray() + assertArrayEquals(arrayOf("/path1"), result) + } + + @Test + fun testPathsFromTXTRecords_TwoPaths() { + val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf( + TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("path=/path1", "something-else", "path=/path2")) + )).toTypedArray() + result.sort() + assertArrayEquals(arrayOf("/path1", "/path2"), result) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt new file mode 100644 index 0000000..1c12099 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.security.NetworkSecurityPolicy +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.Request +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.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class HttpClientTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + lateinit var httpClient: HttpClient + lateinit var server: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + + httpClient = httpClientBuilder.build() + + server = MockWebServer() + server.start(30000) + } + + @After + fun tearDown() { + server.shutdown() + httpClient.close() + } + + + @Test + fun testCookies() { + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + val url = server.url("/test") + + // set cookie for root path (/) and /test path in first response + server.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("Set-Cookie", "cookie1=1; path=/") + .addHeader("Set-Cookie", "cookie2=2") + .setBody("Cookie set")) + httpClient.okHttpClient.newCall(Request.Builder() + .get().url(url) + .build()).execute() + assertNull(server.takeRequest().getHeader("Cookie")) + + // cookie should be sent with second request + // second response lets first cookie expire and overwrites second cookie + server.enqueue(MockResponse() + .addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0") + .addHeader("Set-Cookie", "cookie2=2a") + .setResponseCode(200)) + httpClient.okHttpClient.newCall(Request.Builder() + .get().url(url) + .build()).execute() + val header = server.takeRequest().getHeader("Cookie") + assertTrue(header == "cookie1=1; cookie2=2" || header == "cookie2=2; cookie1=1") + + server.enqueue(MockResponse() + .setResponseCode(200)) + httpClient.okHttpClient.newCall(Request.Builder() + .get().url(url) + .build()).execute() + assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie")) + } + +} diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt new file mode 100644 index 0000000..760332d --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import androidx.test.filters.SdkSuppress +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.Request +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class OkhttpClientTest { + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun inject() { + hiltRule.inject() + } + + + @Test + @SdkSuppress(maxSdkVersion = 34) + fun testIcloudWithSettings() { + httpClientBuilder.build().use { client -> + client.okHttpClient + .newCall( + Request.Builder() + .get() + .url("https://icloud.com") + .build() + ) + .execute() + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/push/PushMessageHandlerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/PushMessageHandlerTest.kt new file mode 100644 index 0000000..9d1549e --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/PushMessageHandlerTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class PushMessageHandlerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var handler: PushMessageHandler + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testParse_InvalidXml() { + Assert.assertNull(handler.parse("Non-XML content")) + } + + @Test + fun testParse_WithXmlDeclAndTopic() { + val topic = handler.parse( + "" + + "" + + " O7M1nQ7cKkKTKsoS_j6Z3w" + + "" + ) + Assert.assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/push/UnifiedPushServiceTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/UnifiedPushServiceTest.kt new file mode 100644 index 0000000..b324339 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/push/UnifiedPushServiceTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.test.rule.ServiceTestRule +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class UnifiedPushServiceTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + @get:Rule + val serviceTestRule = ServiceTestRule() + + @Inject + @ApplicationContext + lateinit var context: Context + + @RelaxedMockK + @BindValue + lateinit var pushRegistrationManager: PushRegistrationManager + + lateinit var binder: IBinder + lateinit var unifiedPushService: UnifiedPushService + + + @Before + fun setUp() { + hiltRule.inject() + + binder = serviceTestRule.bindService(Intent(context, UnifiedPushService::class.java))!! + unifiedPushService = (binder as PushService.PushBinder).getService() as UnifiedPushService + } + + + @Test + fun testOnNewEndpoint() = runTest { + val endpoint = mockk { + every { url } returns "https://example.com/12" + } + unifiedPushService.onNewEndpoint(endpoint, "12") + + advanceUntilIdle() + coVerify { + pushRegistrationManager.processSubscription(12, endpoint) + } + confirmVerified(pushRegistrationManager) + } + + @Test + fun testOnRegistrationFailed() = runTest { + unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "34") + + advanceUntilIdle() + coVerify { + pushRegistrationManager.removeSubscription(34) + } + confirmVerified(pushRegistrationManager) + } + + @Test + fun testOnUnregistered() = runTest { + unifiedPushService.onUnregistered("45") + + advanceUntilIdle() + coVerify { + pushRegistrationManager.removeSubscription(45) + } + confirmVerified(pushRegistrationManager) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt new file mode 100644 index 0000000..9a06da1 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import androidx.hilt.work.HiltWorkerFactory +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.resource.LocalCalendarStore +import at.bitfire.davdroid.resource.LocalDataStore +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.AutomaticSyncManager +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.account.AccountsCleanupWorker +import at.bitfire.davdroid.sync.account.TestAccount +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.clearAllMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountRepositoryTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + // System under test + + @Inject + lateinit var accountRepository: AccountRepository + + // Real injections + + @Inject + @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var accountSettingsFactory: AccountSettings.Factory + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + // Dependency overrides + + @BindValue @MockK(relaxed = true) + lateinit var automaticSyncManager: AutomaticSyncManager + + @BindValue @MockK(relaxed = true) + lateinit var localAddressBookStore: LocalAddressBookStore + + @BindValue @MockK(relaxed = true) + lateinit var localCalendarStore: LocalCalendarStore + + @BindValue @MockK(relaxed = true) + lateinit var serviceRepository: DavServiceRepository + + @BindValue @MockK(relaxed = true) + lateinit var syncWorkerManager: SyncWorkerManager + + @BindValue @MockK(relaxed = true) + lateinit var tasksAppManager: TasksAppManager + + + // Account setup + private val newName = "Renamed Account" + lateinit var am: AccountManager + lateinit var accountType: String + lateinit var account: Account + + @Before + fun setUp() { + hiltRule.inject() + TestUtils.setUpWorkManager(context, workerFactory) + + // Account setup + am = AccountManager.get(context) + accountType = context.getString(R.string.account_type) + account = TestAccount.create() + + // AccountsCleanupWorker static mocking + mockkObject(AccountsCleanupWorker) + every { AccountsCleanupWorker.lockAccountsCleanup() } returns Unit + } + + @After + fun tearDown() { + am.getAccountsByType(accountType).forEach { account -> + am.removeAccountExplicitly(account) + } + + unmockkObject(AccountsCleanupWorker) + clearAllMocks() + } + + + // testRename + + @Test(expected = IllegalArgumentException::class) + fun testRename_checksForAlreadyExisting() = runTest { + val existing = Account("Existing Account", accountType) + am.addAccountExplicitly(existing, null, null) + + accountRepository.rename(account.name, existing.name) + } + + @Test + fun testRename_locksAccountsCleanup() = runTest { + accountRepository.rename(account.name, newName) + + verify { AccountsCleanupWorker.lockAccountsCleanup() } + } + + @Test + fun testRename_renamesAccountInAndroid() = runTest { + accountRepository.rename(account.name, newName) + + val accountsAfter = am.getAccountsByType(accountType) + assertTrue(accountsAfter.any { it.name == newName }) + } + + @Test + fun testRename_cancelsRunningSynchronizationOfOldAccount() = runTest { + accountRepository.rename(account.name, newName) + + coVerify { syncWorkerManager.cancelAllWork(account) } + } + + @Test + fun testRename_disablesPeriodicSyncsForOldAccount() = runTest { + accountRepository.rename(account.name, newName) + + for (dataType in SyncDataType.entries) + coVerify(exactly = 1) { + syncWorkerManager.disablePeriodic(account, dataType) + } + } + + @Test + fun testRename_updatesAccountNameReferencesInDatabase() = runTest { + accountRepository.rename(account.name, newName) + + coVerify { serviceRepository.renameAccount(account.name, newName) } + } + + @Test + fun testRename_updatesAddressBooks() = runTest { + accountRepository.rename(account.name, newName) + + val newAccount = accountRepository.fromName(newName) + coVerify { localAddressBookStore.updateAccount(account, newAccount) } + } + + @Test + fun testRename_updatesCalendarEvents() = runTest { + accountRepository.rename(account.name, newName) + + val newAccount = accountRepository.fromName(newName) + coVerify { localCalendarStore.updateAccount(account, newAccount) } + } + + @Test + fun testRename_updatesAccountNameOfLocalTasks() = runTest { + val mockDataStore = mockk>(relaxed = true) + every { tasksAppManager.getDataStore() } returns mockDataStore + accountRepository.rename(account.name, newName) + + coVerify { mockDataStore.updateAccount(account, accountRepository.fromName(newName)) } + } + + @Test + fun testRename_updatesAutomaticSync() = runTest { + accountRepository.rename(account.name, newName) + + val newAccount = accountRepository.fromName(newName) + coVerify { automaticSyncManager.updateAutomaticSync(newAccount) } + } + + @Test + fun testRename_releasesAccountsCleanupWorkerMutex() = runTest { + accountRepository.rename(account.name, newName) + + verify { AccountsCleanupWorker.lockAccountsCleanup() } + coVerify { serviceRepository.renameAccount(account.name, newName) } + } + +} diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStoreTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStoreTest.kt new file mode 100644 index 0000000..f4f9269 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStoreTest.kt @@ -0,0 +1,225 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.Context +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.sync.account.TestAccount +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkObject +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class LocalAddressBookStoreTest { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var localAddressBookStore: LocalAddressBookStore + + @RelaxedMockK + lateinit var provider: ContentProviderClient + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + lateinit var addressBookAccountType: String + + lateinit var addressBookAccount: Account + lateinit var account: Account + lateinit var service: Service + + @Before + fun setUp() { + hiltRule.inject() + + addressBookAccountType = context.getString(R.string.account_type_address_book) + + account = TestAccount.create() + service = Service( + id = 200, + accountName = account.name, + type = Service.Companion.TYPE_CARDDAV, + principal = null + ) + db.serviceDao().insertOrReplace(service) + addressBookAccount = Account( + "MrRobert@example.com", + addressBookAccountType + ) + } + + @After + fun tearDown() { + TestAccount.remove(account) + removeAddressBooks() + } + + + @Test + fun test_accountName_removesSpecialChars() { + // Should remove iso control characters and `, ", ', + val collection = mockk { + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns "手 M's_\"F-e\"\\(´д`)/;æøå% äöü #42" + every { serviceId } returns service.id + } + assertEquals("手 Ms_F-e\\(´д)/;æøå% äöü #42 (Test Account) #1", localAddressBookStore.accountName(collection)) + } + + @Test + fun test_accountName_missingService() { + val collection = mockk { + every { id } returns 42 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns null + every { serviceId } returns 404 // missing service + } + assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection)) + } + + @Test + fun test_accountName_missingDisplayName() { + val collection = mockk { + every { id } returns 42 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns null + every { serviceId } returns service.id + } + val accountName = localAddressBookStore.accountName(collection) + assertEquals("funnyfriends (${account.name}) #42", accountName) + } + + @Test + fun test_accountName_missingDisplayNameAndService() { + val collection = mockk { + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { displayName } returns null + every { serviceId } returns 404 // missing service + } + assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection)) + } + + + @Test + fun test_create_createAccountReturnsNull() { + val collection = mockk(relaxed = true) { + every { serviceId } returns service.id + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + } + + mockkObject(localAddressBookStore) + every { localAddressBookStore.createAddressBookAccount(any(), any(), any()) } returns null + + assertEquals(null, localAddressBookStore.create(provider, collection)) + } + + @Test + fun test_create_ReadOnly() { + val collection = mockk(relaxed = true) { + every { serviceId } returns service.id + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { readOnly() } returns true + } + val addrBook = localAddressBookStore.create(provider, collection)!! + assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount) + assertTrue(addrBook.readOnly) + } + + @Test + fun test_create_ReadWrite() { + val collection = mockk(relaxed = true) { + every { serviceId } returns service.id + every { id } returns 1 + every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl() + every { readOnly() } returns false + } + + val addrBook = localAddressBookStore.create(provider, collection)!! + assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount) + assertFalse(addrBook.readOnly) + } + + + @Test + fun test_getAll_differentAccount() { + val accountManager = AccountManager.get(context) + mockkObject(accountManager) + every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount) + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns "Another Unrelated Account" + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type + val result = localAddressBookStore.getAll(account, provider) + assertTrue(result.isEmpty()) + } + + @Test + fun test_getAll_sameAccount() { + val accountManager = AccountManager.get(context) + mockkObject(accountManager) + every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount) + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns account.name + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type + val result = localAddressBookStore.getAll(account, provider) + assertEquals(1, result.size) + assertEquals(addressBookAccount, result.first().addressBookAccount) + } + + + /** + * Tests the calculation of read only state is correct + */ + @Test + fun test_shouldBeReadOnly() { + val collectionReadOnly = mockk { every { readOnly() } returns true } + assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false)) + assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true)) + + val collectionNotReadOnly = mockk { every { readOnly() } returns false } + assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false)) + assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true)) + } + + + // helpers + + private fun removeAddressBooks() { + val accountManager = AccountManager.get(context) + accountManager.getAccountsByType(addressBookAccountType).forEach { + accountManager.removeAccountExplicitly(it) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt new file mode 100644 index 0000000..ebe69fd --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalAddressBookTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.Context +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.LabeledProperty +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import ezvcard.property.Telephone +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import java.io.FileNotFoundException +import java.util.LinkedList +import java.util.Optional +import javax.inject.Inject + +@HiltAndroidTest +class LocalAddressBookTest { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + val account = Account("Test Account", "Test Account Type") + + @Before + fun setUp() { + hiltRule.inject() + } + + + /** + * Tests whether contacts are moved (and not lost) when an address book is renamed. + */ + @Test + fun test_renameAccount_retainsContacts() { + localTestAddressBookProvider.provide(account, provider) { addressBook -> + // insert contact with data row + val uid = "12345" + val contact = Contact( + uid = uid, + displayName = "Test Contact", + phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890")))) + ) + val uri = LocalContact(addressBook, contact, null, null, 0).add() + val id = ContentUris.parseId(uri) + val localContact = addressBook.findContactById(id) + localContact.resetDirty() + assertFalse("Contact is dirty before moving", isContactDirty(addressBook, id)) + + // rename address book + val newName = "New Name" + addressBook.renameAccount(newName) + assertEquals(newName, addressBook.addressBookAccount.name) + + // check whether contact is still here (including data rows) and not dirty + val result = addressBook.findContactById(id) + assertFalse("Contact is dirty after moving", isContactDirty(addressBook, id)) + + val contact2 = result.getContact() + assertEquals(uid, contact2.uid) + assertEquals("Test Contact", contact2.displayName) + assertEquals("1234567890", contact2.phoneNumbers.first().component1().text) + } + } + + /** + * Tests whether groups are moved (and not lost) when an address book is renamed. + */ + @Test + fun test_renameAccount_retainsGroups() { + localTestAddressBookProvider.provide(account, provider) { addressBook -> + // insert group + val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0) + val uri = localGroup.add() + val id = ContentUris.parseId(uri) + + // make sure it's not dirty + localGroup.clearDirty(Optional.empty(), null, null) + assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id)) + + // rename address book + val newName = "New Name" + assertTrue(addressBook.renameAccount(newName)) + assertEquals(newName, addressBook.addressBookAccount.name) + + // check whether group is still here and not dirty + val result = addressBook.findGroupById(id) + assertFalse("Group is dirty after moving", isGroupDirty(addressBook, id)) + + val group = result.getContact() + assertEquals("Test Group", group.displayName) + } + } + + + // helpers + + /** + * Returns the dirty flag of the given contact. + * + * @return true if the contact is dirty, false otherwise + * + * @throws FileNotFoundException if the contact can't be found + */ + fun isContactDirty(adddressBook: LocalAddressBook, id: Long): Boolean { + val uri = ContentUris.withAppendedId(adddressBook.rawContactsSyncUri(), id) + provider.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) + return cursor.getInt(0) != 0 + } + throw FileNotFoundException() + } + + /** + * Returns the dirty flag of the given contact group. + * + * @return true if the group is dirty, false otherwise + * + * @throws FileNotFoundException if the group can't be found + */ + fun isGroupDirty(adddressBook: LocalAddressBook, id: Long): Boolean { + val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id) + provider.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) + return cursor.getInt(0) != 0 + } + throw FileNotFoundException() + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt new file mode 100644 index 0000000..f0c7863 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import at.bitfire.synctools.test.InitCalendarProviderRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import javax.inject.Inject + +@HiltAndroidTest +class LocalCalendarTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize() + + @Inject + lateinit var localCalendarFactory: LocalCalendar.Factory + + private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL) + private lateinit var androidCalendar: AndroidCalendar + private lateinit var client: ContentProviderClient + private lateinit var calendar: LocalCalendar + + @Before + fun setUp() { + hiltRule.inject() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + + val provider = AndroidCalendarProvider(account, client) + androidCalendar = provider.createAndGetCalendar(ContentValues()) + calendar = localCalendarFactory.create(androidCalendar) + } + + @After + fun tearDown() { + androidCalendar.delete() + client.closeCompat() + } + + + @Test + fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() { + // create recurring event with only deleted/cancelled instances + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220120T010203Z") + dtStart = DtStart("20220120T010203Z") + summary = "Cancelled exception on 1st day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220121T010203Z") + dtStart = DtStart("20220121T010203Z") + summary = "Cancelled exception on 2nd day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T010203Z") + summary = "Cancelled exception on 3rd day" + status = Status.VEVENT_CANCELLED + }) + } + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + val eventId = localEvent.id + + // set event as dirty + client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is now marked as deleted + client.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(1, cursor.getInt(0)) + } + } + + @Test + // Needs InitCalendarProviderRule + fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + } + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + val eventUrl = androidCalendar.eventUri(localEvent.id) + + // set event as dirty + client.update(eventUrl, contentValuesOf( + Events.DIRTY to 1 + ), null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is not marked as deleted + client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor -> + cursor.moveToNext() + assertEquals(0, cursor.getInt(0)) + } + } + + /** + * Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected. + * @param contentValues values to set on the event. Required: + * - [Events._ID] + * - [Events.DIRTY] + */ + private fun testRemoveNotDirtyMarked(contentValues: ContentValues) { + val id = androidCalendar.addEvent(Entity( + contentValuesOf( + Events.CALENDAR_ID to androidCalendar.id, + Events.DTSTART to System.currentTimeMillis(), + Events.DTEND to System.currentTimeMillis(), + Events.TITLE to "Some Event", + AndroidEvent2.COLUMN_FLAGS to 123 + ).apply { putAll(contentValues) } + )) + + calendar.removeNotDirtyMarked(123) + + assertNull(androidCalendar.getEvent(id)) + } + + @Test + fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked( + contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0) + ) + + @Test + fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked( + contentValuesOf(Events._ID to 1, Events.DIRTY to 0) + ) + + @Test + fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked( + contentValuesOf(Events._ID to 1, Events.DIRTY to null) + ) + + /** + * Verifies that [LocalCalendar.markNotDirty] works as expected. + * @param contentValues values to set on the event. Required: + * - [Events.DIRTY] + */ + private fun testMarkNotDirty(contentValues: ContentValues) { + val id = androidCalendar.addEvent(Entity( + contentValuesOf( + Events.CALENDAR_ID to androidCalendar.id, + Events._ID to 1, + Events.DTSTART to System.currentTimeMillis(), + Events.DTEND to System.currentTimeMillis(), + Events.TITLE to "Some Event", + AndroidEvent2.COLUMN_FLAGS to 123 + ).apply { putAll(contentValues) } + )) + + val updated = calendar.markNotDirty(321) + assertEquals(1, updated) + assertEquals(321, androidCalendar.getEvent(id)?.flags) + } + + @Test + fun test_markNotDirty_DirtyIs0() = testMarkNotDirty( + contentValuesOf( + Events.DIRTY to 0 + ) + ) + + @Test + fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty( + contentValuesOf( + Events.DIRTY to null + ) + ) + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt new file mode 100644 index 0000000..053f035 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.provider.CalendarContract +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.Events +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import at.techbee.jtx.JtxContract.asSyncAdapter +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID +import javax.inject.Inject + +@HiltAndroidTest +class LocalEventTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) + + @Inject + lateinit var localCalendarFactory: LocalCalendar.Factory + + private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL) + private lateinit var client: ContentProviderClient + private lateinit var calendar: LocalCalendar + + @Before + fun setUp() { + hiltRule.inject() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + + val provider = AndroidCalendarProvider(account, client) + calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues())) + } + + @After + fun tearDown() { + calendar.androidCalendar.delete() + client.closeCompat() + } + + + @Test + fun testPrepareForUpload_NoUid() { + // create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event without uid" + } + + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + + // prepare for upload - this should generate a new random uuid, returned as filename + val fileNameWithSuffix = localEvent.prepareForUpload() + val fileName = fileNameWithSuffix.removeSuffix(".ics") + + // throws an exception if fileName is not an UUID + UUID.fromString(fileName) + + // UID in calendar storage should be the same as file name + client.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.UID_2445), null, null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(fileName, cursor.getString(0)) + } + } + + @Test + fun testPrepareForUpload_NormalUid() { + // create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with normal uid" + uid = "some-event@hostname.tld" // old UID format, UUID would be new format + } + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + + // prepare for upload - this should use the UID for the file name + val fileNameWithSuffix = localEvent.prepareForUpload() + val fileName = fileNameWithSuffix.removeSuffix(".ics") + + assertEquals(event.uid, fileName) + + // UID in calendar storage should still be set, too + client.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.UID_2445), null, null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(fileName, cursor.getString(0)) + } + } + + @Test + fun testPrepareForUpload_UidHasDangerousChars() { + // create event + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with funny uid" + uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-" + } + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + + // prepare for upload - this should generate a new random uuid, returned as filename + val fileNameWithSuffix = localEvent.prepareForUpload() + val fileName = fileNameWithSuffix.removeSuffix(".ics") + + // throws an exception if fileName is not an UUID + UUID.fromString(fileName) + + // UID in calendar storage shouldn't have been changed + client.query( + ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account), + arrayOf(Events.UID_2445), null, null, null + )!!.use { cursor -> + cursor.moveToFirst() + assertEquals(event.uid, cursor.getString(0)) + } + } + + + @Test + fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() { + // TODO + } + + @Test + fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() { + // create recurring event with only deleted/cancelled instances + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220120T010203Z") + dtStart = DtStart("20220120T010203Z") + summary = "Cancelled exception on 1st day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220121T010203Z") + dtStart = DtStart("20220121T010203Z") + summary = "Cancelled exception on 2nd day" + status = Status.VEVENT_CANCELLED + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T010203Z") + summary = "Cancelled exception on 3rd day" + status = Status.VEVENT_CANCELLED + }) + } + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + val eventId = localEvent.id!! + + // set event as dirty + client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is now marked as deleted + client.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(1, cursor.getInt(0)) + } + } + + @Test + fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 3 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=3")) + } + calendar.add( + event = event, + fileName = "filename.ics", + eTag = null, + scheduleTag = null, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + val localEvent = calendar.findByName("filename.ics")!! + val eventId = localEvent.id!! + + // set event as dirty + client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply { + put(Events.DIRTY, 1) + }, null, null) + + // this method should mark the event as deleted + calendar.deleteDirtyEventsWithoutInstances() + + // verify that event is not marked as deleted + client.query( + ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), + arrayOf(Events.DELETED), null, null, null + )!!.use { cursor -> + cursor.moveToNext() + assertEquals(0, cursor.getInt(0)) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt new file mode 100644 index 0000000..cfa0a52 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.synctools.storage.ContactsBatchOperation +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.Optional +import javax.inject.Inject + +@HiltAndroidTest +class LocalGroupTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider + + lateinit var provider: ContentProviderClient + + val account = Account("Test Account", "Test Account Type") + + @Before + fun setUp() { + hiltRule.inject() + + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @After + fun tearDown() { + provider.close() + } + + @Test + fun testApplyPendingMemberships_addPendingMembership() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab -> + val contact1 = LocalContact(ab, Contact().apply { + uid = "test1" + displayName = "Test" + }, "test1.vcf", null, 0) + contact1.add() + + val group = newGroup(ab) + // set pending membership of contact1 + ab.provider!!.update( + ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!), + ContentValues().apply { + put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString()) + }, + null, null + ) + + // pending membership -> contact1 should be added to group + LocalGroup.applyPendingMemberships(ab) + + // check group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID), + "${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(group.id, cursor.getLong(0)) + assertEquals(contact1.id, cursor.getLong(1)) + + assertFalse(cursor.moveToNext()) + } + // check cached group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(group.id, cursor.getLong(0)) + assertEquals(contact1.id, cursor.getLong(1)) + + assertFalse(cursor.moveToNext()) + } + } + } + + @Test + fun testApplyPendingMemberships_removeMembership() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab -> + val contact1 = LocalContact(ab, Contact().apply { + uid = "test1" + displayName = "Test" + }, "test1.vcf", null, 0) + contact1.add() + + val group = newGroup(ab) + + // add contact1 to group + val batch = ContactsBatchOperation(ab.provider!!) + contact1.addToGroup(batch, group.id!!) + batch.commit() + + // no pending memberships -> membership should be removed + LocalGroup.applyPendingMemberships(ab) + + // check group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), + arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID), + "${GroupMembership.MIMETYPE}=?", + arrayOf(GroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertFalse(cursor.moveToNext()) + } + // check cached group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), + arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", + arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertFalse(cursor.moveToNext()) + } + } + } + + @Test + fun testClearDirty_addCachedGroupMembership() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab -> + val group = newGroup(ab) + + val contact1 = + LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0) + contact1.add() + + // insert group membership, but no cached group membership + ab.provider!!.insert( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply { + put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) + put(GroupMembership.RAW_CONTACT_ID, contact1.id) + put(GroupMembership.GROUP_ROW_ID, group.id) + } + ) + + group.clearDirty(Optional.empty(), null) + + // check cached group membership + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), + arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", + arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertTrue(cursor.moveToNext()) + assertEquals(group.id, cursor.getLong(0)) + assertEquals(contact1.id, cursor.getLong(1)) + + assertFalse(cursor.moveToNext()) + } + } + } + + @Test + fun testClearDirty_removeCachedGroupMembership() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab -> + val group = newGroup(ab) + + val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0) + contact1.add() + + // insert cached group membership, but no group membership + ab.provider!!.insert( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply { + put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id) + put(CachedGroupMembership.GROUP_ID, group.id) + } + ) + + group.clearDirty(Optional.empty(), null) + + // cached group membership should be gone + ab.provider!!.query( + ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID), + "${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE), + null + )!!.use { cursor -> + assertFalse(cursor.moveToNext()) + } + } + } + + @Test + fun testMarkMembersDirty() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab -> + val group = newGroup(ab) + + val contact1 = + LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0) + contact1.add() + + val batch = ContactsBatchOperation(ab.provider!!) + contact1.addToGroup(batch, group.id!!) + batch.commit() + + assertEquals(0, ab.findDirty().size) + group.markMembersDirty() + assertEquals(contact1.id, ab.findDirty().first().id) + } + } + + @Test + fun testPrepareForUpload() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab -> + val group = newGroup(ab) + assertNull(group.getContact().uid) + + val fileName = group.prepareForUpload() + val newUid = group.getContact().uid + assertNotNull(newUid) + assertEquals("$newUid.vcf", fileName) + } + } + + @Test + fun testUpdate() { + localTestAddressBookProvider.provide(account, provider) { ab -> + val group = newGroup(ab) + group.update(Contact(displayName = "New Group Name"), null, null, null, 0) + } + } + + + // helpers + + private fun newGroup(addressBook: LocalAddressBook): LocalGroup = + LocalGroup(addressBook, + Contact().apply { + displayName = "Test Group" + }, null, null, 0 + ).apply { + add() + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalTestAddressBook.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalTestAddressBook.kt new file mode 100644 index 0000000..a21caba --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalTestAddressBook.kt @@ -0,0 +1,59 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration +import at.bitfire.vcard4android.GroupMethod +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Optional +import java.util.logging.Logger + +/** + * A local address book that provides an easy way to set the group method in tests. + */ +class LocalTestAddressBook @AssistedInject constructor( + @Assisted account: Account, + @Assisted("addressBook") addressBookAccount: Account, + @Assisted provider: ContentProviderClient, + @Assisted override val groupMethod: GroupMethod, + accountSettingsFactory: AccountSettings.Factory, + collectionRepository: DavCollectionRepository, + @ApplicationContext context: Context, + logger: Logger, + serviceRepository: DavServiceRepository, + syncFramework: SyncFrameworkIntegration +): LocalAddressBook( + account = account, + _addressBookAccount = addressBookAccount, + provider = provider, + accountSettingsFactory = accountSettingsFactory, + collectionRepository = collectionRepository, + context = context, + dirtyVerifier = Optional.empty(), + logger = logger, + serviceRepository = serviceRepository, + syncFramework = syncFramework +) { + + @AssistedFactory + interface Factory { + fun create( + account: Account, + @Assisted("addressBook") addressBookAccount: Account, + provider: ContentProviderClient, + groupMethod: GroupMethod + ): LocalTestAddressBook + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalTestAddressBookProvider.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalTestAddressBookProvider.kt new file mode 100644 index 0000000..83b8d9d --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalTestAddressBookProvider.kt @@ -0,0 +1,72 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.Context +import at.bitfire.davdroid.R +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.qualifiers.ApplicationContext +import org.junit.Assert.assertTrue +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject + +/** + * Provides [LocalTestAddressBook]s in tests. + */ +class LocalTestAddressBookProvider @Inject constructor( + @ApplicationContext context: Context, + private val localTestAddressBookFactory: LocalTestAddressBook.Factory +) { + + /** + * Counter for creating unique address book names. + */ + val counter = AtomicInteger() + + val accountManager = AccountManager.get(context) + val accountType = context.getString(R.string.account_type_address_book) + + /** + * Creates and provides a new temporary [LocalTestAddressBook] for the given [account] and + * removes it again. + * + * @param account The DAVx5 account to use for the address book + * @param provider Content provider needed to access and modify the address book + * @param groupMethod The group method the address book should use + * @param block Function to execute with the temporary available address book + */ + fun provide( + account: Account, + provider: ContentProviderClient, + groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS, + block: (LocalTestAddressBook) -> Unit + ) { + // create new address book account + val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", accountType) + assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null)) + val addressBook = localTestAddressBookFactory.create(account, addressBookAccount, provider, groupMethod) + + // Empty the address book (Needed by LocalGroupTest) + for (contact in addressBook.queryContacts(null, null)) + contact.delete() + for (group in addressBook.queryGroups(null, null)) + group.delete() + + try { + // provide address book + block(addressBook) + } finally { + // recreate account of provided address book, since the account might have been renamed + val renamedAccount = Account(addressBook.addressBookAccount.name, addressBook.addressBookAccount.type) + + // remove address book account / address book + assertTrue(accountManager.removeAccountExplicitly(renamedAccount)) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandlerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandlerTest.kt new file mode 100644 index 0000000..ea9c5b6 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandlerTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.content.Context +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.resource.LocalTestAddressBookProvider +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.AfterClass +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class CachedGroupMembershipHandlerTest { + + @Inject + @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + val account = Account("Test Account", "Test Account Type") + + @Before + fun inject() { + hiltRule.inject() + } + + + @Test + fun testMembership() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBook -> + val contact = Contact() + val localContact = LocalContact(addressBook, contact, null, null, 0) + CachedGroupMembershipHandler(localContact).handle(ContentValues().apply { + put(CachedGroupMembership.GROUP_ID, 123456) + put(CachedGroupMembership.RAW_CONTACT_ID, 789) + }, contact) + assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray()) + } + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilderTest.kt new file mode 100644 index 0000000..2e9e067 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilderTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.net.Uri +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.resource.LocalTestAddressBookProvider +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class GroupMembershipBuilderTest { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + val account = Account("Test Account", "Test Account Type") + + + @Before + fun inject() { + hiltRule.inject() + } + + + @Test + fun testCategories_GroupsAsCategories() { + val contact = Contact().apply { + categories += "TEST GROUP" + } + localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories -> + GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result -> + assertEquals(1, result.size) + assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE]) + assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID]) + } + } + } + + @Test + fun testCategories_GroupsAsVCards() { + val contact = Contact().apply { + categories += "TEST GROUP" + } + localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards -> + GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result -> + // group membership is constructed during post-processing + assertEquals(0, result.size) + } + } + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context: Context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandlerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandlerTest.kt new file mode 100644 index 0000000..baeb26b --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandlerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.content.Context +import android.provider.ContactsContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.resource.LocalTestAddressBookProvider +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.AfterClass +import org.junit.Assert +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class GroupMembershipHandlerTest { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + val account = Account("Test Account", "Test Account Type") + + @Before + fun inject() { + hiltRule.inject() + } + + + @Test + fun testMembership_GroupsAsCategories() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories -> + val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP") + + val contact = Contact() + val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0) + GroupMembershipHandler(localContact).handle(ContentValues().apply { + put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup) + put(CachedGroupMembership.RAW_CONTACT_ID, -1) + }, contact) + assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray()) + assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray()) + } + } + + + @Test + fun testMembership_GroupsAsVCards() { + localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards -> + val contact = Contact() + val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0) + GroupMembershipHandler(localContact).handle(ContentValues().apply { + put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist + put(CachedGroupMembership.RAW_CONTACT_ID, -1) + }, contact) + assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray()) + assertTrue(contact.categories.isEmpty()) + } + } + + + companion object { + + @JvmField + @ClassRule + val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!! + + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun connect() { + val context: Context = InstrumentationRegistry.getInstrumentation().context + provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!! + Assert.assertNotNull(provider) + } + + @AfterClass + @JvmStatic + fun disconnect() { + provider.close() + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilderTest.kt new file mode 100644 index 0000000..5795687 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilderTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.net.Uri +import at.bitfire.vcard4android.Contact +import org.junit.Assert.assertEquals +import org.junit.Test + +class UnknownPropertiesBuilderTest { + + @Test + fun testUnknownProperties_None() { + UnknownPropertiesBuilder(Uri.EMPTY, null, Contact(), false).build().also { result -> + assertEquals(0, result.size) + } + } + + @Test + fun testUnknownProperties_Properties() { + UnknownPropertiesBuilder(Uri.EMPTY, null, Contact().apply { + unknownProperties = "X-TEST:12345" + }, false).build().also { result -> + assertEquals(1, result.size) + assertEquals(UnknownProperties.CONTENT_ITEM_TYPE, result[0].values[UnknownProperties.MIMETYPE]) + assertEquals("X-TEST:12345", result[0].values[UnknownProperties.UNKNOWN_PROPERTIES]) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandlerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandlerTest.kt new file mode 100644 index 0000000..f1a5a24 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandlerTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import at.bitfire.vcard4android.Contact +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UnknownPropertiesHandlerTest { + + @Test + fun testUnknownProperties_Empty() { + val contact = Contact() + UnknownPropertiesHandler.handle(ContentValues().apply { + putNull(UnknownProperties.UNKNOWN_PROPERTIES) + }, contact) + assertNull(contact.unknownProperties) + } + + @Test + fun testUnknownProperties_Values() { + val contact = Contact() + UnknownPropertiesHandler.handle(ContentValues().apply { + put(UnknownProperties.UNKNOWN_PROPERTIES, "X-TEST:12345") + }, contact) + assertEquals("X-TEST:12345", contact.unknownProperties) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt new file mode 100644 index 0000000..aa4fcf5 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import android.security.NetworkSecurityPolicy +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class CollectionsWithoutHomeSetRefresherTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory + + @BindValue + @MockK(relaxed = true) + lateinit var settings: SettingsManager + + private lateinit var client: HttpClient + private lateinit var mockServer: MockWebServer + private lateinit var service: Service + + @Before + fun setUp() { + hiltRule.inject() + + // Start mock web server + mockServer = MockWebServer().apply { + dispatcher = TestDispatcher(logger) + start() + } + + // build HTTP client + client = httpClientBuilder.build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + + // insert test service + val serviceId = db.serviceDao().insertOrReplace( + Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null) + ) + service = db.serviceDao().get(serviceId)!! + } + + @After + fun tearDown() { + client.close() + mockServer.shutdown() + } + + + // refreshCollectionsWithoutHomeSet + + @Test + fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() { + // place homeless collection in DB + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + ) + ) + + // Refresh + refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet() + + // Check the collection got updated - with display name and description + assertEquals( + Collection( + collectionId, + service.id, + null, + 1, // will have gotten an owner too + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ), + db.collectionDao().get(collectionId) + ) + } + + @Test + fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() { + // place homeless collection in DB - it is also inaccessible + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE") + ) + ) + + // Refresh - should delete collection + refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet() + + // Check the collection got deleted + assertEquals(null, db.collectionDao().get(collectionId)) + } + + @Test + fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() { + // place homeless collection in DB + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + ) + ) + + // Refresh homeless collections + assertEquals(0, db.principalDao().getByService(service.id).size) + refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet() + + // Check principal saved and the collection was updated with its reference + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url) + assertEquals(null, principals[0].displayName) + assertEquals( + principals[0].id, + db.collectionDao().get(collectionId)!!.ownerId + ) + } + + + companion object { + private const val PATH_CARDDAV = "/carddav" + private const val SUBPATH_PRINCIPAL = "/principal" + private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts" + private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts" + } + + class TestDispatcher( + private val logger: Logger + ): Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path!!.trimEnd('/') + logger.info("${request.method} on $path") + + if (request.method.equals("PROPFIND", true)) { + val properties = when (path) { + PATH_CARDDAV + SUBPATH_ADDRESSBOOK -> + "" + + " " + + " " + + "" + + "My Contacts" + + "My Contacts Description" + + "" + + " ${PATH_CARDDAV + SUBPATH_PRINCIPAL}" + + "" + + else -> "" + } + + return MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " $path" + + " "+ + properties + + " " + + "" + + "") + } + + return MockResponse().setResponseCode(404) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt new file mode 100644 index 0000000..a77ca84 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import android.security.NetworkSecurityPolicy +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.net.URI +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class DavResourceFinderTest { + + companion object { + private const val PATH_NO_DAV = "/nodav" + private const val PATH_CALDAV = "/caldav" + private const val PATH_CARDDAV = "/carddav" + private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav" + + private const val SUBPATH_PRINCIPAL = "/principal" + private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks" + private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts" + } + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var resourceFinderFactory: DavResourceFinder.Factory + + private lateinit var server: MockWebServer + private lateinit var client: HttpClient + private lateinit var finder: DavResourceFinder + + @Before + fun setUp() { + hiltRule.inject() + + server = MockWebServer().apply { + dispatcher = TestDispatcher(logger) + start() + } + + val credentials = Credentials(username = "mock", password = "12345".toSensitiveString()) + client = httpClientBuilder + .authenticate(host = null, getCredentials = { credentials }) + .build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + + val baseURI = URI.create("/") + finder = resourceFinderFactory.create(baseURI, credentials) + } + + @After + fun tearDown() { + client.close() + server.shutdown() + } + + + @Test + fun testRememberIfAddressBookOrHomeset() { + // recognize home set + var info = ServiceInfo() + DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)) + .propfind(0, AddressbookHomeSet.NAME) { response, _ -> + finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) + } + assertEquals(0, info.collections.size) + assertEquals(1, info.homeSets.size) + assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first()) + + // recognize address book + info = ServiceInfo() + DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK)) + .propfind(0, ResourceType.NAME) { response, _ -> + finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) + } + assertEquals(1, info.collections.size) + assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first()) + assertEquals(0, info.homeSets.size) + } + + @Test + fun testProvidesService() { + assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV)) + assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV)) + + assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)) + assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV)) + + assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)) + assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV)) + + assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV)) + assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV)) + } + + @Test + fun testGetCurrentUserPrincipal() { + assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV)) + assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV)) + + assertEquals( + server.url(PATH_CALDAV + SUBPATH_PRINCIPAL), + finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV) + ) + assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV)) + + assertEquals( + server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL), + finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV) + ) + assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV)) + } + + @Test + fun testQueryEmailAddress() { + var info = ServiceInfo() + assertArrayEquals( + arrayOf("email1@example.com", "email2@example.com"), + finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray() + ) + assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty()) + } + + + // mock server + + class TestDispatcher( + private val logger: Logger + ): Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + if (!checkAuth(request)) { + val authenticate = MockResponse().setResponseCode(401) + authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"") + return authenticate + } + + val path = request.path!! + + if (request.method.equals("OPTIONS", true)) { + val dav = when { + path.startsWith(PATH_CALDAV) -> "calendar-access" + path.startsWith(PATH_CARDDAV) -> "addressbook" + path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook" + else -> null + } + val response = MockResponse().setResponseCode(200) + if (dav != null) + response.addHeader("DAV", dav) + return response + } else if (request.method.equals("PROPFIND", true)) { + val props: String? + when (path) { + PATH_CALDAV, + PATH_CARDDAV -> + props = "$path$SUBPATH_PRINCIPAL" + + PATH_CARDDAV + SUBPATH_PRINCIPAL -> + props = "" + + " $PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET" + + "" + + PATH_CARDDAV + SUBPATH_ADDRESSBOOK -> + props = "" + + " " + + " " + + "" + + PATH_CALDAV + SUBPATH_PRINCIPAL -> + props = "" + + " urn:unknown-entry" + + " mailto:email1@example.com" + + " mailto:email2@example.com" + + "" + + else -> props = null + } + logger.info("Sending props: $props") + return MockResponse() + .setResponseCode(207) + .setBody("" + + "" + + " ${request.path}" + + " $props" + + "" + + "") + } + + return MockResponse().setResponseCode(404) + } + + private fun checkAuth(rq: RecordedRequest) = + rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ==" + + } + +} diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt new file mode 100644 index 0000000..5607b75 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt @@ -0,0 +1,473 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import android.security.NetworkSecurityPolicy +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class HomeSetRefresherTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory + + @BindValue + @MockK(relaxed = true) + lateinit var settings: SettingsManager + + private lateinit var client: HttpClient + private lateinit var mockServer: MockWebServer + private lateinit var service: Service + + @Before + fun setUp() { + hiltRule.inject() + + // Start mock web server + mockServer = MockWebServer().apply { + dispatcher = TestDispatcher(logger) + start() + } + + // build HTTP client + client = httpClientBuilder.build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + + // insert test service + val serviceId = db.serviceDao().insertOrReplace( + Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null) + ) + service = db.serviceDao().get(serviceId)!! + } + + @After + fun tearDown() { + client.close() + mockServer.shutdown() + } + + + // refreshHomesetsAndTheirCollections + + @Test + fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest { + // save homeset in DB + val homesetId = db.homeSetDao().insert( + HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL")) + ) + + // Refresh + homeSetRefresherFactory.create(service, client.okHttpClient) + .refreshHomesetsAndTheirCollections() + + // Check the collection defined in homeset is now in the database + assertEquals( + Collection( + 1, + service.id, + homesetId, + 1, // will have gotten an owner too + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ), + db.collectionDao().getByService(service.id).first() + ) + } + + @Test + fun refreshHomesetsAndTheirCollections_updatesExistingCollection() { + // save "old" collection in DB + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ) + ) + + // Refresh + homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections() + + // Check the collection got updated + assertEquals( + Collection( + collectionId, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description" + ), + db.collectionDao().get(collectionId) + ) + } + + @Test + fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() { + // save "old" collection in DB - with set flags + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description", + forceReadOnly = true, + sync = true + ) + ) + + // Refresh + homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections() + + // Check the collection got updated + assertEquals( + Collection( + collectionId, + service.id, + null, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), + displayName = "My Contacts", + description = "My Contacts Description", + forceReadOnly = true, + sync = true + ), + db.collectionDao().get(collectionId) + ) + } + + @Test + fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() { + // save homeset in DB - which is empty (zero address books) on the serverside + val homesetId = db.homeSetDao().insert( + HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY")) + ) + + // place collection in DB - as part of the homeset + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + homesetId, + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + ) + + // Refresh - should mark collection as homeless, because serverside homeset is empty. + homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections() + + // Check the collection, is now marked as homeless + assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId) + } + + @Test + fun refreshHomesetsAndTheirCollections_addsOwnerUrls() { + // save a homeset in DB + val homesetId = db.homeSetDao().insert( + HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL")) + ) + + // place collection in DB - as part of the homeset + val collectionId = db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + homesetId, // part of above home set + null, + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/") + ) + ) + + // Refresh - homesets and their collections + assertEquals(0, db.principalDao().getByService(service.id).size) + homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections() + + // Check principal saved and the collection was updated with its reference + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url) + assertEquals(null, principals[0].displayName) + assertEquals( + principals[0].id, + db.collectionDao().get(collectionId)!!.ownerId + ) + } + + + // other + + @Test + fun shouldPreselect_none() { + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("/addressbook-homeset/addressbook/") + ) + val homesets = listOf( + HomeSet( + id = 0, + serviceId = service.id, + personal = true, + url = mockServer.url("/addressbook-homeset/") + ) + ) + + val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_all() { + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("/addressbook-homeset/addressbook/") + ) + val homesets = listOf( + HomeSet( + id = 0, + serviceId = service.id, + personal = false, + url = mockServer.url("/addressbook-homeset/") + ) + ) + + val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + assertTrue(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_all_blacklisted() { + val url = mockServer.url("/addressbook-homeset/addressbook/") + + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString() + + val collection = Collection( + id = 0, + serviceId = service.id, + homeSetId = 0, + type = Collection.TYPE_ADDRESSBOOK, + url = url + ) + val homesets = listOf( + HomeSet( + id = 0, + serviceId = service.id, + personal = false, + url = mockServer.url("/addressbook-homeset/") + ) + ) + + val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_personal_notPersonal() { + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + id = 0, + serviceId = service.id, + homeSetId = 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("/addressbook-homeset/addressbook/") + ) + val homesets = listOf( + HomeSet( + id = 0, + serviceId = service.id, + personal = false, + url = mockServer.url("/addressbook-homeset/") + ) + ) + + val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_personal_isPersonal() { + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns "" + + val collection = Collection( + 0, + service.id, + 0, + type = Collection.TYPE_ADDRESSBOOK, + url = mockServer.url("/addressbook-homeset/addressbook/") + ) + val homesets = listOf( + HomeSet( + id = 0, + serviceId = service.id, + personal = true, + url = mockServer.url("/addressbook-homeset/") + ) + ) + + val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + assertTrue(refresher.shouldPreselect(collection, homesets)) + } + + @Test + fun shouldPreselect_personal_isPersonalButBlacklisted() { + val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/") + + every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL + every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString() + + val collection = Collection( + id = 0, + serviceId = service.id, + homeSetId = 0, + type = Collection.TYPE_ADDRESSBOOK, + url = collectionUrl + ) + val homesets = listOf( + HomeSet( + id = 0, + serviceId = service.id, + personal = true, + url = mockServer.url("/addressbook-homeset/") + ) + ) + + val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + assertFalse(refresher.shouldPreselect(collection, homesets)) + } + + + companion object { + + private const val PATH_CARDDAV = "/carddav" + + private const val SUBPATH_PRINCIPAL = "/principal" + private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset" + private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty" + private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts" + + } + + class TestDispatcher( + private val logger: Logger + ) : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path!!.trimEnd('/') + + if (request.method.equals("PROPFIND", true)) { + val properties = when (path) { + + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL -> + "" + + " " + + " " + + "" + + "My Contacts" + + "My Contacts Description" + + "" + + " ${PATH_CARDDAV + SUBPATH_PRINCIPAL}" + + "" + + SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> "" + + else -> "" + } + + logger.info("Queried: $path") + return MockResponse() + .setResponseCode(207) + .setBody( + "" + + "" + + " ${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}" + + " " + + properties + + " " + + " HTTP/1.1 200 OK" + + "" + + "" + ) + } + + return MockResponse().setResponseCode(404) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt new file mode 100644 index 0000000..231f734 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import android.security.NetworkSecurityPolicy +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Principal +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import junit.framework.TestCase.assertEquals +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class PrincipalsRefresherTest { + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var principalsRefresher: PrincipalsRefresher.Factory + + @BindValue + @MockK(relaxed = true) + lateinit var settings: SettingsManager + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + private lateinit var client: HttpClient + private lateinit var mockServer: MockWebServer + private lateinit var service: Service + + @Before + fun setUp() { + hiltRule.inject() + + // Start mock web server + mockServer = MockWebServer().apply { + dispatcher = TestDispatcher(logger) + start() + } + + // build HTTP client + client = httpClientBuilder.build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + + // insert test service + val serviceId = db.serviceDao().insertOrReplace( + Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null) + ) + service = db.serviceDao().get(serviceId)!! + } + + @After + fun tearDown() { + client.close() + mockServer.shutdown() + } + + + @Test + fun refreshPrincipals_inaccessiblePrincipal() { + // place principal without display name in db + val principalId = db.principalDao().insert( + Principal( + 0, + service.id, + mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash + null // no display name for now + ) + ) + // add an associated collection - as the principal is rightfully removed otherwise + db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + principalId, // create association with principal + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash + ) + ) + + // Refresh principals + principalsRefresher.create(service, client.okHttpClient).refreshPrincipals() + + // Check principal was not updated + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url) + assertEquals(null, principals[0].displayName) + } + + @Test + fun refreshPrincipals_updatesPrincipal() { + // place principal without display name in db + val principalId = db.principalDao().insert( + Principal( + 0, + service.id, + mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash + null // no display name for now + ) + ) + // add an associated collection - as the principal is rightfully removed otherwise + db.collectionDao().insertOrUpdateByUrl( + Collection( + 0, + service.id, + null, + principalId, // create association with principal + Collection.TYPE_ADDRESSBOOK, + mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash + ) + ) + + // Refresh principals + principalsRefresher.create(service, client.okHttpClient).refreshPrincipals() + + // Check principal now got a display name + val principals = db.principalDao().getByService(service.id) + assertEquals(1, principals.size) + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url) + assertEquals("Mr. Wobbles", principals[0].displayName) + } + + @Test + fun refreshPrincipals_deletesPrincipalsWithoutCollections() { + // place principal without collections in DB + db.principalDao().insert( + Principal( + 0, + service.id, + mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/") + ) + ) + + // Refresh principals - detecting it does not own collections + principalsRefresher.create(service, client.okHttpClient).refreshPrincipals() + + // Check principal was deleted + val principals = db.principalDao().getByService(service.id) + assertEquals(0, principals.size) + } + + + companion object { + + private const val PATH_CARDDAV = "/carddav" + + private const val SUBPATH_PRINCIPAL = "/principal" + private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal" + private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2" + private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0" + private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset" + private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty" + private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts" + + } + + class TestDispatcher( + private val logger: Logger + ) : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path!!.trimEnd('/') + + if (request.method.equals("PROPFIND", true)) { + val properties = when (path) { + + PATH_CARDDAV + SUBPATH_PRINCIPAL -> + "" + + "Mr. Wobbles" + "" + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}" + "" + "" + " ${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}" + + "" + + PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS -> + "" + + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}" + + "" + + "Mr. Wobbles Jr." + + + SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> "" + + else -> "" + } + + logger.info("Queried: $path") + return MockResponse() + .setResponseCode(207) + .setBody( + "" + + "" + + " $path" + + " " + + properties + + " " + + "" + + "" + ) + } + + return MockResponse().setResponseCode(404) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt new file mode 100644 index 0000000..24ed912 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import android.security.NetworkSecurityPolicy +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class ServiceRefresherTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var serviceRefresherFactory: ServiceRefresher.Factory + + private lateinit var client: HttpClient + private lateinit var mockServer: MockWebServer + private lateinit var service: Service + + @Before + fun setUp() { + hiltRule.inject() + + // Start mock web server + mockServer = MockWebServer().apply { + dispatcher = TestDispatcher(logger) + start() + } + + // build HTTP client + client = httpClientBuilder.build() + Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + + // insert test service + val serviceId = db.serviceDao().insertOrReplace( + Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null) + ) + service = db.serviceDao().get(serviceId)!! + } + + @After + fun tearDown() { + client.close() + mockServer.shutdown() + } + + + @Test + fun testDiscoverHomesets() { + val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL) + + // Query home sets + serviceRefresherFactory.create(service, client.okHttpClient) + .discoverHomesets(baseUrl) + + // Check home set has been saved correctly to database + val savedHomesets = db.homeSetDao().getByService(service.id) + assertEquals(2, savedHomesets.size) + + // Home set from current-user-principal + val personalHomeset = savedHomesets[1] + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url) + assertEquals(service.id, personalHomeset.serviceId) + // personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!) + assertEquals(true, personalHomeset.personal) + + // Home set found in a group principal + val groupHomeset = savedHomesets[0] + assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url) + assertEquals(service.id, groupHomeset.serviceId) + // personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups) + assertEquals(false, groupHomeset.personal) + } + + + companion object { + private const val PATH_CARDDAV = "/carddav" + + private const val SUBPATH_PRINCIPAL = "/principal" + private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0" + private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset" + private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal" + + } + + class TestDispatcher( + private val logger: Logger + ) : Dispatcher() { + + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path!!.trimEnd('/') + logger.info("Query: ${request.method} on $path ") + + if (request.method.equals("PROPFIND", true)) { + val properties = when (path) { + PATH_CARDDAV + SUBPATH_PRINCIPAL -> + "" + + "Mr. Wobbles" + + "" + + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}" + + "" + + "" + + " ${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}" + + "" + + PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 -> + "" + + "All address books" + + "" + + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}" + + " ${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}" + + "" + + else -> "" + } + return MockResponse() + .setResponseCode(207) + .setBody( + "" + + "" + + " $path" + + " " + + properties + + " " + + "" + + "" + ) + } + + return MockResponse().setResponseCode(404) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/AccountSettingsTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/AccountSettingsTest.kt new file mode 100644 index 0000000..67eb29a --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/AccountSettingsTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import android.accounts.AccountManager +import android.content.Context +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.sync.account.TestAccount +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountSettingsTest { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var accountSettingsFactory: AccountSettings.Factory + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + + @Before + fun setUp() { + hiltRule.inject() + TestUtils.setUpWorkManager(context) + } + + + @Test(expected = IllegalArgumentException::class) + fun testUpdate_MissingMigrations() { + TestAccount.provide(version = 1) { account -> + // will run AccountSettings.update + accountSettingsFactory.create(account, abortOnMissingMigration = true) + } + } + + @Test + fun testUpdate_RunAllMigrations() { + TestAccount.provide(version = 6) { account -> + // will run AccountSettings.update + accountSettingsFactory.create(account, abortOnMissingMigration = true) + + val accountManager = AccountManager.get(context) + val version = accountManager.getUserData(account, AccountSettings.KEY_SETTINGS_VERSION).toInt() + assertEquals(AccountSettings.CURRENT_VERSION, version) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt new file mode 100644 index 0000000..277ce77 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/SettingsManagerTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toSet +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class SettingsManagerTest { + + companion object { + /** Use this setting to test SettingsManager methods. Will be removed after every test run. */ + const val SETTING_TEST = "test" + } + + + @get:Rule + val hiltRule = HiltAndroidRule(this) + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Inject lateinit var settingsManager: SettingsManager + + @Before + fun inject() { + hiltRule.inject() + } + + @After + fun removeTestSetting() { + settingsManager.remove(SETTING_TEST) + } + + + @Test + fun test_containsKey_NotExisting() { + assertFalse(settingsManager.containsKey("notExisting")) + } + + @Test + fun test_containsKey_Existing() { + // provided by DefaultsProvider + assertEquals(Settings.PROXY_TYPE_SYSTEM, settingsManager.getInt(Settings.PROXY_TYPE)) + } + + + @Test + fun test_observerFlow_initialValue() = runTest { + var counter = 0 + val live = settingsManager.observerFlow { + if (counter++ == 0) + 23 + else + throw AssertionError("A second value was requested") + } + assertEquals(23, live.first()) + } + + @Test + fun test_observerFlow_updatedValue() = runTest { + var counter = 0 + val live = settingsManager.observerFlow { + when (counter++) { + 0 -> { + // update some setting so that we will be called a second time + settingsManager.putBoolean(SETTING_TEST, true) + // and emit initial value + 23 + } + 1 -> 42 // updated value + else -> throw AssertionError() + } + } + + val result = live.take(2).toSet() + assertEquals(setOf(23, 42), result) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration17Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration17Test.kt new file mode 100644 index 0000000..50fbdfb --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration17Test.kt @@ -0,0 +1,102 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.sync.account.TestAccount +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountSettingsMigration17Test { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var migration: AccountSettingsMigration17 + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val permissionRule = GrantPermissionRule.grant(android.Manifest.permission.READ_CONTACTS, android.Manifest.permission.WRITE_CONTACTS) + + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testMigrate_OldAddressBook_CollectionInDB() { + val localAddressBookUserDataUrl = "url" + TestAccount.provide(version = 16) { account -> + val accountManager = AccountManager.get(context) + val addressBookAccountType = context.getString(R.string.account_type_address_book) + var addressBookAccount = Account("Address Book", addressBookAccountType) + assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null)) + + try { + // address book has account + URL + val url = "https://example.com/address-book" + accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name) + accountManager.setAndVerifyUserData(addressBookAccount, localAddressBookUserDataUrl, url) + + // and is known in database + db.serviceDao().insertOrReplace( + Service( + id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null + ) + ) + db.collectionDao().insert( + Collection( + id = 100, + serviceId = 1, + url = url.toHttpUrl(), + type = Collection.TYPE_ADDRESSBOOK, + displayName = "Some Address Book" + ) + ) + + // run migration + migration.migrate(account) + + // migration renames address book, update account + addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter { + accountManager.getUserData(it, localAddressBookUserDataUrl) == url + }.first() + assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name) + + // ID is now assigned + assertEquals(100L, accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLong()) + } finally { + accountManager.removeAccountExplicitly(addressBookAccount) + } + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18Test.kt new file mode 100644 index 0000000..18fa030 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18Test.kt @@ -0,0 +1,122 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalAddressBook +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import io.mockk.verify +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountSettingsMigration18Test { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var migration: AccountSettingsMigration18 + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testMigrate_AddressBook_InvalidCollection() { + val addressBookAccountType = context.getString(R.string.account_type_address_book) + var addressBookAccount = Account("Address Book", addressBookAccountType) + + val accountManager = AccountManager.get(context) + mockkObject(accountManager) + every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount) + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123" + + val account = Account("test", "test") + migration.migrate(account) + + verify(exactly = 0) { + accountManager.setUserData(addressBookAccount, any(), any()) + } + } + + @Test + fun testMigrate_AddressBook_NoCollection() { + val addressBookAccountType = context.getString(R.string.account_type_address_book) + var addressBookAccount = Account("Address Book", addressBookAccountType) + + val accountManager = AccountManager.get(context) + mockkObject(accountManager) + every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount) + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123" + + val account = Account("test", "test") + migration.migrate(account) + + verify(exactly = 0) { + accountManager.setUserData(addressBookAccount, any(), any()) + } + } + + @Test + fun testMigrate_AddressBook_ValidCollection() { + val account = Account("test", "test") + + db.serviceDao().insertOrReplace(Service( + id = 10, + accountName = account.name, + type = Service.TYPE_CARDDAV, + principal = null + )) + db.collectionDao().insertOrUpdateByUrl(Collection( + id = 100, + serviceId = 10, + url = "http://example.com".toHttpUrl(), + type = Collection.TYPE_ADDRESSBOOK + )) + + val addressBookAccountType = context.getString(R.string.account_type_address_book) + var addressBookAccount = Account("Address Book", addressBookAccountType) + + val accountManager = AccountManager.get(context) + mockkObject(accountManager) + every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount) + every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "100" + + migration.migrate(account) + + verify { + accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name) + accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration19Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration19Test.kt new file mode 100644 index 0000000..f12a009 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration19Test.kt @@ -0,0 +1,83 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import at.bitfire.davdroid.sync.AutomaticSyncManager +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountSettingsMigration19Test { + + @Inject @ApplicationContext + lateinit var context: Context + + @BindValue + @RelaxedMockK + lateinit var automaticSyncManager: AutomaticSyncManager + + @Inject + lateinit var migration: AccountSettingsMigration19 + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + + @Before + fun setUp() { + hiltRule.inject() + + // Initialize WorkManager for instrumentation tests. + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setWorkerFactory(workerFactory) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + + @Test + fun testMigrate_CancelsOldWorkersAndUpdatesAutomaticSync() { + val workManager = WorkManager.getInstance(context) + mockkObject(workManager) + + val account = Account("Some", "Test") + migration.migrate(account) + + verify { + workManager.cancelUniqueWork("periodic-sync at.bitfire.davdroid.addressbooks Test/Some") + workManager.cancelUniqueWork("periodic-sync com.android.calendar Test/Some") + workManager.cancelUniqueWork("periodic-sync at.techbee.jtx.provider Test/Some") + workManager.cancelUniqueWork("periodic-sync org.dmfs.tasks Test/Some") + workManager.cancelUniqueWork("periodic-sync org.tasks.opentasks Test/Some") + + automaticSyncManager.updateAutomaticSync(account) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration20Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration20Test.kt new file mode 100644 index 0000000..771eea7 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration20Test.kt @@ -0,0 +1,144 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.provider.CalendarContract +import android.provider.CalendarContract.Calendars +import androidx.core.content.contentValuesOf +import androidx.core.database.getLongOrNull +import androidx.test.rule.GrantPermissionRule +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalCalendarStore +import at.bitfire.davdroid.resource.LocalTestAddressBookProvider +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.vcard4android.GroupMethod +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountSettingsMigration20Test { + + @Inject + lateinit var calendarStore: LocalCalendarStore + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var migration: AccountSettingsMigration20 + + @Inject + lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + @get:Rule + val permissionsRule = GrantPermissionRule.grant( + Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR + ) + + val accountManager by lazy { AccountManager.get(context) } + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testMigrateAddressBooks_UrlMatchesCollection() { + // set up legacy address-book with URL, but without collection ID + val account = Account("test", "test") + val url = "https://example.com/" + + db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null)) + val collectionId = db.collectionDao().insert(Collection( + serviceId = 1, + type = Collection.Companion.TYPE_ADDRESSBOOK, + url = url.toHttpUrl() + )) + + localTestAddressBookProvider.provide(account, mockk(relaxed = true), GroupMethod.GROUP_VCARDS) { addressBook -> + + accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name) + accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type) + accountManager.setAndVerifyUserData(addressBook.addressBookAccount, AccountSettingsMigration20.ADDRESS_BOOK_USER_DATA_URL, url) + accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, null) + + migration.migrateAddressBooks(account, cardDavServiceId = 1) + + assertEquals( + collectionId, + accountManager.getUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID).toLongOrNull() + ) + } + } + + + @Test + fun testMigrateCalendars_UrlMatchesCollection() { + // set up legacy calendar with URL, but without collection ID + val account = Account("test", CalendarContract.ACCOUNT_TYPE_LOCAL) + val url = "https://example.com/" + + db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CALDAV, principal = null)) + val collectionId = db.collectionDao().insert( + Collection( + serviceId = 1, + type = Collection.Companion.TYPE_CALENDAR, + url = url.toHttpUrl() + ) + ) + + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!.use { provider -> + val uri = provider.insert( + Calendars.CONTENT_URI.asSyncAdapter(account), + contentValuesOf( + Calendars.ACCOUNT_NAME to account.name, + Calendars.ACCOUNT_TYPE to account.type, + Calendars.CALENDAR_DISPLAY_NAME to "Test", + Calendars.NAME to url, + Calendars.SYNC_EVENTS to 1 + ) + )!!.asSyncAdapter(account) + try { + migration.migrateCalendars(account, 1) + + provider.query(uri, arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor -> + cursor.moveToNext() + assertEquals(collectionId, cursor.getLongOrNull(0)) + } + } finally { + provider.delete(uri, null, null) + } + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt new file mode 100644 index 0000000..da251a4 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt @@ -0,0 +1,238 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentResolver +import android.content.SyncRequest +import android.content.SyncStatusObserver +import android.os.Bundle +import android.provider.CalendarContract +import androidx.test.filters.SdkSuppress +import at.bitfire.davdroid.sync.account.TestAccount +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.AfterClass +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import java.util.Collections +import java.util.LinkedList +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltAndroidTest +class AndroidSyncFrameworkTest: SyncStatusObserver { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var logger: Logger + + lateinit var account: Account + val authority = CalendarContract.AUTHORITY + + private lateinit var stateChangeListener: Any + private val recordedStates = Collections.synchronizedList(LinkedList()) + + @Before + fun setUp() { + hiltRule.inject() + + account = TestAccount.create() + + // Enable sync globally and for the test account + ContentResolver.setIsSyncable(account, authority, 1) + + // Remember states the sync framework reports as pairs of (sync pending, sync active). + recordedStates.clear() + onStatusChanged(0) // record first entry (pending = false, active = false) + stateChangeListener = ContentResolver.addStatusChangeListener( + ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, + this + ) + } + + @After + fun tearDown() { + ContentResolver.removeStatusChangeListener(stateChangeListener) + TestAccount.remove(account) + } + + + /** + * Correct behaviour of the sync framework on Android 13 and below. + * Pending state is correctly reflected + */ + @SdkSuppress(maxSdkVersion = 33) + @Test + fun testVerifySyncAlwaysPending_correctBehaviour_android13() { + verifySyncStates( + listOf( + State(pending = false, active = false), // no sync pending or active + State(pending = true, active = false, optional = true), // sync becomes pending + State(pending = true, active = true), // ... and pending and active at the same time + State(pending = false, active = true), // ... and then only active + State(pending = false, active = false) // sync finished + ) + ) + } + + /** + * Wrong behaviour of the sync framework on Android 14+. + * Pending state stays true forever (after initial run), active state behaves correctly + */ + @SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */) + @Test + fun testVerifySyncAlwaysPending_wrongBehaviour_android14() { + verifySyncStates( + listOf( + State(pending = false, active = false), // no sync pending or active + State(pending = true, active = false, optional = true), // sync becomes pending + State(pending = true, active = true), // ... and pending and active at the same time + State(pending = true, active = false) // ... and finishes, but stays pending + ) + ) + } + + + // helpers + + private fun syncRequest() = SyncRequest.Builder() + .setSyncAdapter(account, authority) + .syncOnce() + .setExtras(Bundle()) // needed for Android 9 + .setExpedited(true) // sync request will be scheduled at the front of the sync request queue + .setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF + .build() + + /** + * Verifies that the given expected states match the recorded states. + */ + private fun verifySyncStates(expectedStates: List) = runBlocking { + // Verify that last state is non-optional. + if (expectedStates.last().optional) + throw IllegalArgumentException("Last expected state must not be optional") + + // We use runBlocking for these tests because it uses the default dispatcher + // which does not auto-advance virtual time and we need real system time to + // test the sync framework behavior. + + ContentResolver.requestSync(syncRequest()) + + // Even though the always-pending-bug is present on Android 14+, the sync active + // state behaves correctly, so we can record the state changes as pairs (pending, + // active) and expect a certain sequence of state pairs to verify the presence or + // absence of the bug on different Android versions. + withTimeout(60.seconds) { // Usually takes less than 30 seconds + while (recordedStates.size < expectedStates.size) { + // verify already known states + if (recordedStates.isNotEmpty()) + assertStatesEqual(expectedStates, recordedStates, fullMatch = false) + + delay(500) // avoid busy-waiting + } + + assertStatesEqual(expectedStates, recordedStates, fullMatch = true) + } + } + + private fun assertStatesEqual(expectedStates: List, actualStates: List, fullMatch: Boolean) { + assertTrue("Expected states=$expectedStates, actual=$actualStates", statesMatch(expectedStates, actualStates, fullMatch)) + } + + /** + * Checks whether [actualStates] have matching [expectedStates], under the condition + * that expected states with the [State.optional] flag can be skipped. + * + * Note: When [fullMatch] is not set, this method can return _true_ even if not all expected states are used. + * + * @param expectedStates expected states (can include optional states which don't have to be present in actual states) + * @param actualStates actual states + * @param fullMatch whether all non-optional expected states must be present in actual states + */ + private fun statesMatch(expectedStates: List, actualStates: List, fullMatch: Boolean): Boolean { + // iterate through entries + val expectedIterator = expectedStates.iterator() + for (actual in actualStates) { + if (!expectedIterator.hasNext()) + return false + var expected = expectedIterator.next() + + // skip optional expected entries if they don't match the actual entry + while (!actual.stateEquals(expected) && expected.optional) { + if (!expectedIterator.hasNext()) + return false + expected = expectedIterator.next() + } + + // we now have a non-optional expected state and it must match + if (!actual.stateEquals(expected)) + return false + } + + // full match: all expected states must have been used + if (fullMatch && expectedIterator.hasNext()) + return false + + return true + } + + + // SyncStatusObserver implementation and data class + + override fun onStatusChanged(which: Int) { + val state = State( + pending = ContentResolver.isSyncPending(account, authority), + active = ContentResolver.isSyncActive(account, authority) + ) + synchronized(recordedStates) { + if (recordedStates.lastOrNull() != state) { + logger.info("$account syncState = $state") + recordedStates += state + } + } + } + + data class State( + val pending: Boolean, + val active: Boolean, + val optional: Boolean = false + ) { + fun stateEquals(other: State) = + pending == other.pending && active == other.active + } + + + companion object { + + var globalAutoSyncBeforeTest = false + + @BeforeClass + @JvmStatic + fun before() { + globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically() + + // We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS + ContentResolver.setMasterSyncAutomatically(false) + } + + @AfterClass + @JvmStatic + fun after() { + ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/FakeSyncAdapter.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/FakeSyncAdapter.kt new file mode 100644 index 0000000..a47305e --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/FakeSyncAdapter.kt @@ -0,0 +1,51 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.IBinder +import at.bitfire.davdroid.sync.adapter.SyncAdapter +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class FakeSyncAdapter @Inject constructor( + @ApplicationContext context: Context, + private val logger: Logger +): AbstractThreadedSyncAdapter(context, true), SyncAdapter { + + init { + logger.info("FakeSyncAdapter created") + } + + override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + logger.log( + Level.INFO, + "onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)", + extras.keySet().map { key -> "extras[$key] = ${extras[key]}" } + ) + + // fake 5 sec sync + try { + Thread.sleep(5000) + } catch (_: InterruptedException) { + logger.info("onPerformSync($account) cancelled") + } + + logger.info("onPerformSync($account) finished") + } + + + // SyncAdapter implementation and Hilt module + + override fun getBinder(): IBinder = syncAdapterBinder + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt new file mode 100644 index 0000000..8ac7f01 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.resource.LocalJtxCollectionStore +import at.bitfire.davdroid.sync.account.TestAccount +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.ical4android.TaskProvider +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.test.GrantPermissionOrSkipRule +import at.techbee.jtx.JtxContract +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeNotNull +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.StringReader +import javax.inject.Inject + + +/** + * Ensure you have jtxBoard installed on the emulator, before running these tests. Otherwise they + * will be skipped. + */ +@HiltAndroidTest +class JtxSyncManagerTest { + + @Inject + @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var serviceRepository: DavServiceRepository + + @Inject + lateinit var localJtxCollectionStore: LocalJtxCollectionStore + + @Inject + lateinit var jtxSyncManagerFactory: JtxSyncManager.Factory + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val permissionRule = GrantPermissionOrSkipRule(TaskProvider.PERMISSIONS_JTX.toSet()) + + lateinit var account: Account + + private lateinit var provider: ContentProviderClient + private lateinit var syncManager: JtxSyncManager + private lateinit var localJtxCollection: LocalJtxCollection + + @Before + fun setUp() { + hiltRule.inject() + + // Check jtxBoard permissions were granted (+jtxBoard is installed); skip test otherwise + assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX)) + + // Acquire the jtx content provider + val providerOrNull = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY) + assumeNotNull(providerOrNull) + provider = providerOrNull!! + + account = TestAccount.create() + + // Create dummy dependencies + val service = Service(0, account.name, Service.TYPE_CALDAV, null) + val serviceId = serviceRepository.insertOrReplaceBlocking(service) + val dbCollection = Collection( + 0, + serviceId, + type = Collection.TYPE_CALENDAR, + url = "https://example.com".toHttpUrl() + ) + localJtxCollection = localJtxCollectionStore.create(provider, dbCollection)!! + syncManager = jtxSyncManagerFactory.jtxSyncManager( + account = account, + httpClient = httpClientBuilder.build(), + syncResult = SyncResult(), + localCollection = localJtxCollection, + collection = dbCollection, + resync = null + ) + } + + @After + fun tearDown() { + if (this::localJtxCollection.isInitialized) + localJtxCollectionStore.delete(localJtxCollection) + serviceRepository.deleteAllBlocking() + + if (this::provider.isInitialized) + provider.closeCompat() + + if (this::account.isInitialized) + TestAccount.remove(account) + } + + + @Test + fun testProcessICalObject_addsVtodo() { + val calendar = "BEGIN:VCALENDAR\n" + + "PRODID:-Vivaldi Calendar V1.0//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VTODO\n" + + "SUMMARY:Test Task (Main VTODO)\n" + + "DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" + + "UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" + + "END:VTODO\n" + + "END:VCALENDAR" + + // Should create "demo-calendar" + syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar)) + + // Verify main VTODO is created + val localJtxIcalObject = localJtxCollection.findByName("demo-calendar")!! + assertEquals("47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f", localJtxIcalObject.uid) + assertEquals("abc123", localJtxIcalObject.eTag) + assertEquals("Test Task (Main VTODO)", localJtxIcalObject.summary) + } + + @Test + fun testProcessICalObject_addsRecurringVtodo_withoutDtStart() { + // Valid calendar example (See bitfireAT/davx5-ose#1265) + // Note: We don't support starting a recurrence from DUE (RFC 5545 leaves it open to interpretation) + val calendar = "BEGIN:VCALENDAR\n" + + "PRODID:-Vivaldi Calendar V1.0//EN\n" + + "VERSION:2.0\n" + + "BEGIN:VTODO\n" + + + "SUMMARY:Test Task (Exception)\n" + + "DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" + + "DUE;TZID=America/New_York:20250228T130000\n" + + "RECURRENCE-ID;TZID=America/New_York:20250228T130000\n" + + "UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" + + + "END:VTODO\n" + + "BEGIN:VTODO\n" + + + "SUMMARY:Test Task (Main VTODO)\n" + + "DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" + + "DUE;TZID=America/New_York:20250228T130000\n" + // Due date will NOT be assumed as start for recurrence + "SEQUENCE:1\n" + + "UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" + + "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20250505T235959Z\n" + + + "END:VTODO\n" + + "END:VCALENDAR" + + // Create and store calendar + syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar)) + + // Verify main VTODO was created with RRULE present + val mainVtodo = localJtxCollection.findByName("demo-calendar")!! + assertEquals("Test Task (Main VTODO)", mainVtodo.summary) + assertEquals("FREQ=WEEKLY;UNTIL=20250505T235959Z;INTERVAL=1;BYDAY=FR", mainVtodo.rrule) + + // Verify the RRULE exception instance was created with correct recurrence-id timezone + val vtodoException = localJtxCollection.findRecurInstance( + uid = "47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f", + recurid = "20250228T130000" + )!! + assertEquals("Test Task (Exception)", vtodoException.summary) + assertEquals("America/New_York", vtodoException.recuridTimezone) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt new file mode 100644 index 0000000..86a37c5 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestCollection.kt @@ -0,0 +1,47 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import at.bitfire.davdroid.resource.LocalCollection +import at.bitfire.davdroid.resource.SyncState + +class LocalTestCollection( + override val dbCollectionId: Long = 0L +): LocalCollection { + + override val tag = "LocalTestCollection" + override val title = "Local Test Collection" + + override var lastSyncState: SyncState? = null + + val entries = mutableListOf() + + override val readOnly: Boolean + get() = throw NotImplementedError() + + override fun findDeleted() = entries.filter { it.deleted } + override fun findDirty() = entries.filter { it.dirty } + + override fun findByName(name: String) = entries.firstOrNull { it.fileName == name } + + override fun markNotDirty(flags: Int): Int { + var updated = 0 + for (dirty in findDirty()) { + dirty.flags = flags + updated++ + } + return updated + } + + override fun removeNotDirtyMarked(flags: Int): Int { + val numBefore = entries.size + entries.removeIf { !it.dirty && it.flags == flags } + return numBefore - entries.size + } + + override fun forgetETags() { + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt new file mode 100644 index 0000000..f41b3e8 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt @@ -0,0 +1,44 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.content.Context +import at.bitfire.davdroid.resource.LocalResource +import java.util.Optional + +class LocalTestResource: LocalResource { + + override val id: Long? = null + override var fileName: String? = null + override var eTag: String? = null + override var scheduleTag: String? = null + override var flags: Int = 0 + + var deleted = false + var dirty = false + + override fun prepareForUpload() = "generated-file.txt" + + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { + dirty = false + if (fileName.isPresent) + this.fileName = fileName.get() + this.eTag = eTag + this.scheduleTag = scheduleTag + } + + override fun updateFlags(flags: Int) { + this.flags = flags + } + + override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError() + override fun deleteLocal() = throw NotImplementedError() + override fun resetDeleted() = throw NotImplementedError() + + override fun getDebugSummary() = "Test Resource" + + override fun getViewUri(context: Context) = null + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncAdapterImplTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncAdapterImplTest.kt new file mode 100644 index 0000000..a6c06d3 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncAdapterImplTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.provider.CalendarContract +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.WorkInfo +import androidx.work.WorkManager +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.sync.account.TestAccount +import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.Awaits +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject +import javax.inject.Provider +import kotlin.coroutines.cancellation.CancellationException + +@HiltAndroidTest +class SyncAdapterImplTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var syncAdapterImplProvider: Provider + + @BindValue @MockK + lateinit var syncWorkerManager: SyncWorkerManager + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + lateinit var account: Account + + private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically() + + @Before + fun setUp() { + hiltRule.inject() + TestUtils.setUpWorkManager(context, workerFactory) + + account = TestAccount.create() + + ContentResolver.setMasterSyncAutomatically(true) + ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true) + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + } + + @After + fun tearDown() { + ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest) + TestAccount.remove(account) + } + + + @Test + fun testSyncAdapter_onPerformSync_cancellation() = runTest { + val workManager = WorkManager.getInstance(context) + val syncAdapter = syncAdapterImplProvider.get() + + mockkObject(workManager) { + // don't actually create a worker + every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker" + + // assume worker takes a long time + every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits + + val sync = launch { + syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult()) + } + + // simulate incoming cancellation from sync framework + syncAdapter.onSyncCanceled() + + // wait for sync to finish (should happen immediately) + sync.join() + } + } + + @Test + fun testSyncAdapter_onPerformSync_returnsAfterTimeout() { + val workManager = WorkManager.getInstance(context) + val syncAdapter = syncAdapterImplProvider.get() + + mockkObject(workManager) { + // don't actually create a worker + every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker" + + // assume worker takes a long time + every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits + + mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function + // immediate timeout (instead of really waiting) + coEvery { withTimeout(any(), any Unit>()) } throws CancellationException("Simulated timeout") + + syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult()) + } + } + } + + @Test + fun testSyncAdapter_onPerformSync_runsInTime() { + val workManager = WorkManager.getInstance(context) + val syncAdapter = syncAdapterImplProvider.get() + + mockkObject(workManager) { + // don't actually create a worker + every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker" + + // assume worker immediately returns with success + val success = mockk() + every { success.state } returns WorkInfo.State.SUCCEEDED + every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow { + emit(listOf(success)) + delay(60000) // keep the flow active + } + + // should just run + syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult()) + } + } + +} diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt new file mode 100644 index 0000000..fefbd0c --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt @@ -0,0 +1,503 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.hilt.work.HiltWorkerFactory +import at.bitfire.dav4jvm.PropStat +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.Response.HrefRelation +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.TestUtils.assertWithin +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.repository.DavSyncStatsRepository +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.account.TestAccount +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.Protocol +import okhttp3.internal.http.StatusLine +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.Instant +import javax.inject.Inject + +@HiltAndroidTest +class SyncManagerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockKRule = MockKRule(this) + + @Inject + lateinit var accountSettingsFactory: AccountSettings.Factory + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var syncManagerFactory: TestSyncManager.Factory + + @BindValue + @RelaxedMockK + lateinit var syncStatsRepository: DavSyncStatsRepository + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + private lateinit var account: Account + private lateinit var server: MockWebServer + + @Before + fun setUp() { + hiltRule.inject() + + TestUtils.setUpWorkManager(context, workerFactory) + + account = TestAccount.create() + + server = MockWebServer().apply { + start() + } + } + + @After + fun tearDown() { + TestAccount.remove(account) + + // clear annoying syncError notifications + NotificationManagerCompat.from(context).cancelAll() + + server.close() + } + + + private fun queryCapabilitiesResponse(cTag: String? = null): MockResponse { + val body = StringBuilder() + body.append( + "\n" + + "\n" + + " \n" + + " /\n" + + " \n" + + " \n" + ) + if (cTag != null) + body.append("$cTag\n") + body.append( + " \n" + + " \n" + + " \n" + + "" + ) + return MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "text/xml") + .setBody(body.toString()) + } + + + @Test + fun testPerformSync_503RetryAfter_DelaySeconds() = runTest { + server.enqueue(MockResponse() + .setResponseCode(503) + .setHeader("Retry-After", "60")) // 60 seconds + + val result = SyncResult() + val syncManager = syncManager(LocalTestCollection(), result) + syncManager.performSync() + + val expected = Instant.now() + .plusSeconds(60) + .toEpochMilli() + // 5 sec tolerance for test + assertWithin(expected, result.delayUntil*1000, 5000) + } + + @Test + fun testPerformSync_FirstSync_Empty() = runTest { + val collection = LocalTestCollection() /* no last known ctag */ + server.enqueue(queryCapabilitiesResponse()) + + val syncManager = syncManager(collection) + syncManager.performSync() + + assertFalse(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertFalse(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertTrue(collection.entries.isEmpty()) + } + + @Test + fun testPerformSync_UploadNewMember_ETagOnPut() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + entries += LocalTestResource().apply { + dirty = true + } + } + server.enqueue(queryCapabilitiesResponse("ctag1")) + + // PUT -> 204 No Content + server.enqueue(MockResponse() + .setResponseCode(204) + .setHeader("ETag", "etag-from-put")) + + // modifications sent, so DAVx5 will query CTag again + server.enqueue(queryCapabilitiesResponse("ctag2")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/generated-file.txt"), + null, + listOf(PropStat( + listOf( + GetETag("\"etag-from-put\"") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + } + syncManager.performSync() + + assertTrue(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertFalse(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("etag-from-put", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_UploadModifiedMember_ETagOnPut() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + entries += LocalTestResource().apply { + fileName = "existing-file.txt" + eTag = "old-etag-like-on-server" + dirty = true + } + } + server.enqueue(queryCapabilitiesResponse("ctag1")) + + // PUT -> 204 No Content + server.enqueue(MockResponse() + .setResponseCode(204) + .addHeader("ETag", "etag-from-put")) + + // modifications sent, so DAVx5 will query CTag again + server.enqueue(queryCapabilitiesResponse("ctag2")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/existing-file.txt"), + null, + listOf(PropStat( + listOf( + GetETag("etag-from-put") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + + assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-put")) + } + syncManager.performSync() + + assertTrue(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertFalse(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("etag-from-put", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_UploadModifiedMember_NoETagOnPut() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + entries += LocalTestResource().apply { + fileName = "existing-file.txt" + eTag = "old-etag-like-on-server" + dirty = true + } + } + server.enqueue(queryCapabilitiesResponse("ctag1")) + + // PUT -> 204 No Content + server.enqueue(MockResponse().setResponseCode(204)) + + // modifications sent, so DAVx5 will query CTag again + server.enqueue(queryCapabilitiesResponse("ctag2")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/existing-file.txt"), + null, + listOf(PropStat( + listOf( + GetETag("etag-from-propfind") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + + assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-propfind")) + } + syncManager.performSync() + + assertTrue(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertTrue(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("etag-from-propfind", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_UploadModifiedMember_412PreconditionFailed() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + entries += LocalTestResource().apply { + fileName = "existing-file.txt" + eTag = "etag-that-has-been-changed-on-server-in-the-meanwhile" + dirty = true + } + } + server.enqueue(queryCapabilitiesResponse("ctag1")) + + // PUT -> 412 Precondition Failed + server.enqueue(MockResponse() + .setResponseCode(412)) + + // modifications sent, so DAVx5 will query CTag again + server.enqueue(queryCapabilitiesResponse("ctag1")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/existing-file.txt"), + null, + listOf(PropStat( + listOf( + GetETag("changed-etag-from-server") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + + assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "changed-etag-from-server")) + } + syncManager.performSync() + + assertTrue(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertTrue(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("changed-etag-from-server", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_NoopOnMemberWithSameETag() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1") + entries += LocalTestResource().apply { + fileName = "downloaded-member.txt" + eTag = "MemberETag1" + } + } + server.enqueue(queryCapabilitiesResponse("ctag2")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/downloaded-member.txt"), + null, + listOf(PropStat( + listOf( + GetETag("\"MemberETag1\"") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + + } + syncManager.performSync() + + assertFalse(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertFalse(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("MemberETag1", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_DownloadNewMember() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + } + server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/new-member.txt"), + null, + listOf(PropStat( + listOf( + GetETag("\"NewMemberETag1\"") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + + assertDownloadRemote = mapOf(Pair(server.url("/new-member.txt"), "NewMemberETag1")) + } + syncManager.performSync() + + assertFalse(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertTrue(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("NewMemberETag1", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_DownloadUpdatedMember() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + entries += LocalTestResource().apply { + fileName = "downloaded-member.txt" + eTag = "MemberETag1" + } + } + server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag")) + + val syncManager = syncManager(collection).apply { + listAllRemoteResult = listOf( + Pair(Response( + server.url("/"), + server.url("/downloaded-member.txt"), + null, + listOf(PropStat( + listOf( + GetETag("\"MemberETag2\"") + ), + StatusLine(Protocol.HTTP_1_1, 200, "OK") + ) + )), HrefRelation.MEMBER) + ) + + assertDownloadRemote = mapOf(Pair(server.url("/downloaded-member.txt"), "MemberETag2")) + } + syncManager.performSync() + + assertFalse(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertTrue(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertEquals(1, collection.entries.size) + assertEquals("MemberETag2", collection.entries.first().eTag) + } + + @Test + fun testPerformSync_RemoveVanishedMember() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag") + entries += LocalTestResource().apply { + fileName = "downloaded-member.txt" + } + } + server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag")) + + val syncManager = syncManager(collection) + syncManager.performSync() + + assertFalse(syncManager.didGenerateUpload) + assertTrue(syncManager.didListAllRemote) + assertFalse(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertTrue(collection.entries.isEmpty()) + } + + @Test + fun testPerformSync_CTagDidntChange() = runTest { + val collection = LocalTestCollection().apply { + lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1") + } + server.enqueue(queryCapabilitiesResponse("ctag1")) + + val syncManager = syncManager(collection) + syncManager.performSync() + + assertFalse(syncManager.didGenerateUpload) + assertFalse(syncManager.didListAllRemote) + assertFalse(syncManager.didDownloadRemote) + assertFalse(syncManager.syncResult.hasError()) + assertTrue(collection.entries.isEmpty()) + } + + + // helpers + + private fun syncManager( + localCollection: LocalTestCollection, + syncResult: SyncResult = SyncResult(), + collection: Collection = mockk(relaxed = true) { + every { id } returns 1 + every { url } returns server.url("/") + } + ) = syncManagerFactory.create( + account, + httpClientBuilder.build(), + syncResult, + localCollection, + collection + ) + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt new file mode 100644 index 0000000..ee54409 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.resource.LocalDataStore +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.impl.annotations.SpyK +import io.mockk.junit4.MockKRule +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger + +class SyncerTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @RelaxedMockK + lateinit var logger: Logger + + val dataStore: LocalTestStore = mockk(relaxed = true) + val provider: ContentProviderClient = mockk(relaxed = true) + + @SpyK + @InjectMockKs + var syncer = TestSyncer(mockk(relaxed = true), null, SyncResult(), dataStore) + + + @Test + fun testSync_prepare_fails() { + every { syncer.prepare(provider) } returns false + every { syncer.getSyncEnabledCollections() } returns emptyMap() + + // Should stop the sync after prepare returns false + syncer.sync(provider) + verify(exactly = 1) { syncer.prepare(provider) } + verify(exactly = 0) { syncer.getSyncEnabledCollections() } + } + + @Test + fun testSync_prepare_succeeds() { + every { syncer.prepare(provider) } returns true + every { syncer.getSyncEnabledCollections() } returns emptyMap() + + // Should continue the sync after prepare returns true + syncer.sync(provider) + verify(exactly = 1) { syncer.prepare(provider) } + verify(exactly = 1) { syncer.getSyncEnabledCollections() } + } + + + @Test + fun testUpdateCollections_deletesCollection() { + val localCollection = mockk { + every { dbCollectionId } returns 0L + every { title } returns "Collection to be deleted locally" + } + + // Should delete the localCollection if dbCollection (remote) does not exist + val localCollections = mutableListOf(localCollection) + val result = syncer.updateCollections(mockk(), localCollections, emptyMap()) + verify(exactly = 1) { dataStore.delete(localCollection) } + + // Updated local collection list should be empty + assertTrue(result.isEmpty()) + } + + @Test + fun testUpdateCollections_updatesCollection() { + val localCollection = mockk { + every { dbCollectionId } returns 0L + every { title } returns "The Local Collection" + } + val dbCollection = mockk { + every { id } returns 0L + } + val dbCollections = mapOf(0L to dbCollection) + + // Should update the localCollection if it exists + val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections) + verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) } + + // Updated local collection list should be same as input + assertArrayEquals(arrayOf(localCollection), result.toTypedArray()) + } + + @Test + fun testUpdateCollections_findsNewCollection() { + val dbCollection = mockk { + every { id } returns 0L + } + val localCollections = listOf(mockk { + every { dbCollectionId } returns 0L + }) + val dbCollections = listOf(dbCollection) + val dbCollectionsMap = mapOf(dbCollection.id to dbCollection) + every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections + + // Should return the new collection, because it was not updated + val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap) + + // Updated local collection list contain new entry + assertEquals(1, result.size) + assertEquals(dbCollection.id, result[0].dbCollectionId) + } + + + @Test + fun testCreateLocalCollections() { + val localCollection = mockk() + val dbCollection = mockk() + every { dataStore.create(provider, dbCollection) } returns localCollection + + // Should return list of newly created local collections + val result = syncer.createLocalCollections(provider, listOf(dbCollection)) + assertEquals(listOf(localCollection), result) + } + + + @Test + fun testSyncCollectionContents() { + val dbCollection1 = mockk() + val dbCollection2 = mockk() + val dbCollections = mapOf( + 0L to dbCollection1, + 1L to dbCollection2 + ) + val localCollection1 = mockk { every { dbCollectionId } returns 0L } + val localCollection2 = mockk { every { dbCollectionId } returns 1L } + val localCollections = listOf(localCollection1, localCollection2) + every { localCollection1.dbCollectionId } returns 0L + every { localCollection2.dbCollectionId } returns 1L + every { syncer.syncCollection(provider, any(), any()) } just runs + + // Should call the collection content sync on both collections + syncer.syncCollectionContents(provider, localCollections, dbCollections) + verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) } + verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) } + } + + + // Test helpers + + class TestSyncer( + account: Account, + resyncType: ResyncType?, + syncResult: SyncResult, + theDataStore: LocalTestStore + ) : Syncer(account, resyncType, syncResult) { + + override val dataStore: LocalTestStore = + theDataStore + + override val serviceType: String + get() = throw NotImplementedError() + + override fun prepare(provider: ContentProviderClient): Boolean = + throw NotImplementedError() + + override fun getDbSyncCollections(serviceId: Long): List = + throw NotImplementedError() + + override fun syncCollection( + provider: ContentProviderClient, + localCollection: LocalTestCollection, + remoteCollection: Collection + ) { + throw NotImplementedError() + } + + } + + class LocalTestStore : LocalDataStore { + + override val authority: String + get() = throw NotImplementedError() + + override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? { + throw NotImplementedError() + } + + override fun create( + provider: ContentProviderClient, + fromCollection: Collection + ): LocalTestCollection? { + throw NotImplementedError() + } + + override fun getAll( + account: Account, + provider: ContentProviderClient + ): List { + throw NotImplementedError() + } + + override fun update( + provider: ContentProviderClient, + localCollection: LocalTestCollection, + fromCollection: Collection + ) { + throw NotImplementedError() + } + + override fun delete(localCollection: LocalTestCollection) { + throw NotImplementedError() + } + + override fun updateAccount(oldAccount: Account, newAccount: Account) { + throw NotImplementedError() + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt new file mode 100644 index 0000000..43bcc45 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt @@ -0,0 +1,123 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.caldav.GetCTag +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.di.SyncDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.util.DavUtils.lastSegment +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import okhttp3.HttpUrl +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assert.assertEquals + +class TestSyncManager @AssistedInject constructor( + @Assisted account: Account, + @Assisted httpClient: HttpClient, + @Assisted syncResult: SyncResult, + @Assisted localCollection: LocalTestCollection, + @Assisted collection: Collection, + @SyncDispatcher syncDispatcher: CoroutineDispatcher +): SyncManager( + account, + httpClient, + SyncDataType.EVENTS, + syncResult, + localCollection, + collection, + resync = null, + syncDispatcher +) { + + @AssistedFactory + interface Factory { + fun create( + account: Account, + httpClient: HttpClient, + syncResult: SyncResult, + localCollection: LocalTestCollection, + collection: Collection + ): TestSyncManager + } + + override fun prepare(): Boolean { + davCollection = DavCollection(httpClient.okHttpClient, collection.url) + return true + } + + var didQueryCapabilities = false + override suspend fun queryCapabilities(): SyncState? { + if (didQueryCapabilities) + throw IllegalStateException("queryCapabilities() must not be called twice") + didQueryCapabilities = true + + var cTag: SyncState? = null + davCollection.propfind(0, GetCTag.NAME) { response, rel -> + if (rel == Response.HrefRelation.SELF) + response[GetCTag::class.java]?.cTag?.let { + cTag = SyncState(SyncState.Type.CTAG, it) + } + } + + return cTag + } + + var didGenerateUpload = false + override fun generateUpload(resource: LocalTestResource): RequestBody { + didGenerateUpload = true + return resource.toString().toRequestBody() + } + + override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT + + var listAllRemoteResult = emptyList>() + var didListAllRemote = false + override suspend fun listAllRemote(callback: MultiResponseCallback) { + if (didListAllRemote) + throw IllegalStateException("listAllRemote() must not be called twice") + didListAllRemote = true + for (result in listAllRemoteResult) + callback.onResponse(result.first, result.second) + } + + var assertDownloadRemote = emptyMap() + var didDownloadRemote = false + override suspend fun downloadRemote(bunch: List) { + didDownloadRemote = true + assertEquals(assertDownloadRemote.keys.toList(), bunch) + + for ((url, eTag) in assertDownloadRemote) { + val fileName = url.lastSegment + var localEntry = localCollection.entries.firstOrNull { it.fileName == fileName } + if (localEntry == null) { + val newEntry = LocalTestResource().also { + it.fileName = fileName + } + localCollection.entries += newEntry + localEntry = newEntry + } + localEntry.eTag = eTag + localEntry.flags = LocalResource.FLAG_REMOTELY_PRESENT + } + } + + override fun postProcess() { + } + + override fun notifyInvalidResourceTitle() = + throw NotImplementedError() + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/AccountsCleanupWorkerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/AccountsCleanupWorkerTest.kt new file mode 100644 index 0000000..5ce6f34 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/AccountsCleanupWorkerTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.account + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.os.Bundle +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.testing.TestListenableWorkerBuilder +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class AccountsCleanupWorkerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var settingsManager: SettingsManager + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + lateinit var accountManager: AccountManager + lateinit var addressBookAccountType: String + lateinit var addressBookAccount: Account + lateinit var service: Service + + @Before + fun setUp() { + hiltRule.inject() + TestUtils.setUpWorkManager(context, workerFactory) + + accountManager = AccountManager.get(context) + service = createTestService() + + addressBookAccountType = context.getString(R.string.account_type_address_book) + addressBookAccount = Account("Fancy address book account", addressBookAccountType) + } + + @After + fun tearDown() { + // Remove the account here in any case; Nice to have when the test fails + accountManager.removeAccountExplicitly(addressBookAccount) + } + + + @Test + fun testCleanUpServices_noAccount() { + // Insert service that reference to invalid account + db.serviceDao().insertOrReplace(Service(id = 1, accountName = "test", type = Service.TYPE_CALDAV, principal = null)) + assertNotNull(db.serviceDao().get(1)) + + // Create worker and run the method + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(workerFactory) + .build() + worker.cleanUpServices() + + // Verify that service is deleted + assertNull(db.serviceDao().get(1)) + } + + @Test + fun testCleanUpServices_oneAccount() { + TestAccount.provide { existingAccount -> + // Insert services, one that reference the existing account and one that references an invalid account + db.serviceDao().insertOrReplace(Service(id = 1, accountName = existingAccount.name, type = Service.TYPE_CALDAV, principal = null)) + assertNotNull(db.serviceDao().get(1)) + + db.serviceDao().insertOrReplace(Service(id = 2, accountName = "not existing", type = Service.TYPE_CARDDAV, principal = null)) + assertNotNull(db.serviceDao().get(2)) + + // Create worker and run the method + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(workerFactory) + .build() + worker.cleanUpServices() + + // Verify that one service is deleted and the other one is kept + assertNotNull(db.serviceDao().get(1)) + assertNull(db.serviceDao().get(2)) + } + } + + + @Test + fun testCleanUpAddressBooks_deletesAddressBookWithoutAccount() { + // Create address book account without corresponding account + assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null)) + assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList()) + + // Create worker and run the method + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(workerFactory) + .build() + worker.cleanUpAddressBooks() + + // Verify account was deleted + assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty()) + } + + @Test + fun testCleanUpAddressBooks_keepsAddressBookWithAccount() { + TestAccount.provide { existingAccount -> + // Create address book account _with_ corresponding account and verify + val userData = Bundle(2).apply { + putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, existingAccount.name) + putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, existingAccount.type) + } + assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData)) + assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList()) + + // Create worker and run the method + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(workerFactory) + .build() + worker.cleanUpAddressBooks() + + // Verify account was _not_ deleted + assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList()) + } + } + + + // helpers + + private fun createTestService(): Service { + val service = Service(id=0, accountName="test", type=Service.TYPE_CARDDAV, principal = null) + val serviceId = db.serviceDao().insertOrReplace(service) + return db.serviceDao().get(serviceId)!! + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/SystemAccountUtilsTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/SystemAccountUtilsTest.kt new file mode 100644 index 0000000..b05b36d --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/SystemAccountUtilsTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.account + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.os.Bundle +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class SystemAccountUtilsTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var settingsManager: SettingsManager + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun testCreateAccount() { + val userData = Bundle(2) + userData.putString("int", "1") + userData.putString("string", "abc/\"-") + + val account = Account("AccountUtilsTest", context.getString(R.string.account_type)) + val manager = AccountManager.get(context) + try { + assertTrue(SystemAccountUtils.createAccount(context, account, userData)) + + // validate user data + assertEquals("1", manager.getUserData(account, "int")) + assertEquals("abc/\"-", manager.getUserData(account, "string")) + } finally { + assertTrue(manager.removeAccountExplicitly(account)) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt new file mode 100644 index 0000000..c354f97 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt @@ -0,0 +1,53 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid.sync.account + +import android.accounts.Account +import android.accounts.AccountManager +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.AccountSettings +import org.junit.Assert.assertTrue + +object TestAccount { + + private val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext } + + /** + * Creates a test account, usually in the `Before` setUp of a test. + * + * Remove it with [remove]. + */ + fun create(version: Int = AccountSettings.CURRENT_VERSION, accountName: String = "Test Account"): Account { + val accountType = targetContext.getString(R.string.account_type) + val account = Account(accountName, accountType) + + val initialData = AccountSettings.initialUserData(null) + initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString()) + assertTrue(SystemAccountUtils.createAccount(targetContext, account, initialData)) + + return account + } + + /** + * Removes a test account, usually in the `@After` tearDown of a test. + */ + fun remove(account: Account) { + val am = AccountManager.get(targetContext) + assertTrue(am.removeAccountExplicitly(account)) + } + + /** + * Convenience method to create a test account and remove it after executing the block. + */ + fun provide(version: Int = AccountSettings.CURRENT_VERSION, block: (Account) -> Unit) { + val account = create(version) + try { + block(account) + } finally { + remove(account) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/PeriodicSyncWorkerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/PeriodicSyncWorkerTest.kt new file mode 100644 index 0000000..5c59409 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/PeriodicSyncWorkerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkManager +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.account.TestAccount +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class PeriodicSyncWorkerTest { + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + lateinit var account: Account + + @Before + fun setUp() { + hiltRule.inject() + TestUtils.setUpWorkManager(context) + + account = TestAccount.create() + } + + @After + fun tearDown() { + TestAccount.remove(account) + } + + + @Test + fun doWork_cancelsItselfOnInvalidAccount() = runTest { + val invalidAccount = Account("invalid", context.getString(R.string.account_type)) + + // Run PeriodicSyncWorker as TestWorker + val inputData = workDataOf( + BaseSyncWorker.INPUT_DATA_TYPE to SyncDataType.EVENTS.toString(), + BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name, + BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type + ) + + // observe WorkManager cancellation call + val workManager = WorkManager.getInstance(context) + mockkObject(workManager) + + // run test worker, expect failure + val testWorker = TestListenableWorkerBuilder(context, inputData) + .setWorkerFactory(object: WorkerFactory() { + override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) = + syncWorkerFactory.create(appContext, workerParameters) + }) + .build() + val result = testWorker.doWork() + assertTrue(result is ListenableWorker.Result.Failure) + + // verify that worker called WorkManager.cancelWorkById() + verify { + workManager.cancelWorkById(testWorker.id) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/SyncConditionsTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/SyncConditionsTest.kt new file mode 100644 index 0000000..391e1ab --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/SyncConditionsTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import androidx.core.content.getSystemService +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.SyncConditions +import at.bitfire.davdroid.util.PermissionUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class SyncConditionsTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + lateinit var capabilities: NetworkCapabilities + + @Inject + @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var factory: SyncConditions.Factory + + @MockK + lateinit var network1: Network + + @MockK + lateinit var network2: Network + + + private lateinit var accountSettings: AccountSettings + + private lateinit var conditions: SyncConditions + + private lateinit var connectivityManager: ConnectivityManager + + @Before + fun setup() { + hiltRule.inject() + + // prepare accountSettings with some necessary data + accountSettings = mockk { + every { account } returns Account("test", "test") + every { getIgnoreVpns() } returns false // default value + } + + conditions = factory.create(accountSettings) + + connectivityManager = context.getSystemService()!!.also { cm -> + mockkObject(cm) + every { cm.allNetworks } returns arrayOf(network1, network2) + every { cm.getNetworkInfo(network1) } returns mockk() + every { cm.getNetworkInfo(network2) } returns mockk() + every { cm.getNetworkCapabilities(network1) } returns capabilities + every { cm.getNetworkCapabilities(network2) } returns capabilities + } + } + + + @Test + fun testCorrectWifiSsid_CorrectWiFiSsid() { + every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi") + + mockkObject(PermissionUtils) + every { PermissionUtils.canAccessWifiSsid(any()) } returns true + + val wifiManager = context.getSystemService()!! + mockkObject(wifiManager) + every { wifiManager.connectionInfo } returns spyk().apply { + every { ssid } returns "ConnectedWiFi" + } + + assertTrue(conditions.correctWifiSsid()) + } + + @Test + fun testCorrectWifiSsid_WrongWiFiSsid() { + every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2") + + mockkObject(PermissionUtils) + every { PermissionUtils.canAccessWifiSsid(any()) } returns true + + val wifiManager = context.getSystemService()!! + mockkObject(wifiManager) + every { wifiManager.connectionInfo } returns spyk().apply { + every { ssid } returns "ConnectedWiFi" + } + + assertFalse(conditions.correctWifiSsid()) + } + + + @Test + fun testInternetAvailable_capabilitiesNull() { + every { connectivityManager.getNetworkCapabilities(network1) } returns null + every { connectivityManager.getNetworkCapabilities(network2) } returns null + assertFalse(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_Internet() { + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false + assertFalse(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_Validated() { + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + assertFalse(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_InternetValidated() { + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + assertTrue(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_ignoreVpns() { + every { accountSettings.getIgnoreVpns() } returns true + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false + assertFalse(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_ignoreVpns_NotVpn() { + every { accountSettings.getIgnoreVpns() } returns true + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true + assertTrue(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() { + // The real case that failed in davx5-ose#395 is that the connection list contains (in this order) + // 1. a mobile network without INTERNET, but with VALIDATED + // 2. a WiFi network with INTERNET and VALIDATED + + // The "return false" of hasINTERNET will trigger at the first connection, the + // "andThen true" will trigger for the second connection + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + + // There is an internet connection if any(!) connection has both INTERNET and VALIDATED. + assertTrue(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() { + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true + assertTrue(conditions.internetAvailable()) + } + + @Test + fun testInternetAvailable_twoConnectionsFirstOneWithoutNotVpn() { + every { accountSettings.getIgnoreVpns() } returns true + every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true + assertTrue(conditions.internetAvailable()) + } + + + @Test + fun testWifiAvailable_capabilitiesNull() { + every { connectivityManager.getNetworkCapabilities(network1) } returns null + every { connectivityManager.getNetworkCapabilities(network2) } returns null + assertFalse(conditions.wifiAvailable()) + } + + @Test + fun testWifiAvailable() { + every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false + assertFalse(conditions.wifiAvailable()) + } + + @Test + fun testWifiAvailable_wifi() { + every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false + assertFalse(conditions.wifiAvailable()) + } + + @Test + fun testWifiAvailable_validated() { + every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + assertFalse(conditions.wifiAvailable()) + } + + @Test + fun testWifiAvailable_wifiValidated() { + every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true + every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true + assertTrue(conditions.wifiAvailable()) + } + + + @Test + fun testWifiConditionsMet_withoutWifi() { + // "Sync only over Wi-Fi" is disabled + every { accountSettings.getSyncWifiOnly() } returns false + + assertTrue(factory.create(accountSettings).wifiConditionsMet()) + } + + @Test + fun testWifiConditionsMet_anyWifi_wifiEnabled() { + // "Sync only over Wi-Fi" is enabled + every { accountSettings.getSyncWifiOnly() } returns true + + // Wi-Fi is available + mockkObject(conditions) { + // Wi-Fi is available + every { conditions.wifiAvailable() } returns true + + // Wi-Fi SSID is correct + every { conditions.correctWifiSsid() } returns true + + assertTrue(conditions.wifiConditionsMet()) + } + } + + @Test + fun testWifiConditionsMet_anyWifi_wifiDisabled() { + // "Sync only over Wi-Fi" is enabled + every { accountSettings.getSyncWifiOnly() } returns true + + mockkObject(conditions) { + // Wi-Fi is not available + every { conditions.wifiAvailable() } returns false + + // Wi-Fi SSID is correct + every { conditions.correctWifiSsid() } returns true + + assertFalse(conditions.wifiConditionsMet()) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/SyncWorkerManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/SyncWorkerManagerTest.kt new file mode 100644 index 0000000..c7f8be8 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/worker/SyncWorkerManagerTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import androidx.hilt.work.HiltWorkerFactory +import at.bitfire.davdroid.TestUtils +import at.bitfire.davdroid.TestUtils.workScheduledOrRunning +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.account.TestAccount +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class SyncWorkerManagerTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var syncWorkerManager: SyncWorkerManager + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + lateinit var account: Account + + @Before + fun setUp() { + hiltRule.inject() + TestUtils.setUpWorkManager(context, workerFactory) + + account = TestAccount.create() + } + + @After + fun tearDown() { + TestAccount.remove(account) + } + + + // one-time sync workers + + @Test + fun testEnqueueOneTime() { + val workerName = OneTimeSyncWorker.workerName(account, SyncDataType.EVENTS) + assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName)) + + val returnedName = syncWorkerManager.enqueueOneTime(account, SyncDataType.EVENTS) + assertEquals(workerName, returnedName) + assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName)) + } + + + // periodic sync workers + + @Test + fun enablePeriodic() { + syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get() + + val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS) + assertTrue(workScheduledOrRunning(context, workerName)) + } + + @Test + fun disablePeriodic() { + syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get() + syncWorkerManager.disablePeriodic(account, SyncDataType.EVENTS).result.get() + + val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS) + assertFalse(workScheduledOrRunning(context, workerName)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCaseTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCaseTest.kt new file mode 100644 index 0000000..b1dbe17 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCaseTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.push.PushRegistrationManager +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coVerify +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class CollectionSelectedUseCaseTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + val collection = Collection( + id = 2, + serviceId = 1, + type = Collection.Companion.TYPE_CALENDAR, + url = "https://example.com".toHttpUrl() + ) + + @Inject + lateinit var collectionRepository: DavCollectionRepository + + val service = Service( + id = 1, + type = Service.Companion.TYPE_CALDAV, + accountName = "test@example.com" + ) + + @BindValue + @RelaxedMockK + lateinit var pushRegistrationManager: PushRegistrationManager + + @Inject + lateinit var serviceRepository: DavServiceRepository + + @BindValue + @RelaxedMockK + lateinit var syncWorkerManager: SyncWorkerManager + + @Inject + lateinit var useCase: CollectionSelectedUseCase + + @Before + fun setUp() { + hiltRule.inject() + + serviceRepository.insertOrReplaceBlocking(service) + collectionRepository.insertOrUpdateByUrl(collection) + } + + @After + fun tearDown() { + serviceRepository.deleteAllBlocking() + } + + + @Test + fun testHandleWithDelay() = runTest { + useCase.handleWithDelay(collectionId = collection.id) + + advanceUntilIdle() + coVerify { + syncWorkerManager.enqueueOneTimeAllAuthorities(any()) + pushRegistrationManager.update(service.id) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt new file mode 100644 index 0000000..d0f7593 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test + +class DebugInfoActivityTest { + + @Test + fun testIntentBuilder_LargeLocalResource() { + val a = 'A'.code.toByte() + val intent = DebugInfoActivity.IntentBuilder(InstrumentationRegistry.getInstrumentation().context) + .withLocalResource(String(ByteArray(1024*1024) { a })) + .build() + val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE) + expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a })) + expected.append("...") + assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY)) + } + + @Test + fun testIntentBuilder_LargeLogs() { + val a = 'A'.code.toByte() + val intent = DebugInfoActivity.IntentBuilder(InstrumentationRegistry.getInstrumentation().context) + .withLogs(String(ByteArray(1024*1024) { a })) + .build() + val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE) + expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a })) + expected.append("...") + assertEquals(expected.toString(), intent.getStringExtra("logs")) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/setup/LoginActivityTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/setup/LoginActivityTest.kt new file mode 100644 index 0000000..25ff4f7 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/setup/LoginActivityTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Test + +class LoginActivityTest { + + @Test + fun loginInfoFromIntent() { + val intent = Intent().apply { + data = Uri.parse("https://example.com/nextcloud") + putExtra(LoginActivity.EXTRA_USERNAME, "user") + putExtra(LoginActivity.EXTRA_PASSWORD, "password") + } + val loginInfo = LoginActivity.loginInfoFromIntent(intent) + assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString()) + assertEquals("user", loginInfo.credentials!!.username) + assertEquals("password", loginInfo.credentials.password?.asString()) + } + + @Test + fun loginInfoFromIntent_withPort() { + val intent = Intent().apply { + data = Uri.parse("https://example.com:444/nextcloud") + putExtra(LoginActivity.EXTRA_USERNAME, "user") + putExtra(LoginActivity.EXTRA_PASSWORD, "password") + } + val loginInfo = LoginActivity.loginInfoFromIntent(intent) + assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString()) + assertEquals("user", loginInfo.credentials!!.username) + assertEquals("password", loginInfo.credentials.password?.asString()) + } + + @Test + fun loginInfoFromIntent_implicit() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com/path")) + val loginInfo = LoginActivity.loginInfoFromIntent(intent) + assertEquals("https://example.com/path", loginInfo.baseUri.toString()) + assertEquals("user", loginInfo.credentials!!.username) + assertEquals("password", loginInfo.credentials.password?.asString()) + } + + @Test + fun loginInfoFromIntent_implicit_withPort() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com:0/path")) + val loginInfo = LoginActivity.loginInfoFromIntent(intent) + assertEquals("https://example.com:0/path", loginInfo.baseUri.toString()) + assertEquals("user", loginInfo.credentials!!.username) + assertEquals("password", loginInfo.credentials.password?.asString()) + } + + @Test + fun loginInfoFromIntent_implicit_email() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:user@example.com")) + val loginInfo = LoginActivity.loginInfoFromIntent(intent) + assertEquals(null, loginInfo.baseUri) + assertEquals("user@example.com", loginInfo.credentials!!.username) + assertEquals(null, loginInfo.credentials.password?.asString()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt new file mode 100644 index 0000000..121e70d --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class CredentialsStoreTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var store: CredentialsStore + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun testSetGetDelete() { + store.setCredentials(0, Credentials(username = "myname", password = "12345".toSensitiveString())) + assertEquals(Credentials(username = "myname", password = "12345".toSensitiveString()), store.getCredentials(0)) + + store.setCredentials(0, null) + assertNull(store.getCredentials(0)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt new file mode 100644 index 0000000..ae79b95 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class WebDavMountRepositoryTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: WebDavMountRepository + + @Before + fun setUp() { + hiltRule.inject() + } + + val web = MockWebServer() + val url = web.url("/") + + @Test + fun testHasWebDav_NoDavHeader() = runTest { + web.enqueue(MockResponse().setResponseCode(200)) + assertNull(repository.hasWebDav(url, null)) + } + + @Test + fun testHasWebDav_DavClass1() = runTest { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV: 1")) + assertEquals(url, repository.hasWebDav(url, null)) + } + + @Test + fun testHasWebDav_DavClass2() = runTest { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV: 1, 2")) + assertEquals(url,repository.hasWebDav(url, null)) + } + + @Test + fun testHasWebDav_DavClass3() = runTest { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV: 1, 3")) + assertEquals(url,repository.hasWebDav(url, null)) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt new file mode 100644 index 0000000..e3f3204 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import android.security.NetworkSecurityPolicy +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavDocument +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.network.HttpClient +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidTest +class QueryChildDocumentsOperationTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val mockkRule = MockKRule(this) + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var db: AppDatabase + + @Inject + lateinit var operation: QueryChildDocumentsOperation + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var testDispatcher: TestDispatcher + + private lateinit var server: MockWebServer + private lateinit var client: HttpClient + + private lateinit var mount: WebDavMount + private lateinit var rootDocument: WebDavDocument + + @Before + fun setUp() { + hiltRule.inject() + + // create server and client + server = MockWebServer().apply { + dispatcher = testDispatcher + start() + } + + client = httpClientBuilder.build() + + // mock server delivers HTTP without encryption + assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) + + // create WebDAV mount and root document in DB + runBlocking { + val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT))) + mount = db.webDavMountDao().getById(mountId) + rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount) + } + } + + @After + fun tearDown() { + client.close() + server.shutdown() + + runBlocking { + db.webDavMountDao().deleteAsync(mount) + } + } + + + @Test + fun testDoQueryChildren_insert() = runTest { + // Query + operation.queryChildren(rootDocument) + + // Assert new children were inserted into db + assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size) + assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName) + assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName) + assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName) + } + + @Test + fun testDoQueryChildren_update() = runTest { + // Create parent and root in database + assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName) + + // Create a folder + val folderId = db.webDavDocumentDao().insert( + WebDavDocument( + 0, + mount.id, + rootDocument.id, + "My_Books", + true, + "My Books", + ) + ) + assertEquals("My_Books", db.webDavDocumentDao().get(folderId)!!.name) + assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName) + + // Query - should update the parent displayname and folder name + operation.queryChildren(rootDocument) + + // Assert parent and children were updated in database + assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName) + assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name) + assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName) + + } + + @Test + fun testDoQueryChildren_delete() = runTest { + // Create a folder + val folderId = db.webDavDocumentDao().insert( + WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted") + ) + assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name) + + // Query - discovers serverside deletion + operation.queryChildren(rootDocument) + + // Assert folder got deleted + assertEquals(null, db.webDavDocumentDao().get(folderId)) + } + + @Test + fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest { + // Create two directories + val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true)) + val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true)) + val parent1 = db.webDavDocumentDao().get(parent1Id)!! + val parent2 = db.webDavDocumentDao().get(parent2Id)!! + assertEquals("parent1", parent1.name) + assertEquals("parent2", parent2.name) + + // Query - find children of two nodes simultaneously + operation.queryChildren(parent1) + operation.queryChildren(parent2) + + // Assert the two folders names have changed + assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name) + assertEquals("childTwo.txt", db.webDavDocumentDao().getChildren(parent2Id)[0].name) + } + + + // mock server + + class TestDispatcher @Inject constructor( + private val logger: Logger + ): Dispatcher() { + + data class Resource( + val name: String, + val props: String + ) + + override fun dispatch(request: RecordedRequest): MockResponse { + logger.info("Request: $request") + val requestPath = request.path!!.trimEnd('/') + + if (request.method.equals("PROPFIND", true)) { + val propsMap = mutableMapOf( + PATH_WEBDAV_ROOT to arrayOf( + Resource("", + "" + + "Cats WebDAV" + ), + Resource("Secret_Document.pages", + "Secret_Document.pages", + ), + Resource("MeowMeow_Cats.docx", + "MeowMeow_Cats.docx" + ), + Resource("Library", + "" + + "Library" + ) + ), + + "$PATH_WEBDAV_ROOT/parent1" to arrayOf( + Resource("childOne.txt", + "childOne.txt" + ), + ), + "$PATH_WEBDAV_ROOT/parent2" to arrayOf( + Resource("childTwo.txt", + "childTwo.txt" + ) + ) + ) + + val responses = propsMap[requestPath]?.joinToString { resource -> + "$requestPath/${resource.name}" + + resource.props + + "" + } + + val multistatus = + "" + + responses + + "" + + logger.info("Response: $multistatus") + return MockResponse() + .setResponseCode(207) + .setBody(multistatus) + } + + return MockResponse().setResponseCode(404) + } + + } + + + companion object { + private const val PATH_WEBDAV_ROOT = "/webdav" + } + +} \ No newline at end of file diff --git a/app/src/androidTest/res/drawable-hdpi/ic_launcher.png b/app/src/androidTest/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..96a442e Binary files /dev/null and b/app/src/androidTest/res/drawable-hdpi/ic_launcher.png differ diff --git a/app/src/androidTest/res/drawable-ldpi/ic_launcher.png b/app/src/androidTest/res/drawable-ldpi/ic_launcher.png new file mode 100644 index 0000000..9923872 Binary files /dev/null and b/app/src/androidTest/res/drawable-ldpi/ic_launcher.png differ diff --git a/app/src/androidTest/res/drawable-mdpi/ic_launcher.png b/app/src/androidTest/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..359047d Binary files /dev/null and b/app/src/androidTest/res/drawable-mdpi/ic_launcher.png differ diff --git a/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png b/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..71c6d76 Binary files /dev/null and b/app/src/androidTest/res/drawable-xhdpi/ic_launcher.png differ diff --git a/app/src/androidTest/res/values/strings.xml b/app/src/androidTest/res/values/strings.xml new file mode 100644 index 0000000..95b8d49 --- /dev/null +++ b/app/src/androidTest/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Davx5Test + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..02ee4ca --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/gplv3.html b/app/src/main/assets/gplv3.html new file mode 100644 index 0000000..ad6baab --- /dev/null +++ b/app/src/main/assets/gplv3.html @@ -0,0 +1,628 @@ +

GNU GENERAL PUBLIC LICENSE

+

Version 3, 29 June 2007

+ +

Copyright © 2007 Free Software Foundation, Inc. +<http://fsf.org/>

+Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed.

+ +

Preamble

+ +

The GNU General Public License is a free, copyleft license for +software and other kinds of works.

+ +

The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too.

+ +

When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things.

+ +

To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others.

+ +

For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights.

+ +

Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it.

+ +

For the developers\' and authors\' protection, the GPL clearly explains +that there is no warranty for this free software. For both users\' and +authors\' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions.

+ +

Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users\' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users.

+ +

Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free.

+ +

The precise terms and conditions for copying, distribution and +modification follow.

+ +

TERMS AND CONDITIONS

+ +

0. Definitions.

+ +

“This License” refers to version 3 of the GNU General Public License.

+ +

“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks.

+ +

“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations.

+ +

To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work.

+ +

A “covered work” means either the unmodified Program or a work based +on the Program.

+ +

To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well.

+ +

To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying.

+ +

An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion.

+ +

1. Source Code.

+ +

The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work.

+ +

A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language.

+ +

The “System Libraries” of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it.

+ +

The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work\'s +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work.

+ +

The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source.

+ +

The Corresponding Source for a work in source code form is that +same work.

+ +

2. Basic Permissions.

+ +

All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law.

+ +

You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you.

+ +

Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary.

+ +

3. Protecting Users\' Legal Rights From Anti-Circumvention Law.

+ +

No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures.

+ +

When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work\'s +users, your or third parties\' legal rights to forbid circumvention of +technological measures.

+ +

4. Conveying Verbatim Copies.

+ +

You may convey verbatim copies of the Program\'s source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program.

+ +

You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee.

+ +

5. Conveying Modified Source Versions.

+ +

You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions:

+ +
    +
  • a) The work must carry prominent notices stating that you modified + it, and giving a relevant date.
  • + +
  • b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + “keep intact all notices”.
  • + +
  • c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it.
  • + +
  • d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so.
  • +
+ +

A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation\'s users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate.

+ +

6. Conveying Non-Source Forms.

+ +

You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways:

+ +
    +
  • a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange.
  • + +
  • b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge.
  • + +
  • c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b.
  • + +
  • d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements.
  • + +
  • e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d.
  • +
+ +

A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work.

+ +

A “User Product” is either (1) a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product.

+ +

“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made.

+ +

If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM).

+ +

The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network.

+ +

Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying.

+ +

7. Additional Terms.

+ +

“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions.

+ +

When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission.

+ +

Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms:

+ +
    +
  • a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or
  • + +
  • b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or
  • + +
  • c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or
  • + +
  • d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or
  • + +
  • e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or
  • + +
  • f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors.
  • +
+ +

All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying.

+ +

If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms.

+ +

Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way.

+ +

8. Termination.

+ +

You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11).

+ +

However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation.

+ +

Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice.

+ +

Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10.

+ +

9. Acceptance Not Required for Having Copies.

+ +

You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so.

+ +

10. Automatic Licensing of Downstream Recipients.

+ +

Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License.

+ +

An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party\'s predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts.

+ +

You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it.

+ +

11. Patents.

+ +

A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor\'s “contributor version”.

+ +

A contributor\'s “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License.

+ +

Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor\'s essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version.

+ +

In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party.

+ +

If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient\'s use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid.

+ +

If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it.

+ +

A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007.

+ +

Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law.

+ +

12. No Surrender of Others\' Freedom.

+ +

If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program.

+ +

13. Use with the GNU Affero General Public License.

+ +

Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such.

+ +

14. Revised Versions of this License.

+ +

The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns.

+ +

Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation.

+ +

If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy\'s +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program.

+ +

Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version.

+ +

15. Disclaimer of Warranty.

+ +

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

+ +

16. Limitation of Liability.

+ +

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES.

+ +

17. Interpretation of Sections 15 and 16.

+ +

If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee.

+ +

END OF TERMS AND CONDITIONS

diff --git a/app/src/main/assets/logging.properties b/app/src/main/assets/logging.properties new file mode 100644 index 0000000..c9e100f --- /dev/null +++ b/app/src/main/assets/logging.properties @@ -0,0 +1,4 @@ + +# reduce verbose of some otherwise annoying ical4j messages +net.fortuna.ical4j.data.level = INFO +net.fortuna.ical4j.model.Recur.level = INFO diff --git a/app/src/main/assets/translators.json b/app/src/main/assets/translators.json new file mode 100644 index 0000000..2feba3a --- /dev/null +++ b/app/src/main/assets/translators.json @@ -0,0 +1 @@ +{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Waldmeisda","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]} diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 0000000..4cc8140 Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/kotlin/at/bitfire/davdroid/App.kt b/app/src/main/kotlin/at/bitfire/davdroid/App.kt new file mode 100644 index 0000000..e96836c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/App.kt @@ -0,0 +1,83 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.log.LogManager +import at.bitfire.davdroid.startup.StartupPlugin +import at.bitfire.davdroid.sync.account.AccountsCleanupWorker +import at.bitfire.davdroid.ui.UiUtils +import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.logging.Logger +import javax.inject.Inject + +@HiltAndroidApp +class App: Application(), Configuration.Provider { + + @Inject + lateinit var logger: Logger + + /** + * Creates the [LogManager] singleton and thus initializes logging. + */ + @Inject + lateinit var logManager: LogManager + + @Inject + @DefaultDispatcher + lateinit var defaultDispatcher: CoroutineDispatcher + + @Inject + lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin> + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + + override fun onCreate() { + super.onCreate() + + logger.fine("Logging using LogManager $logManager") + + // set light/dark mode + UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates + // some current activity and causes an IllegalStateException in rare cases + + // run startup plugins (sync) + for (plugin in plugins.sortedBy { it.priority() }) { + logger.fine("Running startup plugin: $plugin (onAppCreate)") + plugin.onAppCreate() + } + + // don't block UI for some background checks + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(defaultDispatcher) { + // clean up orphaned accounts in DB from time to time + AccountsCleanupWorker.enable(this@App) + + // create/update app shortcuts + UiUtils.updateShortcuts(this@App) + + // run startup plugins (async) + for (plugin in plugins.sortedBy { it.priorityAsync() }) { + logger.fine("Running startup plugin: $plugin (onAppCreateAsync)") + plugin.onAppCreateAsync() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt new file mode 100644 index 0000000..9e302cd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt @@ -0,0 +1,23 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid + +import at.bitfire.synctools.icalendar.ical4jVersion +import ezvcard.Ezvcard +import net.fortuna.ical4j.model.property.ProdId + +/** + * Brand-specific constants like (non-theme) colors, homepage URLs etc. + */ +object Constants { + + const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt() + + + // product IDs for iCalendar/vCard + + val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion") + const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/TextTable.kt b/app/src/main/kotlin/at/bitfire/davdroid/TextTable.kt new file mode 100644 index 0000000..ed6e3c5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/TextTable.kt @@ -0,0 +1,86 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid + +import java.util.Collections + +class TextTable( + val headers: List +) { + + companion object { + + fun indent(str: String, pos: Int): String = + " ".repeat(pos) + + str.split('\n').joinToString("\n" + " ".repeat(pos)) + + } + + constructor(vararg headers: String): this(headers.toList()) + + + private val lines = mutableListOf>() + + fun addLine(values: List) { + if (values.size != headers.size) + throw IllegalArgumentException("Table line must have ${headers.size} column(s)") + lines += values.map { + it?.toString() ?: "—" + }.toTypedArray() + } + + fun addLine(vararg values: Any?) = addLine(values.toList()) + + override fun toString(): String { + val sb = StringBuilder() + + val headerWidths = headers.map { it.length } + val colWidths = Array(headers.size) { colIdx -> + Collections.max(listOf(headerWidths[colIdx]) + lines.map { it[colIdx] }.map { it.length }) + } + + // first line + sb.append("\n┌") + for (colIdx in headers.indices) + sb .append("─".repeat(colWidths[colIdx] + 2)) + .append(if (colIdx == headers.size - 1) '┐' else '┬') + sb.append('\n') + + // header + sb.append('│') + for (colIdx in headers.indices) + sb .append(' ') + .append(headers[colIdx].padEnd(colWidths[colIdx] + 1)) + .append('│') + sb.append('\n') + + // separator between header and body + sb.append('├') + for (colIdx in headers.indices) { + sb .append("─".repeat(colWidths[colIdx] + 2)) + .append(if (colIdx == headers.size - 1) '┤' else '┼') + } + sb.append('\n') + + // body + for (line in lines) { + for (colIdx in headers.indices) + sb .append("│ ") + .append(line[colIdx].padEnd(colWidths[colIdx] + 1)) + sb.append("│\n") + } + + // last line + sb.append("└") + for (colIdx in headers.indices) { + sb .append("─".repeat(colWidths[colIdx] + 2)) + .append(if (colIdx == headers.size - 1) '┘' else '┴') + } + sb.append("\n\n") + + return sb.toString() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt new file mode 100644 index 0000000..047150e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt @@ -0,0 +1,160 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.database.sqlite.SQLiteQueryBuilder +import androidx.core.app.NotificationCompat +import androidx.core.app.TaskStackBuilder +import androidx.core.database.getStringOrNull +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TextTable +import at.bitfire.davdroid.db.migration.AutoMigration12 +import at.bitfire.davdroid.db.migration.AutoMigration16 +import at.bitfire.davdroid.db.migration.AutoMigration18 +import at.bitfire.davdroid.ui.AccountsActivity +import at.bitfire.davdroid.ui.NotificationRegistry +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.Writer +import javax.inject.Singleton + +/** + * The app database. Managed via android jetpack room. Room provides an abstraction + * layer over SQLite. + * + * Note: In SQLite PRAGMA foreign_keys is off by default. Room activates it for + * production (non-test) databases. + */ +@Database(entities = [ + Service::class, + HomeSet::class, + Collection::class, + Principal::class, + SyncStats::class, + WebDavDocument::class, + WebDavMount::class +], exportSchema = true, version = 18, autoMigrations = [ + AutoMigration(from = 17, to = 18, spec = AutoMigration18::class), + AutoMigration(from = 16, to = 17), // collection: add VAPID key + AutoMigration(from = 15, to = 16, spec = AutoMigration16::class), + AutoMigration(from = 14, to = 15), + AutoMigration(from = 13, to = 14), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 11, to = 12, spec = AutoMigration12::class), + AutoMigration(from = 10, to = 11), + AutoMigration(from = 9, to = 10) +]) +@TypeConverters(Converters::class) +abstract class AppDatabase: RoomDatabase() { + + @Module + @InstallIn(SingletonComponent::class) + object AppDatabaseModule { + + @Provides + @Singleton + fun appDatabase( + autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>, + @ApplicationContext context: Context, + manualMigrations: Set<@JvmSuppressWildcards Migration>, + notificationRegistry: NotificationRegistry + ): AppDatabase = Room + .databaseBuilder(context, AppDatabase::class.java, "services.db") + .addMigrations(*manualMigrations.toTypedArray()) + .apply { + for (spec in autoMigrations) + addAutoMigrationSpec(spec) + } + .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing + .addCallback(object: Callback() { + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) { + val launcherIntent = Intent(context, AccountsActivity::class.java) + NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_warning_notify) + .setContentTitle(context.getString(R.string.database_destructive_migration_title)) + .setContentText(context.getString(R.string.database_destructive_migration_text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentIntent( + TaskStackBuilder.create(context) + .addNextIntent(launcherIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + .setAutoCancel(true) + .build() + } + + // remove all accounts because they're unfortunately useless without database + val am = AccountManager.get(context) + for (account in am.getAccountsByType(context.getString(R.string.account_type))) + am.removeAccountExplicitly(account) + } + }) + .build() + + } + + + // DAOs + + abstract fun serviceDao(): ServiceDao + abstract fun homeSetDao(): HomeSetDao + abstract fun collectionDao(): CollectionDao + abstract fun principalDao(): PrincipalDao + abstract fun syncStatsDao(): SyncStatsDao + abstract fun webDavDocumentDao(): WebDavDocumentDao + abstract fun webDavMountDao(): WebDavMountDao + + + // helpers + + fun dump(writer: Writer, ignoreTables: Array) { + val db = openHelper.readableDatabase + db.beginTransactionNonExclusive() + + // iterate through all tables + db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables -> + while (cursorTables.moveToNext()) { + val tableName = cursorTables.getString(0) + if (ignoreTables.contains(tableName)) { + writer.append("$tableName: ") + db.query("SELECT COUNT(*) FROM $tableName").use { cursor -> + if (cursor.moveToNext()) + writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n") + } + } else { + writer.append("$tableName\n") + db.query("SELECT * FROM $tableName").use { cursor -> + val table = TextTable(*cursor.columnNames) + val cols = cursor.columnCount + // print rows + while (cursor.moveToNext()) { + val values = Array(cols) { idx -> cursor.getStringOrNull(idx) } + table.addLine(*values) + } + writer.append(table.toString()) + } + } + } + db.endTransaction() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt new file mode 100644 index 0000000..ef6dedb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt @@ -0,0 +1,266 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.annotation.StringDef +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.dav4jvm.property.caldav.CalendarColor +import at.bitfire.dav4jvm.property.caldav.CalendarDescription +import at.bitfire.dav4jvm.property.caldav.CalendarTimezone +import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId +import at.bitfire.dav4jvm.property.caldav.Source +import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet +import at.bitfire.dav4jvm.property.carddav.AddressbookDescription +import at.bitfire.dav4jvm.property.push.PushTransports +import at.bitfire.dav4jvm.property.push.Topic +import at.bitfire.dav4jvm.property.push.WebPush +import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.davdroid.util.trimToNull +import at.bitfire.ical4android.util.DateUtils +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +@Retention(AnnotationRetention.SOURCE) +@StringDef( + Collection.TYPE_ADDRESSBOOK, + Collection.TYPE_CALENDAR, + Collection.TYPE_WEBCAL +) +annotation class CollectionType + +@Entity(tableName = "collection", + foreignKeys = [ + ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE), + ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL), + ForeignKey(entity = Principal::class, parentColumns = arrayOf("id"), childColumns = arrayOf("ownerId"), onDelete = ForeignKey.SET_NULL) + ], + indices = [ + Index("serviceId","type"), + Index("homeSetId","type"), + Index("ownerId","type"), + Index("pushTopic","type"), + Index("url") + ] +) +data class Collection( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** + * Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely + * identifiable via its [serviceId] and [url]. + */ + val serviceId: Long = 0, + + /** + * A home set this collection belongs to. Multiple homesets are not supported. + * If *null* the collection is considered homeless. + */ + val homeSetId: Long? = null, + + /** + * Principal who is owner of this collection. + */ + val ownerId: Long? = null, + + /** + * Type of service. CalDAV or CardDAV + */ + @CollectionType + val type: String, + + /** + * Address where this collection lives - with trailing slash + */ + val url: HttpUrl, + + /** + * Whether we have the permission to change contents of the collection on the server. + * Even if this flag is set, there may still be other reasons why a collection is effectively read-only. + */ + val privWriteContent: Boolean = true, + /** + * Whether we have the permission to delete the collection on the server + */ + val privUnbind: Boolean = true, + /** + * Whether the user has manually set the "force read-only" flag. + * Even if this flag is not set, there may still be other reasons why a collection is effectively read-only. + */ + val forceReadOnly: Boolean = false, + + /** + * Human-readable name of the collection + */ + val displayName: String? = null, + /** + * Human-readable description of the collection + */ + val description: String? = null, + + // CalDAV only + val color: Int? = null, + + /** default timezone (only timezone ID, like `Europe/Vienna`) */ + val timezoneId: String? = null, + + /** whether the collection supports VEVENT; in case of calendars: null means true */ + val supportsVEVENT: Boolean? = null, + + /** whether the collection supports VTODO; in case of calendars: null means true */ + val supportsVTODO: Boolean? = null, + + /** whether the collection supports VJOURNAL; in case of calendars: null means true */ + val supportsVJOURNAL: Boolean? = null, + + /** Webcal subscription source URL */ + val source: HttpUrl? = null, + + /** whether this collection has been selected for synchronization */ + val sync: Boolean = false, + + /** WebDAV-Push topic */ + val pushTopic: String? = null, + + /** WebDAV-Push: whether this collection supports the Web Push Transport */ + @ColumnInfo(defaultValue = "0") + val supportsWebPush: Boolean = false, + + /** WebDAV-Push: VAPID public key */ + val pushVapidKey: String? = null, + + /** WebDAV-Push subscription URL */ + val pushSubscription: String? = null, + + /** when the [pushSubscription] expires (timestamp, used to determine whether we need to re-subscribe) */ + val pushSubscriptionExpires: Long? = null, + + /** when the [pushSubscription] was created/updated (timestamp) */ + val pushSubscriptionCreated: Long? = null + +) { + + companion object { + + const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK" + const val TYPE_CALENDAR = "CALENDAR" + const val TYPE_WEBCAL = "WEBCAL" + + /** + * Generates a collection entity from a WebDAV response. + * @param dav WebDAV response + * @return null if the response doesn't represent a collection + */ + fun fromDavResponse(dav: Response): Collection? { + val url = UrlUtils.withTrailingSlash(dav.href) + val type: String = dav[ResourceType::class.java]?.let { resourceType -> + when { + resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK + resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR + resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL + else -> null + } + } ?: return null + + var privWriteContent = true + var privUnbind = true + dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet -> + privWriteContent = privilegeSet.mayWriteContent + privUnbind = privilegeSet.mayUnbind + } + + val displayName = dav[DisplayName::class.java]?.displayName.trimToNull() + + var description: String? = null + var color: Int? = null + var timezoneId: String? = null + var supportsVEVENT: Boolean? = null + var supportsVTODO: Boolean? = null + var supportsVJOURNAL: Boolean? = null + var source: HttpUrl? = null + when (type) { + TYPE_ADDRESSBOOK -> { + dav[AddressbookDescription::class.java]?.let { description = it.description } + } + TYPE_CALENDAR, TYPE_WEBCAL -> { + dav[CalendarDescription::class.java]?.let { description = it.description } + dav[CalendarColor::class.java]?.let { color = it.color } + dav[CalendarTimezoneId::class.java]?.let { timezoneId = it.identifier } + if (timezoneId == null) + dav[CalendarTimezone::class.java]?.vTimeZone?.let { + timezoneId = DateUtils.parseVTimeZone(it)?.timeZoneId?.value + } + + if (type == TYPE_CALENDAR) { + supportsVEVENT = true + supportsVTODO = true + supportsVJOURNAL = true + dav[SupportedCalendarComponentSet::class.java]?.let { + supportsVEVENT = it.supportsEvents + supportsVTODO = it.supportsTasks + supportsVJOURNAL = it.supportsJournal + } + } else { // Type.WEBCAL + dav[Source::class.java]?.let { + source = it.hrefs.firstOrNull()?.let { rawHref -> + val href = rawHref + .replace("^webcal://".toRegex(), "http://") + .replace("^webcals://".toRegex(), "https://") + href.toHttpUrlOrNull() + } + } + supportsVEVENT = true + } + } + } + + // WebDAV-Push + var supportsWebPush = false + var vapidPublicKey: String? = null + dav[PushTransports::class.java]?.let { pushTransports -> + for (transport in pushTransports.transports) + if (transport is WebPush) { + supportsWebPush = true + vapidPublicKey = transport.vapidPublicKey?.key + } + } + val pushTopic = dav[Topic::class.java]?.topic + + return Collection( + type = type, + url = url, + privWriteContent = privWriteContent, + privUnbind = privUnbind, + displayName = displayName, + description = description, + color = color, + timezoneId = timezoneId, + supportsVEVENT = supportsVEVENT, + supportsVTODO = supportsVTODO, + supportsVJOURNAL = supportsVJOURNAL, + source = source, + supportsWebPush = supportsWebPush, + pushVapidKey = vapidPublicKey, + pushTopic = pushTopic + ) + } + + } + + // calculated properties + + fun title() = displayName ?: url.lastSegment + fun readOnly() = forceReadOnly || !privWriteContent + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt new file mode 100644 index 0000000..9586872 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt @@ -0,0 +1,132 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface CollectionDao { + + @Query("SELECT * FROM collection WHERE id=:id") + fun get(id: Long): Collection? + + @Query("SELECT * FROM collection WHERE id=:id") + suspend fun getAsync(id: Long): Collection? + + @Query("SELECT * FROM collection WHERE id=:id") + fun getFlow(id: Long): Flow + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId") + suspend fun getByService(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId") + fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE") + fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List + + @Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync") + suspend fun getSyncableByPushTopic(topic: String): Collection? + + @Suppress("unused") // for build variant + @Query("SELECT * FROM collection WHERE sync") + fun getSyncCollections(): List + + @Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1") + suspend fun getFirstVapidKey(serviceId: Long): String? + + @Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type") + suspend fun anyOfType(serviceId: Long, @CollectionType type: String): Boolean + + @Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL") + suspend fun anyPushCapable(): Boolean + + /** + * Returns collections which + * - support VEVENT and/or VTODO (= supported calendar collections), or + * - have supportsVEVENT = supportsVTODO = null (= address books) + */ + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " + + "AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName COLLATE NOCASE, URL COLLATE NOCASE") + fun pageByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync") + fun getByServiceAndSync(serviceId: Long): List + + @Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName COLLATE NOCASE, collection.url COLLATE NOCASE") + fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url") + fun getByServiceAndUrl(serviceId: Long, url: String): Collection? + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE") + fun getSyncCalendars(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE") + fun getSyncJtxCollections(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE") + fun getSyncTaskLists(serviceId: Long): List + + /** + * Get a list of collections that are both sync enabled and push capable (supportsWebPush and + * pushTopic is available). + */ + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL") + suspend fun getPushCapableSyncCollections(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL") + suspend fun getPushRegistered(serviceId: Long): List + + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL AND NOT sync") + suspend fun getPushRegisteredAndNotSyncable(serviceId: Long): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(collection: Collection): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAsync(collection: Collection): Long + + @Update + fun update(collection: Collection) + + @Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id") + suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean) + + @Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id") + suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000) + + @Query("UPDATE collection SET sync=:sync WHERE id=:id") + suspend fun updateSync(id: Long, sync: Boolean) + + /** + * Tries to insert new row, but updates existing row if already present. + * This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)" + * which will create a new row with incremented ID and thus breaks entity relationships! + * + * @param collection Collection to be inserted or updated + * @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons. + */ + @Transaction + fun insertOrUpdateByUrl(collection: Collection): Long = getByServiceAndUrl( + collection.serviceId, + collection.url.toString() + )?.let { localCollection -> + update(collection.copy(id = localCollection.id)) + localCollection.id + } ?: insert(collection) + + @Delete + fun delete(collection: Collection) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Converters.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Converters.kt new file mode 100644 index 0000000..10240c4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Converters.kt @@ -0,0 +1,31 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.TypeConverter +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull + +class Converters { + + @TypeConverter + fun httpUrlToString(url: HttpUrl?) = + url?.toString() + + @TypeConverter + fun mediaTypeToString(mediaType: MediaType?) = + mediaType?.toString() + + @TypeConverter + fun stringToHttpUrl(url: String?): HttpUrl? = + url?.toHttpUrlOrNull() + + @TypeConverter + fun stringToMediaType(mimeType: String?): MediaType? = + mimeType?.toMediaTypeOrNull() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt new file mode 100644 index 0000000..9f32a33 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt @@ -0,0 +1,43 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import at.bitfire.davdroid.util.DavUtils.lastSegment +import okhttp3.HttpUrl + +@Entity(tableName = "homeset", + foreignKeys = [ + ForeignKey(entity = Service::class, parentColumns = ["id"], childColumns = ["serviceId"], onDelete = ForeignKey.CASCADE) + ], + indices = [ + // index by service; no duplicate URLs per service + Index("serviceId", "url", unique = true) + ] +) +data class HomeSet( + @PrimaryKey(autoGenerate = true) + val id: Long, + + val serviceId: Long, + + /** + * Whether this homeset belongs to the [Service.principal] given by [serviceId]. + */ + val personal: Boolean, + + val url: HttpUrl, + + val privBind: Boolean = true, + + val displayName: String? = null +) { + + fun title() = displayName ?: url.lastSegment + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt new file mode 100644 index 0000000..adfd60c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt @@ -0,0 +1,60 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface HomeSetDao { + + @Query("SELECT * FROM homeset WHERE id=:homesetId") + fun getById(homesetId: Long): HomeSet? + + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url") + fun getByUrl(serviceId: Long, url: String): HomeSet? + + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId") + fun getByService(serviceId: Long): List + + @Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE") + fun getBindableByAccountAndServiceTypeFlow(accountName: String, @ServiceType serviceType: String): Flow> + + @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") + fun getBindableByServiceFlow(serviceId: Long): Flow> + + @Insert + fun insert(homeSet: HomeSet): Long + + @Update + fun update(homeset: HomeSet) + + /** + * If a homeset with the given service ID and URL already exists, it is updated with the other fields. + * Otherwise, a new homeset is inserted. + * + * This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)" + * which will create a new row with incremented ID and thus breaks entity relationships! + * + * @param homeSet home set to insert/update + * + * @return ID of the row that has been inserted or updated. -1 If the insert fails due to other reasons. + */ + @Transaction + fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long = + getByUrl(homeSet.serviceId, homeSet.url.toString())?.let { existingHomeset -> + update(homeSet.copy(id = existingHomeset.id)) + existingHomeset.id + } ?: insert(homeSet) + + @Delete + fun delete(homeset: HomeSet) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt new file mode 100644 index 0000000..03e1574 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt @@ -0,0 +1,70 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.util.trimToNull +import okhttp3.HttpUrl + +/** + * A principal entity representing a WebDAV principal (rfc3744). + */ +@Entity(tableName = "principal", + foreignKeys = [ + ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE) + ], + indices = [ + // index by service, urls are unique + Index("serviceId", "url", unique = true) + ] +) +data class Principal( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val serviceId: Long, + /** URL of the principal, always without trailing slash */ + val url: HttpUrl, + val displayName: String? = null +) { + + companion object { + + /** + * Generates a principal entity from a WebDAV response. + * @param dav WebDAV response (make sure that you have queried `DAV:resource-type` and `DAV:display-name`) + * @return generated principal data object (with `id`=0), `null` if the response doesn't represent a principal + */ + fun fromDavResponse(serviceId: Long, dav: Response): Principal? { + // Check if response is a principal + val resourceType = dav[ResourceType::class.java] ?: return null + if (!resourceType.types.contains(ResourceType.PRINCIPAL)) + return null + + // Try getting the display name of the principal + val displayName: String? = dav[DisplayName::class.java]?.displayName.trimToNull() + + // Create and return principal - even without it's display name + return Principal( + serviceId = serviceId, + url = UrlUtils.omitTrailingSlash(dav.href), + displayName = displayName + ) + } + + fun fromServiceAndUrl(service: Service, url: HttpUrl) = Principal( + serviceId = service.id, + url = UrlUtils.omitTrailingSlash(url) + ) + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt new file mode 100644 index 0000000..8fc344d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt @@ -0,0 +1,67 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import okhttp3.HttpUrl + +@Dao +interface PrincipalDao { + + @Query("SELECT * FROM principal WHERE id=:id") + fun get(id: Long): Principal + + @Query("SELECT * FROM principal WHERE id=:id") + suspend fun getAsync(id: Long): Principal + + @Query("SELECT * FROM principal WHERE serviceId=:serviceId") + fun getByService(serviceId: Long): List + + @Query("SELECT * FROM principal WHERE serviceId=:serviceId AND url=:url") + fun getByUrl(serviceId: Long, url: HttpUrl): Principal? + + /** + * Gets all principals who do not own any collections + */ + @Query("SELECT * FROM principal WHERE principal.id NOT IN (SELECT ownerId FROM collection WHERE ownerId IS NOT NULL)") + fun getAllWithoutCollections(): List + + @Insert + fun insert(principal: Principal): Long + + @Update + fun update(principal: Principal) + + @Delete + fun delete(principal: Principal) + + /** + * Inserts, updates or just gets existing principal if its display name has not + * changed (will not update/overwrite with null values). + * + * @param principal Principal to be inserted or updated + * @return ID of the newly inserted or already existing principal + */ + fun insertOrUpdate(serviceId: Long, principal: Principal): Long { + // Try to get existing principal by URL + val oldPrincipal = getByUrl(serviceId, principal.url) + + // Insert new principal if not existing + if (oldPrincipal == null) + return insert(principal) + + // Otherwise update the existing principal + if (principal.displayName != oldPrincipal.displayName) + update(principal.copy(id = oldPrincipal.id)) + + // In any case return the id of the principal + return oldPrincipal.id + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Service.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Service.kt new file mode 100644 index 0000000..10bc798 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Service.kt @@ -0,0 +1,44 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.annotation.StringDef +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import okhttp3.HttpUrl + +@Retention(AnnotationRetention.SOURCE) +@StringDef(Service.TYPE_CALDAV, Service.TYPE_CARDDAV) +annotation class ServiceType + +/** + * A service entity. + * + * Services represent accounts and are unique. They are of type CardDAV or CalDAV and may have an associated principal. + */ +@Entity(tableName = "service", + indices = [ + // only one service per type and account + Index("accountName", "type", unique = true) + ]) +data class Service( + @PrimaryKey(autoGenerate = true) + val id: Long, + + val accountName: String, + + @ServiceType + val type: String, + + val principal: HttpUrl? = null +) { + + companion object { + const val TYPE_CALDAV = "caldav" + const val TYPE_CARDDAV = "carddav" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt new file mode 100644 index 0000000..c4938e7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt @@ -0,0 +1,49 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ServiceDao { + + @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") + suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service? + + @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") + fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow + + @Query("SELECT id FROM service WHERE accountName=:accountName") + suspend fun getIdsByAccountAsync(accountName: String): List + + @Query("SELECT * FROM service WHERE id=:id") + fun get(id: Long): Service? + + @Query("SELECT * FROM service WHERE id=:id") + suspend fun getAsync(id: Long): Service? + + @Query("SELECT * FROM service") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(service: Service): Long + + @Query("DELETE FROM service") + fun deleteAll() + + @Query("DELETE FROM service WHERE accountName=:accountName") + suspend fun deleteByAccount(accountName: String) + + @Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)") + fun deleteExceptAccounts(accountNames: Array) + + @Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName") + suspend fun renameAccount(oldName: String, newName: String) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStats.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStats.kt new file mode 100644 index 0000000..7dfd610 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStats.kt @@ -0,0 +1,28 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "syncstats", + foreignKeys = [ + ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE) + ], + indices = [ + Index(value = ["collectionId", "dataType"], unique = true) + ] +) +data class SyncStats( + @PrimaryKey(autoGenerate = true) + val id: Long, + + val collectionId: Long, + val dataType: String, + + val lastSync: Long +) \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt new file mode 100644 index 0000000..e7fdcfb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt @@ -0,0 +1,22 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface SyncStatsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrReplace(syncStats: SyncStats) + + @Query("SELECT * FROM syncstats WHERE collectionId=:id") + fun getByCollectionIdFlow(id: Long): Flow> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocument.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocument.kt new file mode 100644 index 0000000..167d2f2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocument.kt @@ -0,0 +1,140 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import android.annotation.SuppressLint +import android.os.Bundle +import android.provider.DocumentsContract.Document +import android.webkit.MimeTypeMap +import androidx.core.os.bundleOf +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM +import at.bitfire.davdroid.webdav.DocumentState +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import java.io.FileNotFoundException +import java.time.Instant + +@Entity( + tableName = "webdav_document", + foreignKeys = [ + ForeignKey(entity = WebDavMount::class, parentColumns = ["id"], childColumns = ["mountId"], onDelete = ForeignKey.CASCADE), + ForeignKey(entity = WebDavDocument::class, parentColumns = ["id"], childColumns = ["parentId"], onDelete = ForeignKey.CASCADE) + ], + indices = [ + Index("mountId", "parentId", "name", unique = true), + Index("parentId") + ] +) +// If any column name is modified, also change it in [DavDocumentsProvider$queryChildDocuments] +data class WebDavDocument( + + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** refers to the [WebDavMount] the document belongs to */ + val mountId: Long, + + /** refers to parent document (*null* when this document is a root document) */ + val parentId: Long?, + + /** file name (without any slashes) */ + val name: String, + val isDirectory: Boolean = false, + + val displayName: String? = null, + val mimeType: MediaType? = null, + val eTag: String? = null, + val lastModified: Long? = null, + val size: Long? = null, + + val mayBind: Boolean? = null, + val mayUnbind: Boolean? = null, + val mayWriteContent: Boolean? = null, + + val quotaAvailable: Long? = null, + val quotaUsed: Long? = null + +) { + + fun cacheKey(): CacheKey? { + if (eTag != null || lastModified != null) + return CacheKey(id, DocumentState(eTag, lastModified?.let { ts -> Instant.ofEpochMilli(ts) })) + return null + } + + @SuppressLint("InlinedApi") + fun toBundle(parent: WebDavDocument?): Bundle { + if (parent?.isDirectory == false) + throw IllegalArgumentException("Parent must be a directory") + + val bundle = bundleOf( + Document.COLUMN_DOCUMENT_ID to id.toString(), + Document.COLUMN_DISPLAY_NAME to name + ) + + displayName?.let { bundle.putString(Document.COLUMN_SUMMARY, it) } + size?.let { bundle.putLong(Document.COLUMN_SIZE, it) } + lastModified?.let { bundle.putLong(Document.COLUMN_LAST_MODIFIED, it) } + + // see RFC 3744 appendix B for required privileges for the various operations + var flags = Document.FLAG_SUPPORTS_COPY + if (isDirectory) { + bundle.putString(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + if (mayBind != false) + flags += Document.FLAG_DIR_SUPPORTS_CREATE + } else { + val reportedMimeType = mimeType ?: + MimeTypeMap.getSingleton().getMimeTypeFromExtension( + MimeTypeMap.getFileExtensionFromUrl(name) + )?.toMediaTypeOrNull() ?: + MEDIA_TYPE_OCTET_STREAM + + bundle.putString(Document.COLUMN_MIME_TYPE, reportedMimeType.toString()) + if (mimeType?.type == "image") + flags += Document.FLAG_SUPPORTS_THUMBNAIL + if (mayWriteContent != false) + flags += Document.FLAG_SUPPORTS_WRITE + } + if (parent?.mayUnbind != false) + flags += Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE or + Document.FLAG_SUPPORTS_RENAME + bundle.putInt(Document.COLUMN_FLAGS, flags) + + return bundle + } + + suspend fun toHttpUrl(db: AppDatabase): HttpUrl { + val mount = db.webDavMountDao().getById(mountId) + + val segments = mutableListOf(name) + var parentIter = parentId + while (parentIter != null) { + val parent = db.webDavDocumentDao().get(parentIter) ?: throw FileNotFoundException() + segments += parent.name + parentIter = parent.parentId + } + + val builder = mount.url.newBuilder() + for (segment in segments.reversed()) + builder.addPathSegment(segment) + return builder.build() + } + + + /** + * Represents a WebDAV document in a given state (with a given ETag/Last-Modified). + */ + data class CacheKey( + val docId: Long, + val documentState: DocumentState + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocumentDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocumentDao.kt new file mode 100644 index 0000000..0c0ac43 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocumentDao.kt @@ -0,0 +1,107 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.RoomRawQuery +import androidx.room.Transaction +import androidx.room.Update + +@Dao +interface WebDavDocumentDao { + + @Query("SELECT * FROM webdav_document WHERE id=:id") + fun get(id: Long): WebDavDocument? + + @Query("SELECT * FROM webdav_document WHERE mountId=:mountId AND (parentId=:parentId OR (parentId IS NULL AND :parentId IS NULL)) AND name=:name") + fun getByParentAndName(mountId: Long, parentId: Long?, name: String): WebDavDocument? + + @RawQuery + fun query(query: RoomRawQuery): List + + /** + * Gets all the child documents from a given parent id. + * + * @param parentId The id of the parent document to get the documents from. + * @param orderBy If desired, a SQL clause to specify how to order the results. + * **The caller is responsible for the correct formatting of this argument. Syntax won't be validated!** + */ + fun getChildren(parentId: Long, orderBy: String = DEFAULT_ORDER): List { + return query( + RoomRawQuery("SELECT * FROM webdav_document WHERE parentId = ? ORDER BY $orderBy") { + it.bindLong(1, parentId) + } + ) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(document: WebDavDocument): Long + + @Query("DELETE FROM webdav_document WHERE parentId=:parentId") + fun removeChildren(parentId: Long) + + @Insert + fun insert(document: WebDavDocument): Long + + @Update + fun update(document: WebDavDocument) + + @Delete + fun delete(document: WebDavDocument) + + + // complex operations + + /** + * Tries to insert new row, but updates existing row if already present. + * This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)" + * which will create a new row with incremented ID and thus breaks entity relationships! + * + * @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons. + */ + @Transaction + fun insertOrUpdate(document: WebDavDocument): Long { + val parentId = document.parentId + ?: return insert(document) + val existingDocument = getByParentAndName(document.mountId, parentId, document.name) + ?: return insert(document) + update(document.copy(id = existingDocument.id)) + return existingDocument.id + } + + @Transaction + fun getOrCreateRoot(mount: WebDavMount): WebDavDocument { + getByParentAndName(mount.id, null, "")?.let { existing -> + return existing + } + + val newDoc = WebDavDocument( + mountId = mount.id, + parentId = null, + name = "", + isDirectory = true, + displayName = mount.name + ) + val id = insertOrReplace(newDoc) + return newDoc.copy(id = id) + } + + + companion object { + + /** + * Default ORDER BY value to use when content provider doesn't specify a sort order: + * _sort by name (directories first)_ + */ + const val DEFAULT_ORDER = "isDirectory DESC, name ASC" + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMount.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMount.kt new file mode 100644 index 0000000..c05888b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMount.kt @@ -0,0 +1,24 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import okhttp3.HttpUrl + +@Entity(tableName = "webdav_mount") +data class WebDavMount( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** display name of the WebDAV mount */ + val name: String, + + /** URL of the WebDAV service, including trailing slash */ + val url: HttpUrl + + // credentials are stored using CredentialsStore + +) \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt new file mode 100644 index 0000000..8160608 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface WebDavMountDao { + + @Delete + suspend fun deleteAsync(mount: WebDavMount) + + @Query("SELECT * FROM webdav_mount ORDER BY name, url") + suspend fun getAll(): List + + @Query("SELECT * FROM webdav_mount ORDER BY name, url") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM webdav_mount WHERE id=:id") + suspend fun getById(id: Long): WebDavMount + + @Insert + suspend fun insert(mount: WebDavMount): Long + + + // complex queries + + /** + * Gets a list of mounts with the quotas of their root document, if available. + */ + @Query("SELECT webdav_mount.*, quotaAvailable, quotaUsed FROM webdav_mount " + + "LEFT JOIN webdav_document ON (webdav_mount.id=webdav_document.mountId AND webdav_document.parentId IS NULL) " + + "ORDER BY webdav_mount.name, webdav_mount.url") + fun getAllWithQuotaFlow(): Flow> + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt new file mode 100644 index 0000000..12de039 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt @@ -0,0 +1,18 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Embedded + +/** + * A [WebDavMount] with an optional root document (that contains information like quota). + */ +data class WebDavMountWithQuota( + @Embedded + val mount: WebDavMount, + + val quotaAvailable: Long? = null, + val quotaUsed: Long? = null +) \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration12.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration12.kt new file mode 100644 index 0000000..4a60e14 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration12.kt @@ -0,0 +1,47 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import android.content.Context +import androidx.room.DeleteColumn +import androidx.room.ProvidedAutoMigrationSpec +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import java.util.logging.Logger +import javax.inject.Inject + +@ProvidedAutoMigrationSpec +@DeleteColumn(tableName = "collection", columnName = "owner") +class AutoMigration12 @Inject constructor( + @ApplicationContext val context: Context, + val logger: Logger +): AutoMigrationSpec { + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + logger.info("Database update to v12, refreshing services to get display names of owners") + db.query("SELECT id FROM service", arrayOf()).use { cursor -> + while (cursor.moveToNext()) { + val serviceId = cursor.getLong(0) + RefreshCollectionsWorker.enqueue(context, serviceId) + } + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AutoMigrationModule { + @Binds @IntoSet + abstract fun provide(impl: AutoMigration12): AutoMigrationSpec + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration16.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration16.kt new file mode 100644 index 0000000..df8be31 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration16.kt @@ -0,0 +1,47 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.ProvidedAutoMigrationSpec +import androidx.room.RenameColumn +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.ical4android.util.DateUtils +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import javax.inject.Inject + +/** + * The timezone column has been renamed to timezoneId, but still contains the VTIMEZONE. + * So we need to parse the VTIMEZONE, extract the timezone ID and save it back. + */ +@ProvidedAutoMigrationSpec +@RenameColumn(tableName = "collection", fromColumnName = "timezone", toColumnName = "timezoneId") +class AutoMigration16 @Inject constructor(): AutoMigrationSpec { + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id, timezoneId FROM collection").use { cursor -> + while (cursor.moveToNext()) { + val id: Long = cursor.getLong(0) + val timezoneDef: String = cursor.getString(1) ?: continue + val vTimeZone = DateUtils.parseVTimeZone(timezoneDef) + val timezoneId = vTimeZone?.timeZoneId?.value + db.execSQL("UPDATE collection SET timezoneId=? WHERE id=?", arrayOf(timezoneId, id)) + } + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AutoMigrationModule { + @Binds @IntoSet + abstract fun provide(impl: AutoMigration16): AutoMigrationSpec + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration18.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration18.kt new file mode 100644 index 0000000..ac0764f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/AutoMigration18.kt @@ -0,0 +1,81 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.room.ProvidedAutoMigrationSpec +import androidx.room.RenameColumn +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.ical4android.TaskProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import javax.inject.Inject + +/** + * Renames syncstats.authority to dataType, and maps values to SyncDataType enum names. + */ +@ProvidedAutoMigrationSpec +@RenameColumn(tableName = "syncstats", fromColumnName = "authority", toColumnName = "dataType") +class AutoMigration18 @Inject constructor() : AutoMigrationSpec { + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + // Drop old unique index + db.execSQL("DROP INDEX IF EXISTS index_syncstats_collectionId_authority") + + val seen = mutableSetOf>() // (collectionId, dataType) + db.query( + "SELECT id, collectionId, dataType, lastSync FROM syncstats ORDER BY lastSync DESC" + ).use { cursor -> + val idIndex = cursor.getColumnIndex("id") + val collectionIdIndex = cursor.getColumnIndex("collectionId") + val authorityIndex = cursor.getColumnIndex("dataType") + + while (cursor.moveToNext()) { + val id = cursor.getLong(idIndex) + val collectionId = cursor.getLong(collectionIdIndex) + val authority = cursor.getString(authorityIndex) + + val dataType = when (authority) { + ContactsContract.AUTHORITY -> SyncDataType.CONTACTS.name + CalendarContract.AUTHORITY -> SyncDataType.EVENTS.name + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.TasksOrg.authority, + TaskProvider.ProviderName.OpenTasks.authority -> SyncDataType.TASKS.name + else -> { + db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id)) + continue + } + } + + val keyValue = collectionId to dataType + if (seen.contains(keyValue)) { + db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id)) + } else { + db.execSQL("UPDATE syncstats SET dataType = ? WHERE id = ?", arrayOf(dataType, id)) + seen.add(keyValue) + } + } + } + + // Create new unique index + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_syncstats_collectionId_dataType ON syncstats (collectionId, dataType)") + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AutoMigrationModule { + @Binds + @IntoSet + abstract fun provide(impl: AutoMigration18): AutoMigrationSpec + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration2.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration2.kt new file mode 100644 index 0000000..caae5c2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration2.kt @@ -0,0 +1,29 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration2 = Migration(1, 2) { db -> + db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL") + db.execSQL("UPDATE collections SET type=(" + + "SELECT CASE service WHEN ? THEN ? ELSE ? END " + + "FROM services WHERE _id=collections.serviceID" + + ")", + arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK")) +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration2Module { + @Provides @IntoSet + fun provide(): Migration = Migration2 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration3.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration3.kt new file mode 100644 index 0000000..4658ddb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration3.kt @@ -0,0 +1,26 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import java.util.logging.Logger + +val Migration3 = Migration(2, 3) { db -> + // We don't have access to the context in a Room migration now, so + // we will just drop those settings from old DAVx5 versions. + Logger.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*") +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration3Module { + @Provides @IntoSet + fun provide(): Migration = Migration3 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration4.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration4.kt new file mode 100644 index 0000000..b321596 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration4.kt @@ -0,0 +1,23 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration4 = Migration(3, 4) { db -> + db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL") +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration4Module { + @Provides @IntoSet + fun provide(): Migration = Migration4 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration5.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration5.kt new file mode 100644 index 0000000..d77581f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration5.kt @@ -0,0 +1,29 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration5 = Migration(4, 5) { db -> + db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly") + + db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privUnbind=NOT readOnly") + + // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration5Module { + @Provides @IntoSet + fun provide(): Migration = Migration5 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration6.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration6.kt new file mode 100644 index 0000000..91edacd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration6.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration6 = Migration(5, 6) { db -> + val sql = arrayOf( + // migrate "services" to "service": rename columns, make id NOT NULL + "CREATE TABLE service(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "accountName TEXT NOT NULL," + + "type TEXT NOT NULL," + + "principal TEXT DEFAULT NULL" + + ")", + "CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)", + "INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services", + "DROP TABLE services", + + // migrate "homesets" to "homeset": rename columns, make id NOT NULL + "CREATE TABLE homeset(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "url TEXT NOT NULL," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)", + "INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets", + "DROP TABLE homesets", + + // migrate "collections" to "collection": rename columns, make id NOT NULL + "CREATE TABLE collection(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "type TEXT NOT NULL," + + "url TEXT NOT NULL," + + "privWriteContent INTEGER NOT NULL DEFAULT 1," + + "privUnbind INTEGER NOT NULL DEFAULT 1," + + "forceReadOnly INTEGER NOT NULL DEFAULT 0," + + "displayName TEXT DEFAULT NULL," + + "description TEXT DEFAULT NULL," + + "color INTEGER DEFAULT NULL," + + "timezone TEXT DEFAULT NULL," + + "supportsVEVENT INTEGER DEFAULT NULL," + + "supportsVTODO INTEGER DEFAULT NULL," + + "supportsVJOURNAL INTEGER DEFAULT NULL," + + "source TEXT DEFAULT NULL," + + "sync INTEGER NOT NULL DEFAULT 0," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)", + "INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " + + "SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections", + "DROP TABLE collections" + ) + sql.forEach { db.execSQL(it) } +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration6Module { + @Provides @IntoSet + fun provide(): Migration = Migration6 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration7.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration7.kt new file mode 100644 index 0000000..7f83113 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration7.kt @@ -0,0 +1,24 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration7 = Migration(6, 7) { db -> + db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL") +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration7Module { + @Provides @IntoSet + fun provide(): Migration = Migration7 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration8.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration8.kt new file mode 100644 index 0000000..76af39b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration8.kt @@ -0,0 +1,26 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration8 = Migration(7, 8) { db -> + db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL") + db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL") + db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)") +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration8Module { + @Provides @IntoSet + fun provide(): Migration = Migration8 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration9.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration9.kt new file mode 100644 index 0000000..f066fc0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/migration/Migration9.kt @@ -0,0 +1,30 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db.migration + +import androidx.room.migration.Migration +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +val Migration9 = Migration(8, 9) { db -> + db.execSQL("CREATE TABLE syncstats (" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," + + "authority TEXT NOT NULL," + + "lastSync INTEGER NOT NULL)") + db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)") + + db.execSQL("CREATE INDEX index_collection_url ON collection(url)") +} + +@Module +@InstallIn(SingletonComponent::class) +internal object Migration9Module { + @Provides @IntoSet + fun provide(): Migration = Migration9 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/di/CoroutineDispatchersModule.kt b/app/src/main/kotlin/at/bitfire/davdroid/di/CoroutineDispatchersModule.kt new file mode 100644 index 0000000..fc33612 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/di/CoroutineDispatchersModule.kt @@ -0,0 +1,61 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier +import javax.inject.Singleton + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class MainDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class SyncDispatcher + +@Module +@InstallIn(SingletonComponent::class) +class CoroutineDispatchersModule { + + @Provides + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @MainDispatcher + fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + /** + * A dispatcher for background sync operations. They're not run on [ioDispatcher] because there can + * be many long-blocking operations at the same time which shouldn't never block other I/O operations + * like database access for the UI. + * + * It uses the I/O dispatcher and limits the number of parallel operations to the number of available processors. + */ + @Provides + @SyncDispatcher + @Singleton + fun syncDispatcher(): CoroutineDispatcher = + Dispatchers.IO.limitedParallelism(Runtime.getRuntime().availableProcessors()) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/di/CoroutineScopesModule.kt b/app/src/main/kotlin/at/bitfire/davdroid/di/CoroutineScopesModule.kt new file mode 100644 index 0000000..40c5c23 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/di/CoroutineScopesModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +class CoroutineScopesModule { + + @Singleton + @Provides + @ApplicationScope + fun applicationScope(@MainDispatcher mainDispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope(SupervisorJob() + mainDispatcher) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/di/LoggerModule.kt b/app/src/main/kotlin/at/bitfire/davdroid/di/LoggerModule.kt new file mode 100644 index 0000000..6af6d6f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/di/LoggerModule.kt @@ -0,0 +1,20 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.logging.Logger + +@Module +@InstallIn(SingletonComponent::class) +class LoggerModule { + + @Provides + fun globalLogger(): Logger = Logger.getGlobal() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt new file mode 100644 index 0000000..4206d00 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt @@ -0,0 +1,177 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.log + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Process +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.TaskStackBuilder +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.LogFileHandler.Companion.debugDir +import at.bitfire.davdroid.ui.AppSettingsActivity +import at.bitfire.davdroid.ui.DebugInfoActivity +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.synctools.log.PlainTextFormatter +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.Closeable +import java.io.File +import java.util.Date +import java.util.logging.FileHandler +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Logging handler that logs to a debug log file. + * + * Shows a permanent notification as long as it's active (until [close] is called). + * + * Only one [LogFileHandler] should be active at once, because the notification is shared. + */ +class LogFileHandler @Inject constructor( + @ApplicationContext val context: Context, + private val logger: Logger, + private val notificationRegistry: NotificationRegistry +): Handler(), Closeable { + + companion object { + + private const val DEBUG_INFO_DIRECTORY = "debug" + + /** + * Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored. + * Must match the contents of `res/xml/debug.paths.xml`. + * + * @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully. + */ + fun debugDir(context: Context): File? { + val dir = File(context.filesDir, DEBUG_INFO_DIRECTORY) + if (dir.exists() && dir.isDirectory) + return dir + + if (dir.mkdir()) + return dir + + return null + } + + /** + * The file (in [debugDir]) where verbose logs are stored. + * + * @return The file where verbose logs are stored, or `null` if there's no [debugDir]. + */ + fun getDebugLogFile(context: Context): File? { + val logDir = debugDir(context) ?: return null + return File(logDir, "davx5-log.txt") + } + + } + + private var fileHandler: FileHandler? = null + private val notificationManager = NotificationManagerCompat.from(context) + + private val logFile = getDebugLogFile(context) + + init { + if (logFile != null) { + if (logFile.createNewFile()) + logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n") + + // actual logging is handled by a FileHandler + fileHandler = FileHandler(logFile.toString(), true).apply { + formatter = PlainTextFormatter.DEFAULT + } + + showNotification() + } else { + logger.severe("Couldn't create log file in app-private directory $DEBUG_INFO_DIRECTORY/.") + level = Level.OFF + } + } + + + @Synchronized + override fun publish(record: LogRecord) { + fileHandler?.publish(record) + } + + @Synchronized + override fun flush() { + fileHandler?.flush() + } + + @Synchronized + override fun close() { + fileHandler?.close() + fileHandler = null + + // remove all files in debug info directory, may also contain zip files from debug info activity etc. + logFile?.parentFile?.deleteRecursively() + + removeNotification() + } + + + // notifications + + private fun showNotification() { + notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_VERBOSE_LOGGING) { + val builder = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_DEBUG) + builder.setSmallIcon(R.drawable.ic_sd_card_notify) + .setContentTitle(context.getString(R.string.app_settings_logging)) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentText( + context.getString( + R.string.logging_notification_text, context.getString( + R.string.app_name + ) + ) + ) + .setOngoing(true) + + // add action to view/share the logs + val shareIntent = DebugInfoActivity.IntentBuilder(context) + .newTask() + .share() + val pendingShare = TaskStackBuilder.create(context) + .addNextIntentWithParentStack(shareIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + builder.addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_share, + context.getString(R.string.logging_notification_view_share), + pendingShare + ).build() + ) + + // add action to disable verbose logging + val prefIntent = Intent(context, AppSettingsActivity::class.java) + prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val pendingPref = TaskStackBuilder.create(context) + .addNextIntentWithParentStack(prefIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + builder.addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_settings, + context.getString(R.string.logging_notification_disable), + pendingPref + ).build() + ) + + builder.build() + } + } + + private fun removeNotification() { + notificationManager.cancel(NotificationRegistry.NOTIFY_VERBOSE_LOGGING) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt new file mode 100644 index 0000000..3fb285b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt @@ -0,0 +1,90 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.log + +import android.content.Context +import android.util.Log +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.repository.PreferenceRepository +import at.bitfire.synctools.log.LogcatHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Handles logging configuration and which loggers are active at a moment. + * To initialize, just make sure that the [LogManager] singleton is created. + * + * Configures the root logger like this: + * + * - Always logs to logcat. + * - Watches the "log to file" preference and activates or deactivates file logging accordingly. + * - If "log to file" is enabled, log level is set to [Level.ALL]. + * - Otherwise, log level is set to [Level.INFO]. + * + * Preferred ways to get a [Logger] are: + * + * - `@Inject` [Logger] for a general-purpose logger when injection is possible + * - `Logger.getGlobal()` for a general-purpose logger + * - `Logger.getLogger(javaClass.name)` for a specific logger that can be customized + * + * When using the global logger, the class name of the logging calls will still be logged, so there's + * no need to always get a separate logger for each class (only if the class wants to customize it). + */ +@Singleton +class LogManager @Inject constructor( + @ApplicationContext private val context: Context, + private val logFileHandler: Provider, + private val logger: Logger, + private val prefs: PreferenceRepository +) : AutoCloseable { + + private val scope = CoroutineScope(Dispatchers.Default) + + init { + // observe preference changes + scope.launch { + prefs.logToFileFlow().collect { + reloadConfig() + } + } + + reloadConfig() + } + + override fun close() { + scope.cancel() + } + + @Synchronized + fun reloadConfig() { + val logToFile = prefs.logToFile() + val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(logger.name, Log.DEBUG) + logger.info("Verbose logging = $logVerbose; log to file = $logToFile") + + // reset existing loggers and initialize from assets/logging.properties + context.assets.open("logging.properties").use { + val javaLogManager = java.util.logging.LogManager.getLogManager() + javaLogManager.readConfiguration(it) + } + + // root logger: set default log level and always log to logcat + val rootLogger = Logger.getLogger("") + rootLogger.level = if (logVerbose) Level.ALL else Level.INFO + rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID)) + + // log to file, if requested + if (logToFile) + rootLogger.addHandler(logFileHandler.get()) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/log/StringHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/log/StringHandler.kt new file mode 100644 index 0000000..1569cf1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/log/StringHandler.kt @@ -0,0 +1,57 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.log + +import at.bitfire.synctools.log.PlainTextFormatter +import com.google.common.base.Ascii +import java.util.logging.Handler +import java.util.logging.LogRecord + +/** + * Handler that writes log messages to a string buffer. + * + * @param maxSize Maximum size of the buffer. If the buffer exceeds this size, it will be truncated. + */ +class StringHandler( + private val maxSize: Int +): Handler() { + + companion object { + const val TRUNCATION_MARKER = "[...]" + } + + val builder = StringBuilder() + + init { + formatter = PlainTextFormatter.DEFAULT + } + + override fun publish(record: LogRecord) { + var text = formatter.format(record) + + val currentSize = builder.length + val sizeLeft = maxSize - currentSize + + when { + // Append the text if there is enough space + sizeLeft > text.length -> + builder.append(text) + + // Truncate the text if there is not enough space + sizeLeft > TRUNCATION_MARKER.length -> { + text = Ascii.truncate(text, maxSize - currentSize, TRUNCATION_MARKER) + builder.append(text) + } + + // Do nothing if the buffer is already full + } + } + + override fun flush() {} + override fun close() {} + + override fun toString() = builder.toString() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt new file mode 100644 index 0000000..80b90cc --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt @@ -0,0 +1,73 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.net.DnsResolver +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.runBlocking +import org.xbill.DNS.EDNSOption +import org.xbill.DNS.Message +import org.xbill.DNS.Resolver +import org.xbill.DNS.TSIG +import java.io.IOException +import java.time.Duration + +/** + * dnsjava [Resolver] that uses Android's [DnsResolver] API, which can resolve raw queries and + * is available since Android 10. + */ +@RequiresApi(Build.VERSION_CODES.Q) +class Android10Resolver : Resolver { + + private val executor = Dispatchers.IO.asExecutor() + private val resolver = DnsResolver.getInstance() + + override fun send(query: Message): Message = runBlocking { + val future = CompletableDeferred() + + resolver.rawQuery(null, query.toWire(), DnsResolver.FLAG_EMPTY, executor, null, object: DnsResolver.Callback { + override fun onAnswer(rawAnswer: ByteArray, rcode: Int) { + future.complete(Message((rawAnswer))) + } + + override fun onError(error: DnsResolver.DnsException) { + // wrap into IOException as expected by dnsjava + future.completeExceptionally(IOException(error)) + } + }) + + future.await() + } + + + override fun setPort(port: Int) { + // not applicable + } + + override fun setTCP(flag: Boolean) { + // not applicable + } + + override fun setIgnoreTruncation(flag: Boolean) { + // not applicable + } + + override fun setEDNS(version: Int, payloadSize: Int, flags: Int, options: MutableList?) { + // not applicable + } + + override fun setTSIGKey(key: TSIG?) { + // not applicable + } + + override fun setTimeout(timeout: Duration?) { + // not applicable + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt new file mode 100644 index 0000000..0859849 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt @@ -0,0 +1,47 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.content.Context +import android.security.KeyChain +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import java.net.Socket +import java.security.Principal +import javax.net.ssl.X509ExtendedKeyManager + +/** + * KeyManager that provides a client certificate and private key from the Android KeyChain. + * + * @throws IllegalArgumentException if the alias doesn't exist or is not accessible + */ +class ClientCertKeyManager @AssistedInject constructor( + @Assisted private val alias: String, + @ApplicationContext private val context: Context +): X509ExtendedKeyManager() { + + @AssistedFactory + interface Factory { + fun create(alias: String): ClientCertKeyManager + } + + val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias") + val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias") + + override fun getServerAliases(p0: String?, p1: Array?): Array? = null + override fun chooseServerAlias(p0: String?, p1: Array?, p2: Socket?) = null + + override fun getClientAliases(p0: String?, p1: Array?) = arrayOf(alias) + override fun chooseClientAlias(p0: Array?, p1: Array?, p2: Socket?) = alias + + override fun getCertificateChain(forAlias: String?) = + certs.takeIf { forAlias == alias } + + override fun getPrivateKey(forAlias: String?) = + key.takeIf { forAlias == alias } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/DnsRecordResolver.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/DnsRecordResolver.kt new file mode 100644 index 0000000..ca20f5c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/DnsRecordResolver.kt @@ -0,0 +1,166 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Build +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import org.xbill.DNS.ExtendedResolver +import org.xbill.DNS.Lookup +import org.xbill.DNS.Record +import org.xbill.DNS.Resolver +import org.xbill.DNS.ResolverConfig +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.SimpleResolver +import org.xbill.DNS.TXTRecord +import java.net.InetAddress +import java.util.LinkedList +import java.util.TreeMap +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.random.Random + +/** + * Allows to resolve SRV/TXT records. Chooses the correct resolver, DNS servers etc. + */ +class DnsRecordResolver @Inject constructor( + @ApplicationContext val context: Context, + private val logger: Logger +) { + + // resolving + + /** + * Fallback DNS server that will be used when other DNS are not known or working. + * `9.9.9.9` belongs to Cloudflare who promise good privacy. + */ + private val DNS_FALLBACK = InetAddress.getByAddress(byteArrayOf(9,9,9,9)) + + private val resolver by lazy { chooseResolver() } + + init { + // empty initialization for dnsjava because we set the servers for each request + ResolverConfig.setConfigProviders(listOf()) + } + + /** + * Creates a matching Resolver, depending on the Android version: + * + * Android 10+: Android10Resolver, which uses the raw DNS resolver that comes with Android + * Android <10: ExtendedResolver, which uses the known DNS servers to resolve DNS queries + */ + private fun chooseResolver(): Resolver = + if (Build.VERSION.SDK_INT >= 29) { + /* Since Android 10, there's a native DnsResolver API that allows to send SRV queries without + knowing which DNS servers have to be used. DNS over TLS is now also supported. */ + logger.fine("Using Android 10+ DnsResolver") + Android10Resolver() + + } else { + /* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore. + The current version of dnsjava relies on these properties to find the default name servers, + so we have to add the servers explicitly (fortunately, there's an Android API to + get the DNS servers of the network connections). */ + val dnsServers = LinkedList() + + val connectivity = context.getSystemService()!! + @Suppress("DEPRECATION") + connectivity.allNetworks.forEach { network -> + val active = connectivity.getNetworkInfo(network)?.isConnected == true + connectivity.getLinkProperties(network)?.let { link -> + if (active) + // active connection, insert at top of list + dnsServers.addAll(0, link.dnsServers) + else + // inactive connection, insert at end of list + dnsServers.addAll(link.dnsServers) + } + } + + // fallback: add Quad9 DNS in case that no other DNS works + dnsServers.add(DNS_FALLBACK) + + val uniqueDnsServers = LinkedHashSet(dnsServers) + val simpleResolvers = uniqueDnsServers.map { dns -> + logger.fine("Adding DNS server ${dns.hostAddress}") + SimpleResolver(dns) + } + + // combine SimpleResolvers which query one DNS server each to an ExtendedResolver + ExtendedResolver(simpleResolvers.toTypedArray()) + } + + fun resolve(query: String, type: Int): Array { + val lookup = Lookup(query, type) + lookup.setResolver(resolver) + return lookup.run().orEmpty() + } + + + // record selection + + /** + * Selects the best SRV record from a list of records, based on algorithm from RFC 2782. + * + * @param records the records to choose from + * @param randomGenerator a random number generator to use for random selection + * @return the best SRV record, or `null` if no SRV record is available + */ + fun bestSRVRecord(records: Array, randomGenerator: Random = Random.Default): SRVRecord? { + val srvRecords = records.filterIsInstance() + if (srvRecords.size <= 1) + return srvRecords.firstOrNull() + + /* RFC 2782 + Priority + The priority of this target host. A client MUST attempt to + contact the target host with the lowest-numbered priority it can + reach; target hosts with the same priority SHOULD be tried in an + order defined by the weight field. [...] + Weight + A server selection mechanism. The weight field specifies a + relative weight for entries with the same priority. [...] + To select a target to be contacted next, arrange all SRV RRs + (that have not been ordered yet) in any order, except that all + those with weight 0 are placed at the beginning of the list. + Compute the sum of the weights of those RRs, and with each RR + associate the running sum in the selected order. Then choose a + uniform random number between 0 and the sum computed + (inclusive), and select the RR whose running sum value is the + first in the selected order which is greater than or equal to + the random number selected. The target host specified in the + selected SRV RR is the next one to be contacted by the client. + */ + + // Select records which have the minimum priority + val minPriority = srvRecords.minOfOrNull { it.priority } + val usableRecords = srvRecords.filter { it.priority == minPriority } + .sortedBy { it.weight != 0 } // and put those with weight 0 first + + val map = TreeMap() + var runningWeight = 0 + for (record in usableRecords) { + val weight = record.weight + runningWeight += weight + map[runningWeight] = record + } + + val selector = (0..runningWeight).random(randomGenerator) + return map.ceilingEntry(selector)!!.value + } + + fun pathsFromTXTRecords(records: Array): List { + val paths = LinkedList() + records.filterIsInstance().forEach { txt -> + for (segment in txt.strings as List) + if (segment.startsWith("path=")) + paths.add(segment.substring(5)) + } + return paths + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt new file mode 100644 index 0000000..a1f7337 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -0,0 +1,315 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.accounts.Account +import android.content.Context +import androidx.annotation.WorkerThread +import at.bitfire.cert4android.CustomCertManager +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.ForegroundTracker +import com.google.common.net.HttpHeaders +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthState +import okhttp3.Authenticator +import okhttp3.Cache +import okhttp3.ConnectionSpec +import okhttp3.CookieJar +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.brotli.BrotliInterceptor +import okhttp3.internal.tls.OkHostnameVerifier +import okhttp3.logging.HttpLoggingInterceptor +import java.io.File +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext + +class HttpClient( + val okHttpClient: OkHttpClient +): AutoCloseable { + + override fun close() { + okHttpClient.cache?.close() + } + + + // builder + + /** + * Builder for the [HttpClient]. + * + * **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then + * there's only one [Builder] object and setting properties from one location would influence the others. + * + * To generate multiple clients, inject and use `Provider` instead. + */ + class Builder @Inject constructor( + private val accountSettingsFactory: AccountSettings.Factory, + @ApplicationContext private val context: Context, + defaultLogger: Logger, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val keyManagerFactory: ClientCertKeyManager.Factory, + private val oAuthInterceptorFactory: OAuthInterceptor.Factory, + private val settingsManager: SettingsManager + ) { + + // property setters/getters + + private var logger: Logger = defaultLogger + fun setLogger(logger: Logger): Builder { + this.logger = logger + return this + } + + private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY + fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder { + loggerInterceptorLevel = level + return this + } + + // default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking) + private var cookieStore: CookieJar = MemoryCookieStore() + fun setCookieStore(cookieStore: CookieJar): Builder { + this.cookieStore = cookieStore + return this + } + + private var authenticationInterceptor: Interceptor? = null + private var authenticator: Authenticator? = null + private var certificateAlias: String? = null + fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): Builder { + val credentials = getCredentials() + if (credentials.authState != null) { + // OAuth + authenticationInterceptor = oAuthInterceptorFactory.create( + readAuthState = { + // We don't use the "credentials" object from above because it may contain an outdated access token + // when readAuthState is called. Instead, we fetch the up-to-date auth-state. + getCredentials().authState + }, + writeAuthState = { authState -> + updateAuthState?.invoke(authState) + } + + ) + + } else if (credentials.username != null && credentials.password != null) { + // basic/digest auth + val authHandler = BasicDigestAuthHandler( + domain = UrlUtils.hostToDomain(host), + username = credentials.username, + password = credentials.password.asCharArray(), + insecurePreemptive = true + ) + authenticationInterceptor = authHandler + authenticator = authHandler + } + + // client certificate + if (credentials.certificateAlias != null) + certificateAlias = credentials.certificateAlias + + return this + } + + private var followRedirects = false + fun followRedirects(follow: Boolean): Builder { + followRedirects = follow + return this + } + + private var cache: Cache? = null + @Suppress("unused") + fun withDiskCache(maxSize: Long = 10*1024*1024): Builder { + for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) { + if (dir.exists() && dir.canWrite()) { + val cacheDir = File(dir, "HttpClient") + cacheDir.mkdir() + logger.fine("Using disk cache: $cacheDir") + cache = Cache(cacheDir, maxSize) + break + } + } + return this + } + + + // convenience builders from other classes + + /** + * Takes authentication (basic/digest or OAuth and client certificate) from a given account. + * + * **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible. + * + * @param account the account to take authentication from + * @param onlyHost if set: only authenticate for this host name + * + * @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist + */ + @WorkerThread + fun fromAccount(account: Account, onlyHost: String? = null): Builder { + val accountSettings = accountSettingsFactory.create(account) + authenticate( + host = onlyHost, + getCredentials = { + accountSettings.credentials() + }, + updateAuthState = { authState -> + accountSettings.updateAuthState(authState) + } + ) + return this + } + + /** + * Same as [fromAccount], but can be called on any thread. + * + * @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist + */ + suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) { + fromAccount(account, onlyHost) + } + + + // actual builder + + fun build(): HttpClient { + val okBuilder = OkHttpClient.Builder() + // Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network + // traffic within a minute, a sync will be cancelled. + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2 + + // don't allow redirects by default because it would break PROPFIND handling + .followRedirects(followRedirects) + + // add User-Agent to every request + .addInterceptor(UserAgentInterceptor) + + // connection-private cookie store + .cookieJar(cookieStore) + + // allow cleartext and TLS 1.2+ + .connectionSpecs(listOf( + ConnectionSpec.CLEARTEXT, + ConnectionSpec.MODERN_TLS + )) + + // offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`) + .addInterceptor(BrotliInterceptor) + + // add cache, if requested + .cache(cache) + + // app-wide custom proxy support + buildProxy(okBuilder) + + // add authentication + buildAuthentication(okBuilder) + + // add network logging, if requested + if (logger.isLoggable(Level.FINEST)) { + val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) } + loggingInterceptor.redactHeader(HttpHeaders.AUTHORIZATION) + loggingInterceptor.redactHeader(HttpHeaders.COOKIE) + loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE) + loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2) + loggingInterceptor.level = loggerInterceptorLevel + okBuilder.addNetworkInterceptor(loggingInterceptor) + } + + return HttpClient(okBuilder.build()) + } + + private fun buildAuthentication(okBuilder: OkHttpClient.Builder) { + // basic/digest auth and OAuth + authenticationInterceptor?.let { okBuilder.addInterceptor(it) } + authenticator?.let { okBuilder.authenticator(it) } + + // client certificate + val keyManager: KeyManager? = certificateAlias?.let { alias -> + try { + val manager = keyManagerFactory.create(alias) + logger.fine("Using certificate $alias for authentication") + + // HTTP/2 doesn't support client certificates (yet) + // see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04 + okBuilder.protocols(listOf(Protocol.HTTP_1_1)) + + manager + } catch (e: IllegalArgumentException) { + logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e) + null + } + } + + // cert4android integration + val certManager = CustomCertManager( + context = context, + trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES), + appInForeground = if (BuildConfig.customCertsUI) + ForegroundTracker.inForeground // interactive mode + else + null // non-interactive mode + ) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init( + /* km = */ if (keyManager != null) arrayOf(keyManager) else null, + /* tm = */ arrayOf(certManager), + /* random = */ null + ) + okBuilder + .sslSocketFactory(sslContext.socketFactory, certManager) + .hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier)) + } + + private fun buildProxy(okBuilder: OkHttpClient.Builder) { + try { + val proxyTypeValue = settingsManager.getInt(Settings.PROXY_TYPE) + if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) { + // we set our own proxy + val address by lazy { // lazy because not required for PROXY_TYPE_NONE + InetSocketAddress( + settingsManager.getString(Settings.PROXY_HOST), + settingsManager.getInt(Settings.PROXY_PORT) + ) + } + val proxy = + when (proxyTypeValue) { + Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY + Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address) + Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address) + else -> throw IllegalArgumentException("Invalid proxy type") + } + okBuilder.proxy(proxy) + logger.log(Level.INFO, "Using proxy setting", proxy) + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Can't set proxy, ignoring", e) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt new file mode 100644 index 0000000..14f9aa6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/MemoryCookieStore.kt @@ -0,0 +1,81 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import androidx.annotation.VisibleForTesting +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import java.util.LinkedList + +/** + * Primitive cookie store that stores cookies in a (volatile) hash map. + * Will be sufficient for session cookies. + */ +class MemoryCookieStore : CookieJar { + + data class StorageKey( + val domain: String, + val path: String, + val name: String + ) + + private val storage = mutableMapOf() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + /* [RFC 6265 5.3 Storage Model] + + 11. If the cookie store contains a cookie with the same name, + domain, and path as the newly created cookie: + + 1. Let old-cookie be the existing cookie with the same name, + domain, and path as the newly created cookie. (Notice that + this algorithm maintains the invariant that there is at most + one such cookie.) + + 2. If the newly created cookie was received from a "non-HTTP" + API and the old-cookie's http-only-flag is set, abort these + steps and ignore the newly created cookie entirely. + + 3. Update the creation-time of the newly created cookie to + match the creation-time of the old-cookie. + + 4. Remove the old-cookie from the cookie store. + */ + synchronized(storage) { + storage.putAll(cookies.map { + StorageKey( + domain = it.domain, + path = it.path, + name = it.name + ) to it + }) + } + } + + override fun loadForRequest(url: HttpUrl): List { + val cookies = LinkedList() + + synchronized(storage) { + val iter = storage.iterator() + while (iter.hasNext()) { + val (_, cookie) = iter.next() + + // remove expired cookies + if (cookie.expiresAt <= System.currentTimeMillis()) { + iter.remove() + continue + } + + // add applicable cookies to result + if (cookie.matches(url)) + cookies += cookie + } + } + + return cookies + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt new file mode 100644 index 0000000..9ed2836 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt @@ -0,0 +1,139 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.ui.setup.LoginInfo +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import at.bitfire.davdroid.util.withTrailingSlash +import at.bitfire.vcard4android.GroupMethod +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URI +import javax.inject.Inject + +/** + * Implements Nextcloud Login Flow v2. + * + * See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 + */ +class NextcloudLoginFlow @Inject constructor( + httpClientBuilder: HttpClient.Builder +): AutoCloseable { + + companion object { + const val FLOW_V1_PATH = "index.php/login/flow" + const val FLOW_V2_PATH = "index.php/login/v2" + + /** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */ + const val DAV_PATH = "remote.php/dav" + } + + val httpClient = httpClientBuilder + .build() + + override fun close() { + httpClient.close() + } + + + // Login flow state + var loginUrl: HttpUrl? = null + var pollUrl: HttpUrl? = null + var token: String? = null + + + suspend fun initiate(baseUrl: HttpUrl): HttpUrl? { + loginUrl = null + pollUrl = null + token = null + + val json = postForJson(initiateUrl(baseUrl), "".toRequestBody()) + + loginUrl = json.getString("login").toHttpUrlOrNull() + json.getJSONObject("poll").let { poll -> + pollUrl = poll.getString("endpoint").toHttpUrl() + token = poll.getString("token") + } + + return loginUrl + } + + fun initiateUrl(baseUrl: HttpUrl): HttpUrl { + val path = baseUrl.encodedPath + + if (path.endsWith(FLOW_V2_PATH)) + // already a Login Flow v2 URL + return baseUrl + + if (path.endsWith(FLOW_V1_PATH)) + // Login Flow v1 URL, rewrite to v2 + return baseUrl.newBuilder() + .encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH)) + .build() + + // other URL, make it a Login Flow v2 URL + return baseUrl.newBuilder() + .addPathSegments(FLOW_V2_PATH) + .build() + } + + + suspend fun fetchLoginInfo(): LoginInfo { + val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl") + val token = token ?: throw IllegalArgumentException("Missing token") + + // send HTTP request to request server, login name and app password + val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType())) + + // make sure server URL ends with a slash so that DAV_PATH can be appended + val serverUrl = json.getString("server").withTrailingSlash() + + return LoginInfo( + baseUri = URI(serverUrl).resolve(DAV_PATH), + credentials = Credentials( + username = json.getString("loginName"), + password = json.getString("appPassword").toSensitiveString() + ), + suggestedGroupMethod = GroupMethod.CATEGORIES + ) + } + + + private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) { + val postRq = Request.Builder() + .url(url) + .post(requestBody) + .build() + val response = runInterruptible { + httpClient.okHttpClient.newCall(postRq).execute() + } + + if (response.code != HttpURLConnection.HTTP_OK) + throw HttpException(response) + + response.body.use { body -> + val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type") + if (mimeType.type != "application" || mimeType.subtype != "json") + throw DavException("Invalid Login Flow response (not JSON)") + + // decode JSON + return@withContext JSONObject(body.string()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthFastmail.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthFastmail.kt new file mode 100644 index 0000000..5ca5df8 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthFastmail.kt @@ -0,0 +1,49 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import androidx.core.net.toUri +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import java.net.URI + +object OAuthFastmail { + + // DAVx5 Client ID (issued by Fastmail) + private const val CLIENT_ID = "34ce41ae" + + private val SCOPES = arrayOf( + "https://www.fastmail.com/dev/protocol-caldav", // CalDAV + "https://www.fastmail.com/dev/protocol-carddav" // CardDAV + ) + + /** + * The base URL for Fastmail. Note that this URL is used for both CalDAV and CardDAV; + * the SRV records of the domain are checked to determine the respective service base URL. + */ + val baseUri: URI = URI.create("https://fastmail.com/") + + private val serviceConfig = AuthorizationServiceConfiguration( + "https://api.fastmail.com/oauth/authorize".toUri(), + "https://api.fastmail.com/oauth/refresh".toUri() + ) + + + fun signIn(email: String?, locale: String?): AuthorizationRequest { + val builder = AuthorizationRequest.Builder( + serviceConfig, + CLIENT_ID, + ResponseTypeValues.CODE, + OAuthIntegration.redirectUri + ) + return builder + .setScopes(*SCOPES) + .setLoginHint(email) + .setUiLocales(locale) + .build() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthGoogle.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthGoogle.kt new file mode 100644 index 0000000..8f7c373 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthGoogle.kt @@ -0,0 +1,53 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import androidx.core.net.toUri +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import java.net.URI + +object OAuthGoogle { + + // davx5integration@gmail.com (for davx5-ose) + private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com" + + private val SCOPES = arrayOf( + "https://www.googleapis.com/auth/calendar", // CalDAV + "https://www.googleapis.com/auth/carddav" // CardDAV + ) + + /** + * Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide; + * _calid_ of the primary calendar is the account name. + * + * This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary + * calendars. + */ + fun baseUri(googleAccount: String): URI = + URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null) + + private val serviceConfig = AuthorizationServiceConfiguration( + "https://accounts.google.com/o/oauth2/v2/auth".toUri(), + "https://oauth2.googleapis.com/token".toUri() + ) + + + fun signIn(email: String?, customClientId: String?, locale: String?): AuthorizationRequest { + val builder = AuthorizationRequest.Builder( + serviceConfig, + customClientId ?: CLIENT_ID, + ResponseTypeValues.CODE, + OAuthIntegration.redirectUri + ) + return builder + .setScopes(*SCOPES) + .setLoginHint(email) + .setUiLocales(locale) + .build() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthIntegration.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthIntegration.kt new file mode 100644 index 0000000..6e92482 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthIntegration.kt @@ -0,0 +1,63 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.net.toUri +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.network.OAuthIntegration.redirectUri +import kotlinx.coroutines.CompletableDeferred +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.TokenResponse + +/** + * Integration with OpenID AppAuth (Android) + */ +object OAuthIntegration { + + /** redirect URI, must be registered in Manifest */ + val redirectUri = + (BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri() + + /** + * Called by the authorization service when the login is finished and [redirectUri] is launched. + * + * @param authService authorization service + * @param authResponse response from the server (coming over the Intent from the browser / [AuthorizationContract]) + */ + suspend fun authenticate(authService: AuthorizationService, authResponse: AuthorizationResponse): AuthState { + val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token + val authStateFuture = CompletableDeferred() + + authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? -> + if (tokenResponse != null) { + // success, save authState (= refresh token) + authState.update(tokenResponse, refreshTokenException) + authStateFuture.complete(authState) + } else if (refreshTokenException != null) + authStateFuture.completeExceptionally(refreshTokenException) + } + + return authStateFuture.await() + } + + + class AuthorizationContract( + private val authService: AuthorizationService + ) : ActivityResultContract() { + override fun createIntent(context: Context, input: AuthorizationRequest) = + authService.getAuthorizationRequestIntent(input) + + override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? = + intent?.let { AuthorizationResponse.fromIntent(it) } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt new file mode 100644 index 0000000..7cb1dd6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthInterceptor.kt @@ -0,0 +1,106 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import at.bitfire.davdroid.BuildConfig +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationService +import okhttp3.Interceptor +import okhttp3.Response +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Provider + +/** + * Sends an OAuth Bearer token authorization as described in RFC 6750. + * + * @param readAuthState callback that fetches an up-to-date authorization state + * @param writeAuthState callback that persists a new authorization state + */ +class OAuthInterceptor @AssistedInject constructor( + @Assisted private val readAuthState: () -> AuthState?, + @Assisted private val writeAuthState: (AuthState) -> Unit, + private val authServiceProvider: Provider, + private val logger: Logger +): Interceptor { + + @AssistedFactory + interface Factory { + fun create(readAuthState: () -> AuthState?, writeAuthState: (AuthState) -> Unit): OAuthInterceptor + } + + + override fun intercept(chain: Interceptor.Chain): Response { + val rq = chain.request().newBuilder() + + /** Syntax for the "Authorization" header [RFC 6750 2.1]: + * + * b64token = 1*( ALPHA / DIGIT / + * "-" / "." / "_" / "~" / "+" / "/" ) *"=" + * credentials = "Bearer" 1*SP b64token + */ + + val accessToken = provideAccessToken() + if (accessToken != null) + rq.header("Authorization", "Bearer $accessToken") + else + logger.severe("No access token available, won't authenticate") + + return chain.proceed(rq.build()) + } + + /** + * Provides a fresh access token for authorization. Uses the current one if it's still valid, + * or requests a new one if necessary. + * + * This method is synchronized / thread-safe so that it can be called for multiple HTTP requests at the same time. + * + * @return access token or `null` if no valid access token is available (usually because of an error during refresh) + */ + fun provideAccessToken(): String? = synchronized(javaClass) { + // if possible, use cached access token + val authState = readAuthState() ?: return null + + if (authState.isAuthorized && authState.accessToken != null && !authState.needsTokenRefresh) { + if (BuildConfig.DEBUG) // log sensitive information (refresh/access token) only in debug builds + logger.log(Level.FINEST, "Using cached AuthState", authState.jsonSerializeString()) + return authState.accessToken + } + + // request fresh access token + logger.fine("Requesting fresh access token") + val accessTokenFuture = CompletableFuture() + val authService = authServiceProvider.get() + try { + authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? -> + // appauth internally fetches the new token over HttpURLConnection in an AsyncTask + if (BuildConfig.DEBUG) + logger.log(Level.FINEST, "Got new AuthState", authState.jsonSerializeString()) + + // persist updated AuthState + writeAuthState(authState) + + if (ex != null) + accessTokenFuture.completeExceptionally(ex) + else if (accessToken != null) + accessTokenFuture.complete(accessToken) + } + + accessTokenFuture.join() + } catch (e: CompletionException) { + logger.log(Level.SEVERE, "Couldn't obtain access token", e.cause) + null + } finally { + authService.dispose() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt new file mode 100644 index 0000000..8b4e361 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/OAuthModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthorizationService +import java.net.HttpURLConnection +import java.net.URL + +@Module +@InstallIn(SingletonComponent::class) +object OAuthModule { + + /** + * Make sure to call [AuthorizationService.dispose] when obtaining an instance. + * + * Creating an instance is expensive (involves CustomTabsManager), so don't create an + * instance if not necessary (use Provider/Lazy). + */ + @Provides + fun authorizationService(@ApplicationContext context: Context): AuthorizationService = + AuthorizationService(context, + AppAuthConfiguration.Builder() + .setConnectionBuilder { uri -> + val url = URL(uri.toString()) + (url.openConnection() as HttpURLConnection).apply { + setRequestProperty("User-Agent", UserAgentInterceptor.userAgent) + } + }.build() + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt new file mode 100644 index 0000000..88b9791 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/UserAgentInterceptor.kt @@ -0,0 +1,33 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.os.Build +import at.bitfire.davdroid.BuildConfig +import okhttp3.Interceptor +import okhttp3.OkHttp +import okhttp3.Response +import java.util.Locale +import java.util.logging.Logger + +object UserAgentInterceptor: Interceptor { + + val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " + + "okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}" + + init { + Logger.getGlobal().info("Will set User-Agent: $userAgent") + } + + override fun intercept(chain: Interceptor.Chain): Response { + val locale = Locale.getDefault() + val request = chain.request().newBuilder() + .header("User-Agent", userAgent) + .header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5") + .build() + return chain.proceed(request) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt new file mode 100644 index 0000000..8c36463 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt @@ -0,0 +1,119 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import androidx.annotation.VisibleForTesting +import at.bitfire.dav4jvm.XmlReader +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.Lazy +import org.unifiedpush.android.connector.data.PushMessage +import org.xmlpull.v1.XmlPullParserException +import java.io.StringReader +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import at.bitfire.dav4jvm.property.push.PushMessage as DavPushMessage + +/** + * Handles incoming WebDAV-Push messages. + */ +class PushMessageHandler @Inject constructor( + private val accountRepository: AccountRepository, + private val collectionRepository: DavCollectionRepository, + private val logger: Logger, + private val serviceRepository: DavServiceRepository, + private val syncWorkerManager: SyncWorkerManager, + private val tasksAppManager: Lazy +) { + + suspend fun processMessage(message: PushMessage, instance: String) { + if (!message.decrypted) { + logger.severe("Received a push message that could not be decrypted.") + return + } + val messageXml = message.content.toString(Charsets.UTF_8) + logger.log(Level.INFO, "Received push message", messageXml) + + // parse push notification + val topic = parse(messageXml) + + // sync affected collection + if (topic != null) { + logger.info("Got push notification for topic $topic") + + // Sync all authorities of account that the collection belongs to + // Later: only sync affected collection and authorities + collectionRepository.getSyncableByTopic(topic)?.let { collection -> + serviceRepository.get(collection.serviceId)?.let { service -> + val syncDataTypes = mutableSetOf() + // If the type is an address book, add the contacts type + if (collection.type == TYPE_ADDRESSBOOK) + syncDataTypes += SyncDataType.CONTACTS + + // If the collection supports events, add the events type + if (collection.supportsVEVENT != false) + syncDataTypes += SyncDataType.EVENTS + + // If the collection supports tasks, make sure there's a provider installed, + // and add the tasks type + if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false) + if (tasksAppManager.get().currentProvider() != null) + syncDataTypes += SyncDataType.TASKS + + // Schedule sync for all the types identified + val account = accountRepository.fromName(service.accountName) + for (syncDataType in syncDataTypes) + syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true) + } + } + + } else { + // fallback when no known topic is present (shouldn't happen) + val service = instance.toLongOrNull()?.let { serviceRepository.getBlocking(it) } + if (service != null) { + logger.warning("Got push message without topic and service, syncing all accounts") + val account = accountRepository.fromName(service.accountName) + syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true) + + } else { + logger.warning("Got push message without topic, syncing all accounts") + for (account in accountRepository.getAll()) + syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true) + } + } + } + + /** + * Parses a WebDAV-Push message and returns the `topic` that the message is about. + * + * @return topic of the modified collection, or `null` if the topic couldn't be determined + */ + @VisibleForTesting + internal fun parse(message: String): String? { + var topic: String? = null + + val parser = XmlUtils.newPullParser() + try { + parser.setInput(StringReader(message)) + + XmlReader(parser).processTag(DavPushMessage.NAME) { + val pushMessage = DavPushMessage.Factory.create(parser) + topic = pushMessage.topic?.topic + } + } catch (e: XmlPullParserException) { + logger.log(Level.WARNING, "Couldn't parse push message", e) + } + + return topic + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushNotificationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushNotificationManager.kt new file mode 100644 index 0000000..d8e689f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushNotificationManager.kt @@ -0,0 +1,70 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.accounts.Account +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.TaskStackBuilder +import at.bitfire.davdroid.R +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.davdroid.ui.account.AccountActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class PushNotificationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val notificationRegistry: NotificationRegistry +) { + + /** + * Generates the notification ID for a push notification. + */ + private fun notificationId(account: Account, dataType: SyncDataType): Int { + return account.name.hashCode() + account.type.hashCode() + dataType.hashCode() + } + + /** + * Sends a notification to inform the user that a push notification has been received, the + * sync has been scheduled, but it still has not run. + */ + fun notify(account: Account, dataType: SyncDataType) { + notificationRegistry.notifyIfPossible(notificationId(account, dataType)) { + NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_sync) + .setContentTitle(context.getString(R.string.sync_notification_pending_push_title)) + .setContentText(context.getString(R.string.sync_notification_pending_push_message)) + .setSubText(account.name) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setContentIntent( + TaskStackBuilder.create(context) + .addNextIntentWithParentStack( + Intent(context, AccountActivity::class.java).apply { + putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } + ) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + .build() + } + } + + /** + * Once the sync has been started, the notification is no longer needed and can be dismissed. + * It's safe to call this method even if the notification has not been shown. + */ + fun dismiss(account: Account, dataType: SyncDataType) { + NotificationManagerCompat.from(context) + .cancel(notificationId(account, dataType)) + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt new file mode 100644 index 0000000..ad4e882 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -0,0 +1,375 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.push.AuthSecret +import at.bitfire.dav4jvm.property.push.PushRegister +import at.bitfire.dav4jvm.property.push.PushResource +import at.bitfire.dav4jvm.property.push.Subscription +import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey +import at.bitfire.dav4jvm.property.push.WebPushSubscription +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.sync.account.InvalidAccountException +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint +import java.io.StringWriter +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider + +/** + * Manages push registrations and subscriptions. + * + * To update push registrations and subscriptions (for instance after collections have been changed), call [update]. + * + * Public API calls are protected by [mutex] so that there won't be multiple subscribe/unsubscribe operations at the same time. + * If you call other methods than [update], make sure that they don't interfere with other operations. + */ +class PushRegistrationManager @Inject constructor( + private val accountRepository: Lazy, + private val collectionRepository: DavCollectionRepository, + @ApplicationContext private val context: Context, + private val httpClientBuilder: Provider, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger, + private val serviceRepository: DavServiceRepository +) { + + /** + * Sets or removes (disable push) the distributor and updates the subscriptions + worker. + * + * Uses [update] which is protected by [mutex] so creating/deleting subscriptions doesn't + * interfere with other operations. + * + * @param pushDistributor new distributor or `null` to disable Push + */ + suspend fun setPushDistributor(pushDistributor: String?) { + // Disable UnifiedPush and remove all subscriptions + UnifiedPush.removeDistributor(context) + update() + + if (pushDistributor != null) { + // If a distributor was passed, store it and create/register subscriptions + UnifiedPush.saveDistributor(context, pushDistributor) + update() + } + } + + fun getCurrentDistributor() = UnifiedPush.getSavedDistributor(context) + + fun getDistributors() = UnifiedPush.getDistributors(context) + + + /** + * Updates all push registrations and subscriptions so that if Push is available, it's up-to-date and + * working for all database services. If Push is not available, existing subscriptions are unregistered. + * + * Also makes sure that the [PushRegistrationWorker] is enabled if there's a Push-enabled collection. + * + * Acquires [mutex] so that this method can't be called twice at the same time, or at the same time + * with [update(serviceId)]. + */ + suspend fun update() = mutex.withLock { + for (service in serviceRepository.getAll()) + updateService(service.id) + + updatePeriodicWorker() + } + + /** + * Same as [update], but for a specific database service. + * + * Acquires [mutex] so that this method can't be called twice at the same time, or at the same time + * as [update()]. + */ + suspend fun update(serviceId: Long) = mutex.withLock { + updateService(serviceId) + updatePeriodicWorker() + } + + /** + * Registers or unregisters subscriptions depending on whether there is a distributor available. + */ + private suspend fun updateService(serviceId: Long) { + val service = serviceRepository.get(serviceId) ?: return + + // use service ID from database as UnifiedPush instance name + val instance = serviceId.toString() + + val distributorAvailable = getCurrentDistributor() != null + if (distributorAvailable) + try { + val vapid = collectionRepository.getVapidKey(serviceId) + logger.fine("Registering UnifiedPush instance $serviceId (${service.accountName})") + + // message for distributor + val message = "${service.accountName} (${service.type})" + + UnifiedPush.register(context, instance, message, vapid) + } catch (e: UnifiedPush.VapidNotValidException) { + logger.log(Level.WARNING, "Couldn't register invalid VAPID key for service $serviceId", e) + } + else { + logger.fine("Unregistering UnifiedPush instance $serviceId (${service.accountName})") + UnifiedPush.unregister(context, instance) // doesn't call UnifiedPushService.onUnregistered + unsubscribeAll(service) + } + + // UnifiedPush has now been called. It will do its work and then asynchronously call back to UnifiedPushService, which + // will then call processSubscription or removeSubscription. + } + + /** + * Called by [UnifiedPushService] when a subscription (endpoint) is available for the given service. + * + * Uses the subscription to subscribe to syncable collections, and then unsubscribes from non-syncable collections. + */ + suspend fun processSubscription(serviceId: Long, endpoint: PushEndpoint) = mutex.withLock { + val service = serviceRepository.get(serviceId) ?: return + + try { + // subscribe to collections which are selected for synchronization + subscribeSyncable(service, endpoint) + + // unsubscribe from collections which are not selected for synchronization + unsubscribeCollections(service, collectionRepository.getPushRegisteredAndNotSyncable(service.id)) + } catch (_: InvalidAccountException) { + // couldn't create authenticating HTTP client because account is not available + } + } + + private suspend fun subscribeSyncable(service: Service, endpoint: PushEndpoint) { + val subscribeTo = collectionRepository.getPushCapableAndSyncable(service.id) + if (subscribeTo.isEmpty()) + return + + val account = accountRepository.get().fromName(service.accountName) + httpClientBuilder.get() + .fromAccountAsync(account) + .build() + .use { httpClient -> + for (collection in subscribeTo) + try { + val expires = collection.pushSubscriptionExpires + // calculate next run time, but use the duplicate interval for safety (times are not exact) + val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS) + if (expires != null && expires >= nextRun.epochSecond) + logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}") + else { + // no existing subscription or expiring soon + logger.fine("Registering push subscription for ${collection.url}") + subscribe(httpClient, collection, endpoint) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e) + } + } + } + + /** + * Called when no subscription is available (anymore) for the given service. + * + * Unsubscribes from all subscribed collections. + */ + suspend fun removeSubscription(serviceId: Long) = mutex.withLock { + val service = serviceRepository.get(serviceId) ?: return + unsubscribeAll(service) + } + + private suspend fun unsubscribeAll(service: Service) { + val unsubscribeFrom = collectionRepository.getPushRegistered(service.id) + + try { + unsubscribeCollections(service, unsubscribeFrom) + } catch (_: InvalidAccountException) { + // couldn't create authenticating HTTP client because account is not available + } + } + + + /** + * Registers the subscription to a given collection ("subscribe to a collection"). + * + * @param httpClient HTTP client to use + * @param collection collection to subscribe to + * @param endpoint subscription to register + */ + private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) { + // requested expiration time: 3 days + val requestedExpiration = Instant.now() + Duration.ofDays(3) + + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", true) + serializer.insertTag(PushRegister.NAME) { + serializer.insertTag(Subscription.NAME) { + // subscription URL + serializer.insertTag(WebPushSubscription.NAME) { + serializer.insertTag(PushResource.NAME) { + text(endpoint.url) + } + endpoint.pubKeySet?.let { pubKeySet -> + serializer.insertTag(SubscriptionPublicKey.NAME) { + attribute(null, "type", "p256dh") + text(pubKeySet.pubKey) + } + serializer.insertTag(AuthSecret.NAME) { + text(pubKeySet.auth) + } + } + } + } + // requested expiration + serializer.insertTag(PushRegister.EXPIRES) { + text(HttpUtils.formatDate(requestedExpiration)) + } + } + serializer.endDocument() + + runInterruptible(ioDispatcher) { + val xml = writer.toString().toRequestBody(DavResource.MIME_XML) + DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response -> + if (response.isSuccessful) { + // update subscription URL and expiration in DB + val subscriptionUrl = response.header("Location") + val expires = response.header("Expires")?.let { expiresDate -> + HttpUtils.parseDate(expiresDate) + } ?: requestedExpiration + + runBlocking { + collectionRepository.updatePushSubscription( + id = collection.id, + subscriptionUrl = subscriptionUrl, + expires = expires?.epochSecond + ) + } + } else + logger.warning("Couldn't register push for ${collection.url}: $response") + } + } + } + + /** + * Unsubscribe from the given collections. + */ + private suspend fun unsubscribeCollections(service: Service, from: List) { + if (from.isEmpty()) + return + + val account = accountRepository.get().fromName(service.accountName) + httpClientBuilder.get() + .fromAccountAsync(account) + .build() + .use { httpClient -> + for (collection in from) + collection.pushSubscription?.toHttpUrlOrNull()?.let { url -> + logger.info("Unsubscribing Push from ${collection.url}") + unsubscribe(httpClient, collection, url) + } + } + } + + private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) { + try { + runInterruptible(ioDispatcher) { + DavResource(httpClient.okHttpClient, url).delete { + // deleted + } + } + } catch (e: DavException) { + logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e) + } + + // remove registration URL from DB in any case + collectionRepository.updatePushSubscription( + id = collection.id, + subscriptionUrl = null, + expires = null + ) + } + + + /** + * Determines whether there are any push-capable collections and updates the periodic worker accordingly. + * + * If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued. + * A potentially existing worker is replaced, so that the first run should be soon. + * + * Otherwise, a potentially existing worker is cancelled. + */ + private suspend fun updatePeriodicWorker() { + val workerNeeded = collectionRepository.anyPushCapable() + + val workManager = WorkManager.getInstance(context) + if (workerNeeded) { + logger.info("Enqueuing periodic PushRegistrationWorker") + workManager.enqueueUniquePeriodicWork( + WORKER_UNIQUE_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequest.Builder(PushRegistrationWorker::class, WORKER_INTERVAL_DAYS, TimeUnit.DAYS) + .setInitialDelay(5, TimeUnit.SECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .build() + ) + } else { + logger.info("Cancelling periodic PushRegistrationWorker") + workManager.cancelUniqueWork(WORKER_UNIQUE_NAME) + } + } + + + companion object { + + private const val WORKER_UNIQUE_NAME = "push-registration" + const val WORKER_INTERVAL_DAYS = 1L + + /** + * Mutex to synchronize (un)subscription. + */ + val mutex = Mutex() + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt new file mode 100644 index 0000000..061452b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationWorker.kt @@ -0,0 +1,38 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.logging.Logger + +/** + * Worker that runs regularly and initiates push registration updates for all collections. + * + * Managed by [PushRegistrationManager]. + */ +@Suppress("unused") +@HiltWorker +class PushRegistrationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val logger: Logger, + private val pushRegistrationManager: PushRegistrationManager +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + logger.info("Running push registration worker") + + // update registrations for all services + pushRegistrationManager.update() + + return Result.success() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushService.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushService.kt new file mode 100644 index 0000000..31e0331 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/UnifiedPushService.kt @@ -0,0 +1,80 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.push + +import at.bitfire.davdroid.di.ApplicationScope +import dagger.Lazy +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Entry point for UnifiedPush. + * + * Calls [PushRegistrationManager] for most tasks, except incoming push messages, + * which are handled directly. + */ +@AndroidEntryPoint +class UnifiedPushService : PushService() { + + /* Scope to run the requests asynchronously. UnifiedPush binds the service, + * sends the message and unbinds one second later. Our operations may take longer, + * so the scope should not be bound to the service lifecycle. */ + @Inject + @ApplicationScope + lateinit var applicationScope: CoroutineScope + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var pushMessageHandler: Lazy + + @Inject + lateinit var pushRegistrationManager: Lazy + + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + val serviceId = instance.toLongOrNull() ?: return + logger.warning("Got UnifiedPush endpoint for service $serviceId: ${endpoint.url}") + + // register new endpoint at CalDAV/CardDAV servers + applicationScope.launch { + pushRegistrationManager.get().processSubscription(serviceId, endpoint) + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + val serviceId = instance.toLongOrNull() ?: return + logger.warning("UnifiedPush registration failed for service $serviceId: $reason") + + // unregister subscriptions + applicationScope.launch { + pushRegistrationManager.get().removeSubscription(serviceId) + } + } + + override fun onUnregistered(instance: String) { + val serviceId = instance.toLongOrNull() ?: return + logger.warning("UnifiedPush unregistered for service $serviceId") + + applicationScope.launch { + pushRegistrationManager.get().removeSubscription(serviceId) + } + } + + override fun onMessage(message: PushMessage, instance: String) { + applicationScope.launch { + pushMessageHandler.get().processMessage(message, instance) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt new file mode 100644 index 0000000..080b91c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -0,0 +1,269 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.content.Context +import androidx.annotation.WorkerThread +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.db.ServiceType +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.resource.LocalCalendarStore +import at.bitfire.davdroid.servicedetection.DavResourceFinder +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.sync.AutomaticSyncManager +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.account.AccountsCleanupWorker +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.sync.account.SystemAccountUtils +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import at.bitfire.vcard4android.GroupMethod +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Repository for managing CalDAV/CardDAV accounts. + * + * *Note:* This class is not related to address book accounts, which are managed by + * [at.bitfire.davdroid.resource.LocalAddressBook]. + */ +class AccountRepository @Inject constructor( + private val accountSettingsFactory: AccountSettings.Factory, + private val automaticSyncManager: Lazy, + @ApplicationContext private val context: Context, + private val collectionRepository: DavCollectionRepository, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + private val homeSetRepository: DavHomeSetRepository, + private val localCalendarStore: Lazy, + private val localAddressBookStore: Lazy, + private val logger: Logger, + private val serviceRepository: DavServiceRepository, + private val syncWorkerManager: Lazy, + private val tasksAppManager: Lazy +) { + + private val accountType = context.getString(R.string.account_type) + private val accountManager = AccountManager.get(context) + + /** + * Creates a new account with discovered services and enables periodic syncs with + * default sync interval times. + * + * @param accountName name of the account + * @param credentials server credentials + * @param config discovered server capabilities for syncable authorities + * @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories + * + * @return account if account creation was successful; null otherwise (for instance because an account with this name already exists) + */ + @WorkerThread + fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? { + val account = fromName(accountName) + + // create Android account + val userData = AccountSettings.initialUserData(credentials) + logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) + + if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password)) + return null + + // add entries for account to database + logger.log(Level.INFO, "Writing account configuration to database", config) + try { + if (config.cardDAV != null) { + // insert CardDAV service + val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV) + + // set initial CardDAV account settings and set sync intervals (enables automatic sync) + val accountSettings = accountSettingsFactory.create(account) + accountSettings.setGroupMethod(groupMethod) + + // start CardDAV service detection (refresh collections) + RefreshCollectionsWorker.enqueue(context, id) + } + + if (config.calDAV != null) { + // insert CalDAV service + val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV) + + // start CalDAV service detection (refresh collections) + RefreshCollectionsWorker.enqueue(context, id) + } + + // set up automatic sync (processes inserted services) + automaticSyncManager.get().updateAutomaticSync(account) + + } catch(e: InvalidAccountException) { + logger.log(Level.SEVERE, "Couldn't access account settings", e) + return null + } + return account + } + + suspend fun delete(accountName: String): Boolean { + val account = fromName(accountName) + // remove account directly (bypassing the authenticator, which is our own) + return try { + accountManager.removeAccountExplicitly(account) + + // delete address books (= address book accounts) + serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service -> + collectionRepository.getByService(service.id).forEach { collection -> + localAddressBookStore.get().deleteByCollectionId(collection.id) + } + } + + // delete from database + serviceRepository.deleteByAccount(accountName) + + true + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't remove account $accountName", e) + false + } + } + + fun exists(accountName: String): Boolean = + if (accountName.isEmpty()) + false + else + accountManager + .getAccountsByType(accountType) + .any { it.name == accountName } + + fun fromName(accountName: String) = + Account(accountName, accountType) + + fun getAll(): Array = accountManager.getAccountsByType(accountType) + + fun getAllFlow() = callbackFlow> { + val listener = OnAccountsUpdateListener { accounts -> + trySend(accounts.filter { it.type == accountType }.toSet()) + } + withContext(defaultDispatcher) { // causes disk I/O + accountManager.addOnAccountsUpdatedListener(listener, null, true) + } + + awaitClose { + accountManager.removeOnAccountsUpdatedListener(listener) + } + } + + /** + * Renames an account. + * + * **Not**: It is highly advised to re-sync the account after renaming in order to restore + * a consistent state. + * + * @param oldName current name of the account + * @param newName new name the account shall be re named to + * + * @throws InvalidAccountException if the account does not exist + * @throws IllegalArgumentException if the new account name already exists + * @throws Exception (or sub-classes) on other errors + */ + suspend fun rename(oldName: String, newName: String): Unit = withContext(defaultDispatcher) { + val oldAccount = fromName(oldName) + val newAccount = fromName(newName) + + // check whether new account name already exists + if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount)) + throw IllegalArgumentException("Account with name \"$newName\" already exists") + + // rename account + try { + /* https://github.com/bitfireAT/davx5/issues/135 + Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account + because this can cause problems when: + 1. The account is renamed. + 2. The AccountsCleanupWorker is called BEFORE the services table is updated. + → AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore + 3. Now the services would be renamed, but they're not here anymore. */ + AccountsCleanupWorker.lockAccountsCleanup() + + // rename account (also moves AccountSettings) + val future = accountManager.renameAccount(oldAccount, newName, null, null) + + // wait for operation to complete (blocks calling thread) + val newNameFromApi: Account = future.result + if (newNameFromApi.name != newName) + throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName") + + // account renamed, cancel maybe running synchronization of old account + syncWorkerManager.get().cancelAllWork(oldAccount) + + // disable periodic syncs for old account + for (dataType in SyncDataType.entries) + syncWorkerManager.get().disablePeriodic(oldAccount, dataType) + + // update account name references in database + serviceRepository.renameAccount(oldName, newName) + + try { + // update address books + localAddressBookStore.get().updateAccount(oldAccount, newAccount) + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't change address books to renamed account", e) + } + + try { + // update calendar events + localCalendarStore.get().updateAccount(oldAccount, newAccount) + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e) + } + + try { + // update account_name of local tasks + val dataStore = tasksAppManager.get().getDataStore() + dataStore?.updateAccount(oldAccount, newAccount) + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e) + } + + // update automatic sync + automaticSyncManager.get().updateAutomaticSync(newAccount) + } finally { + // release AccountsCleanupWorker mutex at the end of this async coroutine + AccountsCleanupWorker.unlockAccountsCleanup() + } + } + + + // helpers + + private fun insertService(accountName: String, @ServiceType type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { + // insert service + val service = Service(0, accountName, type, info.principal) + val serviceId = serviceRepository.insertOrReplaceBlocking(service) + + // insert home sets + for (homeSet in info.homeSets) + homeSetRepository.insertOrUpdateByUrlBlocking(HomeSet(0, serviceId, true, homeSet)) + + // insert collections + for (collection in info.collections.values) { + collectionRepository.insertOrUpdateByUrl(collection.copy(serviceId = serviceId)) + } + + return serviceId + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt new file mode 100644 index 0000000..ddb4a70 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -0,0 +1,425 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.accounts.Account +import android.content.Context +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.exception.GoneException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.property.caldav.CalendarColor +import at.bitfire.dav4jvm.property.caldav.CalendarDescription +import at.bitfire.dav4jvm.property.caldav.CalendarTimezone +import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId +import at.bitfire.dav4jvm.property.caldav.NS_CALDAV +import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet +import at.bitfire.dav4jvm.property.carddav.AddressbookDescription +import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.CollectionType +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.util.DavUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runInterruptible +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.ComponentList +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VTimeZone +import net.fortuna.ical4j.model.property.Version +import okhttp3.HttpUrl +import java.io.StringWriter +import java.util.UUID +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider + +/** + * Repository for managing collections. + */ +class DavCollectionRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val logger: Logger, + private val httpClientBuilder: Provider, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val serviceRepository: DavServiceRepository +) { + + private val dao = db.collectionDao() + + /** + * Whether there are any collections that are registered for push. + */ + suspend fun anyPushCapable() = dao.anyPushCapable() + + /** + * Creates address book collection on server and locally + */ + suspend fun createAddressBook( + account: Account, + homeSet: HomeSet, + displayName: String, + description: String? + ) { + val folderName = UUID.randomUUID().toString() + val url = homeSet.url.newBuilder() + .addPathSegment(folderName) + .addPathSegment("") // trailing slash + .build() + + // create collection on server + createOnServer( + account = account, + url = url, + method = "MKCOL", + xmlBody = generateMkColXml( + addressBook = true, + displayName = displayName, + description = description + ) + ) + + // no HTTP error -> create collection locally + val collection = Collection( + serviceId = homeSet.serviceId, + homeSetId = homeSet.id, + url = url, + type = Collection.TYPE_ADDRESSBOOK, + displayName = displayName, + description = description + ) + dao.insertAsync(collection) + } + + /** + * Create calendar collection on server and locally + */ + suspend fun createCalendar( + account: Account, + homeSet: HomeSet, + color: Int?, + displayName: String, + description: String?, + timeZoneId: String?, + supportVEVENT: Boolean, + supportVTODO: Boolean, + supportVJOURNAL: Boolean + ) { + val folderName = UUID.randomUUID().toString() + val url = homeSet.url.newBuilder() + .addPathSegment(folderName) + .addPathSegment("") // trailing slash + .build() + + // create collection on server + createOnServer( + account = account, + url = url, + method = "MKCALENDAR", + xmlBody = generateMkColXml( + addressBook = false, + displayName = displayName, + description = description, + color = color, + timezoneId = timeZoneId, + supportsVEVENT = supportVEVENT, + supportsVTODO = supportVTODO, + supportsVJOURNAL = supportVJOURNAL + ) + ) + + // no HTTP error -> create collection locally + val collection = Collection( + serviceId = homeSet.serviceId, + homeSetId = homeSet.id, + url = url, + type = Collection.TYPE_CALENDAR, + displayName = displayName, + description = description, + color = color, + timezoneId = timeZoneId, + supportsVEVENT = supportVEVENT, + supportsVTODO = supportVTODO, + supportsVJOURNAL = supportVJOURNAL + ) + dao.insertAsync(collection) + + // Trigger service detection (because the collection may actually have other properties than the ones we have inserted). + // Some servers are known to change the supported components (VEVENT, …) after creation. + RefreshCollectionsWorker.enqueue(context, homeSet.serviceId) + } + + /** Deletes the given collection from the server and the database. */ + suspend fun deleteRemote(collection: Collection) { + val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + httpClientBuilder.get().fromAccount(account).build().use { httpClient -> + runInterruptible(ioDispatcher) { + try { + DavResource(httpClient.okHttpClient, collection.url).delete { + // success, otherwise an exception would have been thrown → delete locally, too + delete(collection) + } + } catch (e: HttpException) { + if (e is NotFoundException || e is GoneException) { + // HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too + logger.info("Collection ${collection.url} not found on server, deleting locally") + delete(collection) + } else + throw e + } + } + } + } + + suspend fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic) + + fun get(id: Long) = dao.get(id) + suspend fun getAsync(id: Long) = dao.getAsync(id) + + fun getFlow(id: Long) = dao.getFlow(id) + + suspend fun getByService(serviceId: Long) = dao.getByService(serviceId) + + fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url) + + fun getByServiceAndSync(serviceId: Long) = dao.getByServiceAndSync(serviceId) + + fun getSyncCalendars(serviceId: Long) = dao.getSyncCalendars(serviceId) + + fun getSyncJtxCollections(serviceId: Long) = dao.getSyncJtxCollections(serviceId) + + fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId) + + /** Returns all collections that are both selected for synchronization and push-capable. */ + suspend fun getPushCapableAndSyncable(serviceId: Long) = dao.getPushCapableSyncCollections(serviceId) + + suspend fun getPushRegistered(serviceId: Long) = dao.getPushRegistered(serviceId) + suspend fun getPushRegisteredAndNotSyncable(serviceId: Long) = dao.getPushRegisteredAndNotSyncable(serviceId) + + suspend fun getVapidKey(serviceId: Long) = dao.getFirstVapidKey(serviceId) + + /** + * Inserts or updates the collection. + * + * On update, it will _not_ update the flags + * - [Collection.sync] and + * - [Collection.forceReadOnly], + * but use the values of the already existing collection. + * + * @param newCollection Collection to be inserted or updated + */ + fun insertOrUpdateByUrlRememberSync(newCollection: Collection) { + db.runInTransaction { + // remember locally set flags + val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString()) + val newCollectionWithFlags = + if (oldCollection != null) + newCollection.copy(sync = oldCollection.sync, forceReadOnly = oldCollection.forceReadOnly) + else + newCollection + + // commit new collection to database + insertOrUpdateByUrl(newCollectionWithFlags) + } + } + + /** + * Creates or updates the existing collection if it exists (URL) + */ + fun insertOrUpdateByUrl(collection: Collection) { + dao.insertOrUpdateByUrl(collection) + } + + fun pageByServiceAndType(serviceId: Long, @CollectionType type: String) = + dao.pageByServiceAndType(serviceId, type) + + fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String) = + dao.pagePersonalByServiceAndType(serviceId, type) + + /** + * Sets the flag for whether read-only should be enforced on the local collection + */ + suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) { + dao.updateForceReadOnly(id, forceReadOnly) + } + + /** + * Whether or not the local collection should be synced with the server + */ + suspend fun setSync(id: Long, forceReadOnly: Boolean) { + dao.updateSync(id, forceReadOnly) + } + + suspend fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) { + dao.updatePushSubscription( + id = id, + pushSubscription = subscriptionUrl, + pushSubscriptionExpires = expires + ) + } + + /** + * Deletes the collection locally + */ + fun delete(collection: Collection) { + dao.delete(collection) + } + + + // helpers + + private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) { + httpClientBuilder.get() + .fromAccount(account) + .build() + .use { httpClient -> + runInterruptible(ioDispatcher) { + DavResource(httpClient.okHttpClient, url).mkCol( + xmlBody = xmlBody, + method = method + ) { + // success, otherwise an exception would have been thrown + } + } + } + } + + private fun generateMkColXml( + addressBook: Boolean, + displayName: String?, + description: String?, + color: Int? = null, + timezoneId: String? = null, + supportsVEVENT: Boolean = true, + supportsVTODO: Boolean = true, + supportsVJOURNAL: Boolean = true + ): String { + val writer = StringWriter() + val serializer = XmlUtils.newSerializer() + serializer.apply { + setOutput(writer) + + startDocument("UTF-8", null) + setPrefix("", NS_WEBDAV) + setPrefix("CAL", NS_CALDAV) + setPrefix("CARD", NS_CARDDAV) + + if (addressBook) + startTag(NS_WEBDAV, "mkcol") + else + startTag(NS_CALDAV, "mkcalendar") + + insertTag(DavResource.SET) { + insertTag(DavResource.PROP) { + insertTag(ResourceType.NAME) { + insertTag(ResourceType.COLLECTION) + if (addressBook) + insertTag(ResourceType.ADDRESSBOOK) + else + insertTag(ResourceType.CALENDAR) + } + + displayName?.let { + insertTag(DisplayName.NAME) { + text(it) + } + } + + if (addressBook) { + // addressbook-specific properties + description?.let { + insertTag(AddressbookDescription.NAME) { + text(it) + } + } + + } else { + // calendar-specific properties + description?.let { + insertTag(CalendarDescription.NAME) { + text(it) + } + } + color?.let { + insertTag(CalendarColor.NAME) { + text(DavUtils.ARGBtoCalDAVColor(it)) + } + } + timezoneId?.let { id -> + insertTag(CalendarTimezoneId.NAME) { + text(id) + } + getVTimeZone(id)?.let { vTimezone -> + insertTag(CalendarTimezone.NAME) { + text( + // spec requires "an iCalendar object with exactly one VTIMEZONE component" + Calendar( + PropertyList().apply { + add(Version.VERSION_2_0) + add(Constants.iCalProdId) + }, + ComponentList( + listOf(vTimezone) + ) + ).toString() + ) + } + } + } + + if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) { + insertTag(SupportedCalendarComponentSet.NAME) { + // Only if there's at least one not explicitly supported calendar component set, + // otherwise don't include the property, which means "supports everything". + if (supportsVEVENT) + insertTag(SupportedCalendarComponentSet.COMP) { + attribute(null, "name", Component.VEVENT) + } + if (supportsVTODO) + insertTag(SupportedCalendarComponentSet.COMP) { + attribute(null, "name", Component.VTODO) + } + if (supportsVJOURNAL) + insertTag(SupportedCalendarComponentSet.COMP) { + attribute(null, "name", Component.VJOURNAL) + } + } + } + } + } + } + if (addressBook) + endTag(NS_WEBDAV, "mkcol") + else + endTag(NS_CALDAV, "mkcalendar") + endDocument() + } + return writer.toString() + } + + private fun getVTimeZone(tzId: String): VTimeZone? { + val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + return tzRegistry.getTimeZone(tzId)?.vTimeZone + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt new file mode 100644 index 0000000..67ca542 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt @@ -0,0 +1,36 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.accounts.Account +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import javax.inject.Inject + +class DavHomeSetRepository @Inject constructor( + db: AppDatabase +) { + + private val dao = db.homeSetDao() + + fun getAddressBookHomeSetsFlow(account: Account) = + dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV) + + fun getBindableByServiceFlow(serviceId: Long) = dao.getBindableByServiceFlow(serviceId) + + fun getByIdBlocking(id: Long) = dao.getById(id) + + fun getByServiceBlocking(serviceId: Long) = dao.getByService(serviceId) + + fun getCalendarHomeSetsFlow(account: Account) = + dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV) + + fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long = + dao.insertOrUpdateByUrlBlocking(homeSet) + + fun deleteBlocking(homeSet: HomeSet) = dao.delete(homeSet) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt new file mode 100644 index 0000000..11dcd8e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt @@ -0,0 +1,52 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.db.ServiceType +import javax.inject.Inject + +class DavServiceRepository @Inject constructor( + db: AppDatabase +) { + + private val dao = db.serviceDao() + + + // Read + + fun getBlocking(id: Long): Service? = dao.get(id) + suspend fun get(id: Long): Service? = dao.getAsync(id) + + suspend fun getAll(): List = dao.getAll() + + suspend fun getByAccountAndType(name: String, @ServiceType serviceType: String): Service? = + dao.getByAccountAndType(name, serviceType) + + fun getCalDavServiceFlow(accountName: String) = + dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CALDAV) + + fun getCardDavServiceFlow(accountName: String) = + dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CARDDAV) + + + // Create & update + + fun insertOrReplaceBlocking(service: Service) = + dao.insertOrReplace(service) + + suspend fun renameAccount(oldName: String, newName: String) = + dao.renameAccount(oldName, newName) + + + // Delete + + fun deleteAllBlocking() = dao.deleteAll() + + suspend fun deleteByAccount(accountName: String) = + dao.deleteByAccount(accountName) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt new file mode 100644 index 0000000..1fef68b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt @@ -0,0 +1,50 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.content.Context +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.SyncStats +import at.bitfire.davdroid.sync.SyncDataType +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.text.Collator +import javax.inject.Inject + +class DavSyncStatsRepository @Inject constructor( + @ApplicationContext val context: Context, + db: AppDatabase +) { + + private val dao = db.syncStatsDao() + + data class LastSynced( + val dataType: String, + val lastSynced: Long + ) + fun getLastSyncedFlow(collectionId: Long): Flow> = + dao.getByCollectionIdFlow(collectionId).map { list -> + val collator = Collator.getInstance() + list.map { stats -> + LastSynced( + dataType = stats.dataType, + lastSynced = stats.lastSync + ) + }.sortedWith { a, b -> + collator.compare(a.dataType, b.dataType) + } + } + + suspend fun logSyncTime(collectionId: Long, dataType: SyncDataType, lastSync: Long = System.currentTimeMillis()) { + dao.insertOrReplace(SyncStats( + id = 0, + collectionId = collectionId, + dataType = dataType.name, + lastSync = lastSync + )) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt new file mode 100644 index 0000000..e9efffd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/PreferenceRepository.kt @@ -0,0 +1,75 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.content.Context +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject + +/** + * Repository to access preferences. Preferences are stored in a shared preferences file + * and reflect settings that are very low-level and are therefore not covered by + * [at.bitfire.davdroid.settings.SettingsManager]. + */ +class PreferenceRepository @Inject constructor( + @ApplicationContext context: Context +) { + + companion object { + const val LOG_TO_FILE = "log_to_file" + } + + private val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + + /** + * Updates the "log to file" (verbose logging") preference. + */ + fun logToFile(logToFile: Boolean) { + preferences.edit { + putBoolean(LOG_TO_FILE, logToFile) + } + } + + /** + * Gets the "log to file" (verbose logging) preference. + */ + fun logToFile(): Boolean = + preferences.getBoolean(LOG_TO_FILE, false) + + /** + * Gets the "log to file" (verbose logging) preference as a live value. + */ + fun logToFileFlow(): Flow = observeAsFlow(LOG_TO_FILE) { + logToFile() + } + + + // helpers + + private fun observeAsFlow(keyToObserve: String, getValue: () -> T): Flow = + callbackFlow { + val listener = OnSharedPreferenceChangeListener { _, key -> + if (key == keyToObserve) { + trySend(getValue()) + } + } + preferences.registerOnSharedPreferenceChangeListener(listener) + + // Emit the initial value + trySend(getValue()) + + awaitClose { + preferences.unregisterOnSharedPreferenceChangeListener(listener) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/PrincipalRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/PrincipalRepository.kt new file mode 100644 index 0000000..dc4c511 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/PrincipalRepository.kt @@ -0,0 +1,19 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Principal +import javax.inject.Inject + +class PrincipalRepository @Inject constructor( + db: AppDatabase +) { + + private val dao = db.principalDao() + + fun getBlocking(id: Long): Principal = dao.get(id) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt new file mode 100644 index 0000000..7739910 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt @@ -0,0 +1,9 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import at.bitfire.vcard4android.Contact + +interface LocalAddress: LocalResource \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt new file mode 100644 index 0000000..bf74b54 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt @@ -0,0 +1,360 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.Context +import android.os.Bundle +import android.os.RemoteException +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import android.provider.ContactsContract.Groups +import android.provider.ContactsContract.RawContacts +import androidx.annotation.OpenForTesting +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.R +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ONLY +import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.account.SystemAccountUtils +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.ContactsBatchOperation +import at.bitfire.vcard4android.AndroidAddressBook +import at.bitfire.vcard4android.AndroidContact +import at.bitfire.vcard4android.AndroidGroup +import at.bitfire.vcard4android.GroupMethod +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.LinkedList +import java.util.Optional +import java.util.logging.Level +import java.util.logging.Logger + +/** + * A local address book. Requires its own Android account, because Android manages contacts per + * account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5 + * address book" account for every CardDAV address book. + * + * @param account DAVx5 account which "owns" this address book + * @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android + * contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed, + * the new name will only be available in [addressBookAccount], so usually that one should be used. + * @param provider Content provider needed to access and modify the address book + */ +@OpenForTesting +open class LocalAddressBook @AssistedInject constructor( + @Assisted("account") val account: Account, + @Assisted("addressBookAccount") _addressBookAccount: Account, + @Assisted provider: ContentProviderClient, + private val accountSettingsFactory: AccountSettings.Factory, + private val collectionRepository: DavCollectionRepository, + @ApplicationContext private val context: Context, + internal val dirtyVerifier: Optional, + private val logger: Logger, + private val serviceRepository: DavServiceRepository, + private val syncFramework: SyncFrameworkIntegration +): AndroidAddressBook(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection { + + @AssistedFactory + interface Factory { + fun create( + @Assisted("account") account: Account, + @Assisted("addressBookAccount") addressBookAccount: Account, + provider: ContentProviderClient + ): LocalAddressBook + } + + override val tag: String + get() = "contacts-${addressBookAccount.name}" + + override val title + get() = addressBookAccount.name + + private val accountManager by lazy { AccountManager.get(context) } + + /** + * Whether contact groups ([LocalGroup]) are included in query results + * and are affected by updates/deletes on generic members. + * + * For instance, if groupMethod is GROUP_VCARDS, [findDirty] will find only dirty [LocalContact]s, + * but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s. + */ + open val groupMethod: GroupMethod by lazy { + val account = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId -> + collectionRepository.get(collectionId)?.let { collection -> + serviceRepository.getBlocking(collection.serviceId)?.let { service -> + Account(service.accountName, context.getString(R.string.account_type)) + } + } + } + if (account == null) + throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account") + val accountSettings = accountSettingsFactory.create(account) + accountSettings.getGroupMethod() + } + val includeGroups + get() = groupMethod == GroupMethod.GROUP_VCARDS + + override var dbCollectionId: Long? + get() = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull() + set(id) { + accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, id.toString()) + } + + /** + * Read-only flag for the address book itself. + * + * Setting this flag: + * + * - stores the new value in [USER_DATA_READ_ONLY] and + * - sets the read-only flag for all contacts and groups in the address book in the content provider, which will + * prevent non-sync-adapter apps from modifying them. However new entries can still be created, so the address book + * is not really read-only. + * + * Reading this flag returns the stored value from [USER_DATA_READ_ONLY]. + */ + override var readOnly: Boolean + get() = accountManager.getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null + set(readOnly) { + // set read-only flag for address book itself + accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null) + + // update raw contacts + val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0) + provider!!.update(rawContactsSyncUri(), rawContactValues, null, null) + + // update data rows + val dataValues = contentValuesOf(ContactsContract.Data.IS_READ_ONLY to if (readOnly) 1 else 0) + provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) + + // update group rows + val groupValues = contentValuesOf(Groups.GROUP_IS_READ_ONLY to if (readOnly) 1 else 0) + provider!!.update(groupsSyncUri(), groupValues, null, null) + } + + override var lastSyncState: SyncState? + get() = syncState?.let { SyncState.fromString(String(it)) } + set(state) { + syncState = state?.toString()?.toByteArray() + } + + + /* operations on the collection (address book) itself */ + + override fun markNotDirty(flags: Int): Int { + val values = contentValuesOf(LocalContact.COLUMN_FLAGS to flags) + var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null) + + if (includeGroups) { + values.clear() + values.put(LocalGroup.COLUMN_FLAGS, flags) + number += provider!!.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null) + } + + return number + } + + override fun removeNotDirtyMarked(flags: Int): Int { + var number = provider!!.delete(rawContactsSyncUri(), + "NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString())) + + if (includeGroups) + number += provider!!.delete(groupsSyncUri(), + "NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString())) + + return number + } + + /** + * Renames an address book account and moves the contacts and groups (without making them dirty). + * Does not keep user data of the old account, so these have to be set again. + * + * On success, [addressBookAccount] will be updated to the new account name. + * + * _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never + * a moment when both accounts are available. + * + * @param newName the new account name (account type is taken from [addressBookAccount]) + * + * @return whether the account was renamed successfully + */ + internal fun renameAccount(newName: String): Boolean { + val oldAccount = addressBookAccount + logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"") + + // create new account + val newAccount = Account(newName, oldAccount.type) + if (!SystemAccountUtils.createAccount(context, newAccount, Bundle())) + return false + + // move contacts and groups to new account + val batch = ContactsBatchOperation(provider!!) + batch += BatchOperation.CpoBuilder + .newUpdate(groupsSyncUri()) + .withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type)) + .withValue(Groups.ACCOUNT_NAME, newAccount.name) + .withValue(Groups.ACCOUNT_TYPE, newAccount.type) + batch += BatchOperation.CpoBuilder + .newUpdate(rawContactsSyncUri()) + .withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type)) + .withValue(RawContacts.ACCOUNT_NAME, newAccount.name) + .withValue(RawContacts.ACCOUNT_TYPE, newAccount.type) + batch.commit() + + // update AndroidAddressBook.account + addressBookAccount = newAccount + + // delete old account + accountManager.removeAccountExplicitly(oldAccount) + + return true + } + + + /** + * Enables or disables sync on content changes for the address book account based on the current sync + * interval account setting. + */ + fun updateSyncFrameworkSettings() { + val accountSettings = accountSettingsFactory.create(account) + val syncInterval = accountSettings.getSyncInterval(SyncDataType.CONTACTS) + + // Enable/Disable content triggered syncs for the address book account. + if (syncInterval != null) + syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY) + else + syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY) + } + + + /* operations on members (contacts/groups) */ + + override fun findByName(name: String): LocalAddress? { + val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull() + return if (includeGroups) + result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull() + else + result + } + + + /** + * Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0). + * @throws RemoteException on content provider errors + */ + override fun findDeleted() = + if (includeGroups) + findDeletedContacts() + findDeletedGroups() + else + findDeletedContacts() + + fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null) + fun findDeletedGroups() = queryGroups(Groups.DELETED, null) + + /** + * Returns an array of local contacts/groups which have been changed locally (DIRTY != 0). + * @throws RemoteException on content provider errors + */ + override fun findDirty() = + if (includeGroups) + findDirtyContacts() + findDirtyGroups() + else + findDirtyContacts() + fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null) + fun findDirtyGroups() = queryGroups(Groups.DIRTY, null) + + override fun forgetETags() { + if (includeGroups) { + val values = contentValuesOf(AndroidGroup.COLUMN_ETAG to null) + provider!!.update(groupsSyncUri(), values, null, null) + } + val values = contentValuesOf(AndroidContact.COLUMN_ETAG to null) + provider!!.update(rawContactsSyncUri(), values, null, null) + } + + + fun getContactIdsByGroupMembership(groupId: Long): List { + val ids = LinkedList() + provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID), + "(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)", + arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor -> + while (cursor.moveToNext()) + ids += cursor.getLong(0) + } + return ids + } + + fun getContactUidFromId(contactId: Long): String? { + provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID), + "${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.getString(0) + } + return null + } + + + /* special group operations */ + + /** + * Finds the first group with the given title. If there is no group with this + * title, a new group is created. + * @param title title of the group to look for + * @return id of the group with given title + * @throws RemoteException on content provider errors + */ + fun findOrCreateGroup(title: String): Long { + provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID), + "${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.getLong(0) + } + + val values = contentValuesOf(Groups.TITLE to title) + val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group") + return ContentUris.parseId(uri) + } + + fun removeEmptyGroups() { + // find groups without members + /** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */ + queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group -> + logger.log(Level.FINE, "Deleting group", group) + group.delete() + } + } + + + companion object { + + const val USER_DATA_ACCOUNT_NAME = "account_name" + const val USER_DATA_ACCOUNT_TYPE = "account_type" + + /** + * ID of the corresponding database [at.bitfire.davdroid.db.Collection]. + * + * User data of the address book account (Long). + */ + const val USER_DATA_COLLECTION_ID = "collection_id" + + /** + * Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag). + * + * User data of the address book account (Boolean). + */ + const val USER_DATA_READ_ONLY = "read_only" + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt new file mode 100644 index 0000000..6fbb821 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt @@ -0,0 +1,269 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.content.ContentProviderClient +import android.content.Context +import android.provider.ContactsContract +import androidx.annotation.OpenForTesting +import androidx.annotation.VisibleForTesting +import androidx.core.content.contentValuesOf +import androidx.core.os.bundleOf +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.account.SystemAccountUtils +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import at.bitfire.davdroid.util.DavUtils.lastSegment +import com.google.common.base.CharMatcher +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class LocalAddressBookStore @Inject constructor( + @ApplicationContext private val context: Context, + private val localAddressBookFactory: LocalAddressBook.Factory, + private val logger: Logger, + private val serviceRepository: DavServiceRepository, + private val settings: SettingsManager +): LocalDataStore { + + override val authority: String + get() = ContactsContract.AUTHORITY + + /** whether a (usually managed) setting wants all address-books to be read-only **/ + val forceAllReadOnly: Boolean + get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS) + + + /** + * Assembles a name for the address book (account) from its corresponding database [Collection]. + * + * The address book account name contains + * + * - the collection display name or last URL path segment (filtered for dangerous special characters) + * - the actual account name + * - the collection ID, to make it unique. + * + * @param info Collection to take info from + */ + fun accountName(info: Collection): String { + // Name of address book is given collection display name, otherwise the last URL path segment + var name = info.displayName.takeIf { !it.isNullOrEmpty() } ?: info.url.lastSegment + + // Remove ISO control characters + SQL problematic characters + name = CharMatcher + .javaIsoControl() + .or(CharMatcher.anyOf("`'\"")) + .removeFrom(name) + + // Add the actual account name to the address book account name + val sb = StringBuilder(name) + serviceRepository.getBlocking(info.serviceId)?.let { service -> + sb.append(" (${service.accountName})") + } + // Add the collection ID for uniqueness + sb.append(" #${info.id}") + return sb.toString() + } + + override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try { + context.contentResolver.acquireContentProviderClient(authority) + } catch (e: SecurityException) { + if (throwOnMissingPermissions) + throw e + else + /* return */ null + } + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? { + val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + val name = accountName(fromCollection) + val addressBookAccount = createAddressBookAccount( + account = account, + name = name, + id = fromCollection.id + ) ?: return null + + val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider) + + // update settings + addressBook.updateSyncFrameworkSettings() + addressBook.settings = contactsProviderSettings + addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly) + + return addressBook + } + + @OpenForTesting + internal fun createAddressBookAccount(account: Account, name: String, id: Long): Account? { + // create address book account with reference to account, collection ID and URL + val addressBookAccount = Account(name, context.getString(R.string.account_type_address_book)) + val userData = bundleOf( + LocalAddressBook.USER_DATA_ACCOUNT_NAME to account.name, + LocalAddressBook.USER_DATA_ACCOUNT_TYPE to account.type, + LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString() + ) + if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) { + logger.warning("Couldn't create address book account: $addressBookAccount") + return null + } + + return addressBookAccount + } + + override fun getAll(account: Account, provider: ContentProviderClient): List = + getAddressBookAccounts(account).map { addressBookAccount -> + localAddressBookFactory.create(account, addressBookAccount, provider) + } + + override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) { + var currentAccount = localCollection.addressBookAccount + logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection") + + // Update the account name + val newAccountName = accountName(fromCollection) + if (currentAccount.name != newAccountName) { + // rename, move contacts/groups and update [AndroidAddressBook.]account + localCollection.renameAccount(newAccountName) + currentAccount = Account(newAccountName, currentAccount.type) + } + + // Update the account user data + val accountManager = AccountManager.get(context) + accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, localCollection.account.name) + accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, localCollection.account.type) + accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, fromCollection.id.toString()) + + // Set contacts provider settings + localCollection.settings = contactsProviderSettings + + // Update force read only + val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly) + if (nowReadOnly != localCollection.readOnly) { + logger.info("Address book has changed to read-only = $nowReadOnly") + localCollection.readOnly = nowReadOnly + } + + // Update automatic synchronization + localCollection.updateSyncFrameworkSettings() + } + + /** + * Updates address books which are assigned to [oldAccount] so that they're assigned to [newAccount] instead. + * + * @param oldAccount The old account + * @param newAccount The new account + */ + override fun updateAccount(oldAccount: Account, newAccount: Account) { + val accountManager = AccountManager.get(context) + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)) + .filter { addressBookAccount -> + accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == oldAccount.name && + accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == oldAccount.type + } + .forEach { addressBookAccount -> + accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, newAccount.name) + accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, newAccount.type) + } + } + + override fun delete(localCollection: LocalAddressBook) { + val accountManager = AccountManager.get(context) + accountManager.removeAccountExplicitly(localCollection.addressBookAccount) + } + + /** + * Deletes a [LocalAddressBook] based on its corresponding database collection. + * + * @param id [Collection.id] to look for + */ + fun deleteByCollectionId(id: Long) { + val accountManager = AccountManager.get(context) + val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account -> + accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLongOrNull() == id + } + if (addressBookAccount != null) + accountManager.removeAccountExplicitly(addressBookAccount) + } + + /** + * Returns all address book accounts that belong to the given account. + * + * @param account Account which has the address books. + * @return List of address book accounts. + */ + fun getAddressBookAccounts(account: Account): List = + AccountManager.get(context).let { accountManager -> + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)) + .filter { addressBookAccount -> + account.name == accountManager.getUserData( + addressBookAccount, + LocalAddressBook.USER_DATA_ACCOUNT_NAME + ) && account.type == accountManager.getUserData( + addressBookAccount, + LocalAddressBook.USER_DATA_ACCOUNT_TYPE + ) + } + } + + /** + * Returns all address book accounts that belong to the given account in a flow. + * + * @param account Account which has the address books. + * @return List of address book accounts as flow. + */ + fun getAddressBookAccountsFlow(account: Account): Flow> = callbackFlow { + val accountManager = AccountManager.get(context) + val listener = OnAccountsUpdateListener { accounts -> + trySend(getAddressBookAccounts(account)) + } + accountManager.addOnAccountsUpdatedListener( + /* listener = */ listener, + /* handler = */ null, + /* updateImmediately = */ true + ) + awaitClose { accountManager.removeOnAccountsUpdatedListener(listener) } + } + + + companion object { + + /** + * Contacts Provider Settings (equal for every address book) + */ + val contactsProviderSettings + get() = contentValuesOf( + // SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable. + ContactsContract.Settings.SHOULD_SYNC to 1, + + // UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems). + ContactsContract.Settings.UNGROUPED_VISIBLE to 1 + ) + + /** + * Determines whether the address book should be set to read-only. + * + * @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information + * @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege) + */ + @VisibleForTesting + internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean = + info.readOnly() || forceAllReadOnly + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt new file mode 100644 index 0000000..2f0318b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -0,0 +1,235 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.ContentUris +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2 +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar +import at.bitfire.synctools.storage.calendar.CalendarBatchOperation +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.util.LinkedList +import java.util.logging.Logger + +/** + * Application-specific subclass of [AndroidCalendar] for local calendars. + * + * [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]). + */ +class LocalCalendar @AssistedInject constructor( + @Assisted internal val androidCalendar: AndroidCalendar, + private val logger: Logger +) : LocalCollection { + + @AssistedFactory + interface Factory { + fun create(calendar: AndroidCalendar): LocalCalendar + } + + + // properties + + override val dbCollectionId: Long? + get() = androidCalendar.syncId?.toLongOrNull() + + override val tag: String + get() = "events-${androidCalendar.account.name}-${androidCalendar.id}" + + override val title: String + get() = androidCalendar.displayName ?: androidCalendar.id.toString() + + override val readOnly + get() = androidCalendar.accessLevel <= Calendars.CAL_ACCESS_READ + + override var lastSyncState: SyncState? + get() = androidCalendar.readSyncState()?.let { + SyncState.fromString(it) + } + set(state) { + androidCalendar.writeSyncState(state.toString()) + } + + private val recurringCalendar = AndroidRecurringCalendar(androidCalendar) + + + fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) { + val mapped = LegacyAndroidEventBuilder2( + calendar = androidCalendar, + event = event, + syncId = fileName, + eTag = eTag, + scheduleTag = scheduleTag, + flags = flags + ).build() + recurringCalendar.addEventAndExceptions(mapped) + } + + override fun findDeleted(): List { + val result = LinkedList() + androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity -> + result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity)) + } + return result + } + + override fun findDirty(): List { + val dirty = LinkedList() + + /* + * RFC 5545 3.8.7.4. Sequence Number + * When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's" + * CUA each time the "Organizer" makes a significant revision to the calendar component. + */ + androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values -> + dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values)) + } + + return dirty + } + + override fun findByName(name: String) = + androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let { + LocalEvent(recurringCalendar, it) + } + + override fun markNotDirty(flags: Int) = + androidCalendar.updateEventRows( + contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags), + // `dirty` can be 0, 1, or null. "NOT dirty" is not enough. + """ + ${Events.CALENDAR_ID}=? + AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0) + AND ${Events.ORIGINAL_ID} IS NULL + """.trimIndent(), + arrayOf(androidCalendar.id.toString()) + ) + + override fun removeNotDirtyMarked(flags: Int): Int { + // list all non-dirty events with the given flags and delete every row + its exceptions + val batch = CalendarBatchOperation(androidCalendar.client) + androidCalendar.iterateEventRows( + arrayOf(Events._ID), + // `dirty` can be 0, 1, or null. "NOT dirty" is not enough. + """ + ${Events.CALENDAR_ID}=? + AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0) + AND ${Events.ORIGINAL_ID} IS NULL + AND ${AndroidEvent2.COLUMN_FLAGS}=? + """.trimIndent(), + arrayOf(androidCalendar.id.toString(), flags.toString()) + ) { values -> + val id = values.getAsLong(Events._ID) + + // delete event and possible exceptions (content provider doesn't delete exceptions itself) + batch += BatchOperation.CpoBuilder + .newDelete(androidCalendar.eventsUri) + .withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())) + } + return batch.commit() + } + + override fun forgetETags() { + androidCalendar.updateEventRows( + contentValuesOf(AndroidEvent2.COLUMN_ETAG to null), + "${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString()) + ) + } + + + fun processDirtyExceptions() { + // process deleted exceptions + logger.info("Processing deleted exceptions") + + androidCalendar.iterateEventRows( + arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE), + "${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL", + arrayOf(androidCalendar.id.toString()) + ) { values -> + logger.fine("Found deleted exception, removing and re-scheduling original event (if available)") + + val id = values.getAsLong(Events._ID) // can't be null (by definition) + val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query) + + val batch = CalendarBatchOperation(androidCalendar.client) + + // enqueue: increase sequence of main event + val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE)) + val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0 + + batch += BatchOperation.CpoBuilder + .newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account)) + .withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1) + .withValue(Events.DIRTY, 1) + + // completely remove deleted exception + batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account)) + batch.commit() + } + + // process dirty exceptions + logger.info("Processing dirty exceptions") + androidCalendar.iterateEventRows( + arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE), + "${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL", + arrayOf(androidCalendar.id.toString()) + ) { values -> + logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule") + + val id = values.getAsLong(Events._ID) // can't be null (by definition) + val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query) + val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0 + + val batch = CalendarBatchOperation(androidCalendar.client) + + // enqueue: set original event to DIRTY + batch += BatchOperation.CpoBuilder + .newUpdate(androidCalendar.eventUri(originalID)) + .withValue(Events.DIRTY, 1) + + // enqueue: increase exception SEQUENCE and set DIRTY to 0 + batch += BatchOperation.CpoBuilder + .newUpdate(androidCalendar.eventUri(id)) + .withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1) + .withValue(Events.DIRTY, 0) + + batch.commit() + } + } + + /** + * Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted" + * + * @return number of affected events + */ + fun deleteDirtyEventsWithoutInstances() { + // Iterate dirty main events without exceptions + androidCalendar.iterateEventRows( + arrayOf(Events._ID), + "${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", + null + ) { values -> + val eventId = values.getAsLong(Events._ID) + + // get number of instances + val numEventInstances = androidCalendar.numInstances(eventId) + + // delete event if there are no instances + if (numEventInstances == 0) { + logger.fine("Marking event #$eventId without instances as deleted") + androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1)) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt new file mode 100644 index 0000000..21eb8ed --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt @@ -0,0 +1,154 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.content.Context +import android.provider.CalendarContract +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.Reminders +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class LocalCalendarStore @Inject constructor( + @ApplicationContext private val context: Context, + private val accountSettingsFactory: AccountSettings.Factory, + private val localCalendarFactory: LocalCalendar.Factory, + private val logger: Logger, + private val serviceRepository: DavServiceRepository +): LocalDataStore { + + override val authority: String + get() = CalendarContract.AUTHORITY + + override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try { + context.contentResolver.acquireContentProviderClient(authority) + } catch (e: SecurityException) { + if (throwOnMissingPermissions) + throw e + else + /* return */ null + } + + override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? { + val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + // If the collection doesn't have a color, use a default color. + val collectionWithColor = + if (fromCollection.color != null) + fromCollection + else + fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA) + + val values = valuesFromCollectionInfo( + info = collectionWithColor, + withColor = true + ).apply { + // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. + put(Calendars.ACCOUNT_NAME, account.name) + put(Calendars.ACCOUNT_TYPE, account.type) + + // Email address for scheduling. Used by the calendar provider to determine whether the + // user is ORGANIZER/ATTENDEE for a certain event. + put(Calendars.OWNER_ACCOUNT, account.name) + + // flag as visible & syncable at creation, might be changed by user at any time + put(Calendars.VISIBLE, 1) + put(Calendars.SYNC_EVENTS, 1) + } + + logger.log(Level.INFO, "Adding local calendar", values) + val provider = AndroidCalendarProvider(account, client) + return localCalendarFactory.create(provider.createAndGetCalendar(values)) + } + + override fun getAll(account: Account, client: ContentProviderClient) = + AndroidCalendarProvider(account, client) + .findCalendars("${Calendars.SYNC_EVENTS}!=0", null) + .map { localCalendarFactory.create(it) } + + override fun update(client: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) { + val accountSettings = accountSettingsFactory.create(localCollection.androidCalendar.account) + val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()) + + logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values) + val androidCalendar = localCollection.androidCalendar + val provider = AndroidCalendarProvider(androidCalendar.account, client) + provider.updateCalendar(androidCalendar.id, values) + } + + private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { + val values = contentValuesOf( + Calendars._SYNC_ID to info.id, + Calendars.CALENDAR_DISPLAY_NAME to + if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName, + + Calendars.ALLOWED_AVAILABILITY to arrayOf( + Events.AVAILABILITY_BUSY, + Events.AVAILABILITY_FREE + ).joinToString(",") { it.toString() }, + + Calendars.ALLOWED_ATTENDEE_TYPES to arrayOf( + Attendees.TYPE_NONE, + Attendees.TYPE_OPTIONAL, + Attendees.TYPE_REQUIRED, + Attendees.TYPE_RESOURCE + ).joinToString(",") { it.toString() }, + + Calendars.ALLOWED_REMINDERS to arrayOf( + Reminders.METHOD_DEFAULT, + Reminders.METHOD_ALERT, + Reminders.METHOD_EMAIL + ).joinToString(",") { it.toString() }, + ) + + if (withColor && info.color != null) + values.put(Calendars.CALENDAR_COLOR, info.color) + + if (info.privWriteContent && !info.forceReadOnly) { + values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) + values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1) + values.put(Calendars.CAN_ORGANIZER_RESPOND, 1) + } else + values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) + + info.timezoneId?.let { tzId -> + values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId)) + } + + return values + } + + override fun updateAccount(oldAccount: Account, newAccount: Account) { + val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name) + val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount) + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { + it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) + } + } + + override fun delete(localCollection: LocalCalendar) { + logger.log(Level.INFO, "Deleting local calendar", localCollection) + localCollection.androidCalendar.delete() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt new file mode 100644 index 0000000..5a4e956 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt @@ -0,0 +1,76 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +interface LocalCollection> { + + /** a tag that uniquely identifies the collection (DAVx5-wide) */ + val tag: String + + /** ID of the collection in the database (corresponds to [at.bitfire.davdroid.db.Collection.id]) */ + val dbCollectionId: Long? + + /** collection title (used for user notifications etc.) **/ + val title: String + + var lastSyncState: SyncState? + + /** + * Whether the collection should be treated as read-only on sync. + * Stops uploading dirty events (Server side changes are still downloaded). + */ + val readOnly: Boolean + + /** + * Finds local resources of this collection which have been marked as *deleted* by the user + * or an app acting on their behalf. + * + * @return list of resources marked as *deleted* + */ + fun findDeleted(): List + + /** + * Finds local resources of this collection which have been marked as *dirty*, i.e. resources + * which have been modified by the user or an app acting on their behalf. + * + * @return list of resources marked as *dirty* + */ + fun findDirty(): List + + /** + * Finds a local resource of this collection with a given file name. (File names are assigned + * by the sync adapter.) + * + * @param name file name to look for + * @return resource with the given name, or null if none + */ + fun findByName(name: String): T? + + /** + * Updates the flags value for entries which are not dirty. + * + * @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]]) + * + * @return number of marked entries + */ + fun markNotDirty(flags: Int): Int + + /** + * Removes entries which are not dirty with a given flag combination. + * + * @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]], + * all entries with exactly this flag will be removed) + * + * @return number of removed entries + */ + fun removeNotDirtyMarked(flags: Int): Int + + + /** + * Forgets the ETags of all members so that they will be reloaded from the server during sync. + */ + fun forgetETags() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt new file mode 100644 index 0000000..6f7d2b9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt @@ -0,0 +1,239 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.RemoteException +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import android.provider.ContactsContract.RawContacts +import android.provider.ContactsContract.RawContacts.Data +import android.provider.ContactsContract.RawContacts.getContactLookupUri +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler +import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder +import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler +import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder +import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.ContactsBatchOperation +import at.bitfire.vcard4android.AndroidAddressBook +import at.bitfire.vcard4android.AndroidContact +import at.bitfire.vcard4android.AndroidContactFactory +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import com.google.common.base.Ascii +import com.google.common.base.MoreObjects +import java.io.FileNotFoundException +import java.util.Optional +import java.util.UUID +import kotlin.jvm.optionals.getOrNull + +class LocalContact: AndroidContact, LocalAddress { + + companion object { + const val COLUMN_FLAGS = RawContacts.SYNC4 + const val COLUMN_HASHCODE = RawContacts.SYNC3 + } + + override val addressBook: LocalAddressBook + get() = super.addressBook as LocalAddressBook + + internal val cachedGroupMemberships = HashSet() + internal val groupMemberships = HashSet() + + override val scheduleTag: String? + get() = null + + override var flags: Int = 0 + + + constructor(addressBook: LocalAddressBook, values: ContentValues): super(addressBook, values) { + flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 + } + + constructor(addressBook: LocalAddressBook, contact: Contact, fileName: String?, eTag: String?, _flags: Int): super(addressBook, contact, fileName, eTag) { + flags = _flags + } + + init { + processor.registerHandler(CachedGroupMembershipHandler(this)) + processor.registerHandler(GroupMembershipHandler(this)) + processor.registerHandler(UnknownPropertiesHandler) + processor.registerBuilderFactory(GroupMembershipBuilder.Factory(addressBook)) + processor.registerBuilderFactory(UnknownPropertiesBuilder.Factory) + } + + + override fun prepareForUpload(): String { + val contact = getContact() + val uid: String = contact.uid ?: run { + // generate new UID + val newUid = UUID.randomUUID().toString() + + // update in contacts provider + val values = contentValuesOf(COLUMN_UID to newUid) + addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + + // update this event + contact.uid = newUid + + newUid + } + + return "$uid.vcf" + } + + /** + * Clears cached [contact] so that the next read of [contact] will query the content provider again. + */ + fun clearCachedContact() { + _contact = null + } + + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { + if (scheduleTag != null) + throw IllegalArgumentException("Contacts must not have a Schedule-Tag") + + val values = ContentValues(4) + if (fileName.isPresent) + values.put(COLUMN_FILENAME, fileName.get()) + values.put(COLUMN_ETAG, eTag) + values.put(RawContacts.DIRTY, 0) + + // Android 7 workaround + addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values) + + addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + + if (fileName.isPresent) + this.fileName = fileName.get() + this.eTag = eTag + } + + fun resetDirty() { + val values = contentValuesOf(RawContacts.DIRTY to 0) + addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + } + + override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { + this.fileName = fileName + this.eTag = eTag + this.flags = flags + + // processes this.{fileName, eTag, flags} and resets DIRTY flag + update(data) + } + + override fun updateFlags(flags: Int) { + val values = contentValuesOf(COLUMN_FLAGS to flags) + addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + + this.flags = flags + } + + override fun deleteLocal() { + delete() + } + + override fun resetDeleted() { + val values = contentValuesOf(ContactsContract.Groups.DELETED to 0) + addressBook.provider!!.update(rawContactSyncURI(), values, null, null) + } + + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("flags", flags) + .add("contact", + try { + Ascii.truncate(getContact().toString(), 1000, "…") + } catch (e: Exception) { + e + } + ).toString() + + override fun getViewUri(context: Context): Uri? = + id?.let { idNotNull -> + getContactLookupUri( + context.contentResolver, + ContentUris.withAppendedId(RawContacts.CONTENT_URI, idNotNull) + ) + } + + + fun addToGroup(batch: ContactsBatchOperation, groupID: Long) { + batch += BatchOperation.CpoBuilder + .newInsert(dataSyncURI()) + .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) + .withValue(GroupMembership.RAW_CONTACT_ID, id) + .withValue(GroupMembership.GROUP_ROW_ID, groupID) + groupMemberships += groupID + + batch += BatchOperation.CpoBuilder + .newInsert(dataSyncURI()) + .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + .withValue(CachedGroupMembership.RAW_CONTACT_ID, id) + .withValue(CachedGroupMembership.GROUP_ID, groupID) + cachedGroupMemberships += groupID + } + + fun removeGroupMemberships(batch: BatchOperation) { + batch += BatchOperation.CpoBuilder + .newDelete(dataSyncURI()) + .withSelection( + "${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)", + arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + ) + groupMemberships.clear() + cachedGroupMemberships.clear() + } + + /** + * Returns the IDs of all groups the contact was member of (cached memberships). + * Cached memberships are kept in sync with memberships by DAVx5 and are used to determine + * whether a membership has been deleted/added when a raw contact is dirty. + * @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty) + * @throws FileNotFoundException if the current contact can't be found + * @throws RemoteException on contacts provider errors + */ + fun getCachedGroupMemberships(): Set { + getContact() + return cachedGroupMemberships + } + + /** + * Returns the IDs of all groups the contact is member of. + * @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty) + * @throws FileNotFoundException if the current contact can't be found + * @throws RemoteException on contacts provider errors + */ + fun getGroupMemberships(): Set { + getContact() + return groupMemberships + } + + + // data rows + + override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) { + builder.withValue(COLUMN_FLAGS, flags) + super.buildContact(builder, update) + } + + + // factory + + object Factory: AndroidContactFactory { + override fun fromProvider(addressBook: AndroidAddressBook, values: ContentValues) = + LocalContact(addressBook as LocalAddressBook, values) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt new file mode 100644 index 0000000..8e8a7e4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import at.bitfire.davdroid.db.Collection + +/** + * Represents a local data store for a specific collection type. + * Manages creation, update, and deletion of collections of the given type. + */ +interface LocalDataStore> { + + /** + * Content provider authority for the data store. + */ + val authority: String + + /** + * Acquires a content provider client for the data store. The result of this call + * should be passed to all other methods of this class. + * + * **The caller is responsible for closing the content provider client!** + * + * @param throwOnMissingPermissions If `true`, the function will throw [SecurityException] if permissions are not granted. + * + * @return the content provider client, or `null` if the content provider could not be acquired (or permissions are not + * granted and [throwOnMissingPermissions] is `false`) + * + * @throws SecurityException on missing permissions + */ + fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient? + + /** + * Creates a new local collection from the given (remote) collection info. + * + * @param client the content provider client + * @param fromCollection collection info + * + * @return the new local collection, or `null` if creation failed + */ + fun create(client: ContentProviderClient, fromCollection: Collection): T? + + /** + * Returns all local collections of the data store, including those which don't have a corresponding remote + * [Collection] entry. + * + * @param account the account that the data store is associated with + * @param client the content provider client + * + * @return a list of all local collections + */ + fun getAll(account: Account, client: ContentProviderClient): List + + /** + * Updates the local collection with the data from the given (remote) collection info. + * + * @param client the content provider client + * @param localCollection the local collection to update + * @param fromCollection collection info + */ + fun update(client: ContentProviderClient, localCollection: T, fromCollection: Collection) + + /** + * Deletes the local collection. + * + * @param localCollection the local collection to delete + */ + fun delete(localCollection: T) + + /** + * Changes the account assigned to the containing data to another one. + * + * @param oldAccount The old account. + * @param newAccount The new account. + */ + fun updateAccount(oldAccount: Account, newAccount: Account) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt new file mode 100644 index 0000000..549f998 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt @@ -0,0 +1,197 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.ContentUris +import android.content.Context +import android.provider.CalendarContract +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.LegacyAndroidCalendar +import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2 +import at.bitfire.synctools.storage.LocalStorageException +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar +import com.google.common.base.Ascii +import com.google.common.base.MoreObjects +import java.util.Optional +import java.util.UUID + +class LocalEvent( + val recurringCalendar: AndroidRecurringCalendar, + val androidEvent: AndroidEvent2 +) : LocalResource { + + override val id: Long + get() = androidEvent.id + + override val fileName: String? + get() = androidEvent.syncId + + override val eTag: String? + get() = androidEvent.eTag + + override val scheduleTag: String? + get() = androidEvent.scheduleTag + + override val flags: Int + get() = androidEvent.flags + + + override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { + val eventAndExceptions = LegacyAndroidEventBuilder2( + calendar = androidEvent.calendar, + event = data, + syncId = fileName, + eTag = eTag, + scheduleTag = scheduleTag, + flags = flags + ).build() + recurringCalendar.updateEventAndExceptions(id, eventAndExceptions) + } + + + private var _event: Event? = null + /** + * Retrieves the event from the content provider and converts it to a legacy data object. + * + * Caches the result: the content provider is only queried at the first call and then + * this method always returns the same object. + * + * @throws LocalStorageException if there is no local event with the ID from [androidEvent] + */ + @Synchronized + fun getCachedEvent(): Event { + _event?.let { return it } + + val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar) + val event = legacyCalendar.getEvent(androidEvent.id) + ?: throw LocalStorageException("Event ${androidEvent.id} not found") + + _event = event + return event + } + + /** + * Generates the [Event] that should actually be uploaded: + * + * 1. Takes the [getCachedEvent]. + * 2. Calculates the new SEQUENCE. + * + * _Note: This method currently modifies the object returned by [getCachedEvent], but + * this may change in the future._ + * + * @return data object that should be used for uploading + */ + fun eventToUpload(): Event { + val event = getCachedEvent() + + val nonGroupScheduled = event.attendees.isEmpty() + val weAreOrganizer = event.isOrganizer == true + + // Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence): + // - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default). + // - If it's non-null, the event already exists on the server, so increase by one. + val sequence = event.sequence + if (sequence != null && (nonGroupScheduled || weAreOrganizer)) + event.sequence = sequence + 1 + + return event + } + + /** + * Updates the SEQUENCE of the event in the content provider. + * + * @param sequence new sequence value + */ + fun updateSequence(sequence: Int?) { + androidEvent.update(contentValuesOf( + AndroidEvent2.COLUMN_SEQUENCE to sequence + )) + } + + + /** + * Creates and sets a new UID in the calendar provider, if no UID is already set. + * It also returns the desired file name for the event for further processing in the sync algorithm. + * + * @return file name to use at upload + */ + override fun prepareForUpload(): String { + // make sure that UID is set + val uid: String = getCachedEvent().uid ?: run { + // generate new UID + val newUid = UUID.randomUUID().toString() + + // persist to calendar provider + val values = contentValuesOf(Events.UID_2445 to newUid) + androidEvent.update(values) + + // update in cached event data object + getCachedEvent().uid = newUid + + newUid + } + + val uidIsGoodFilename = uid.all { char -> + // see RFC 2396 2.2 + char.isLetterOrDigit() || arrayOf( // allow letters and digits + ';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?' + '-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters + ).contains(char) + } + return if (uidIsGoodFilename) + "$uid.ics" // use UID as file name + else + "${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead + } + + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { + val values = contentValuesOf( + Events.DIRTY to 0, + AndroidEvent2.COLUMN_ETAG to eTag, + AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag + ) + if (fileName.isPresent) + values.put(Events._SYNC_ID, fileName.get()) + androidEvent.update(values) + } + + override fun updateFlags(flags: Int) { + androidEvent.update(contentValuesOf( + AndroidEvent2.COLUMN_FLAGS to flags + )) + } + + override fun deleteLocal() { + recurringCalendar.deleteEventAndExceptions(id) + } + + override fun resetDeleted() { + androidEvent.update(contentValuesOf( + Events.DELETED to 0 + )) + } + + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("scheduleTag", scheduleTag) + .add("flags", flags) + .add("event", + try { + Ascii.truncate(getCachedEvent().toString(), 1000, "…") + } catch (e: Exception) { + e + } + ).toString() + + override fun getViewUri(context: Context) = + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt new file mode 100644 index 0000000..9413c2a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt @@ -0,0 +1,313 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.RemoteException +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import android.provider.ContactsContract.Groups +import android.provider.ContactsContract.RawContacts +import android.provider.ContactsContract.RawContacts.Data +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS +import at.bitfire.davdroid.util.trimToNull +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.ContactsBatchOperation +import at.bitfire.vcard4android.AndroidAddressBook +import at.bitfire.vcard4android.AndroidContact +import at.bitfire.vcard4android.AndroidGroup +import at.bitfire.vcard4android.AndroidGroupFactory +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import com.google.common.base.MoreObjects +import java.util.LinkedList +import java.util.Optional +import java.util.UUID +import java.util.logging.Logger +import kotlin.jvm.optionals.getOrNull + +class LocalGroup: AndroidGroup, LocalAddress { + + companion object { + + private val logger: Logger + get() = Logger.getGlobal() + + const val COLUMN_FLAGS = Groups.SYNC4 + + /** List of member UIDs, as sent by server. This list will be used to establish + * the group memberships when all groups and contacts have been synchronized. + * Use [PendingMemberships] to create/read the list. */ + const val COLUMN_PENDING_MEMBERS = Groups.SYNC3 + + /** + * Processes all groups with non-null [COLUMN_PENDING_MEMBERS]: the pending memberships + * are applied (if possible) to keep cached memberships in sync. + * + * @param addressBook address book to take groups from + */ + fun applyPendingMemberships(addressBook: LocalAddressBook) { + logger.info("Assigning memberships of contact groups") + + addressBook.allGroups { group -> + val groupId = group.id!! + val pendingMemberUids = group.pendingMemberships.toMutableSet() + val batch = ContactsBatchOperation(addressBook.provider!!) + + // required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed + val changeContactIDs = HashSet() + + // process members which are currently in this group, but shouldn't be + for (currentMemberId in addressBook.getContactIdsByGroupMembership(groupId)) { + val uid = addressBook.getContactUidFromId(currentMemberId) ?: continue + + if (!pendingMemberUids.contains(uid)) { + logger.fine("$currentMemberId removed from group $groupId; removing group membership") + val currentMember = addressBook.findContactById(currentMemberId) + currentMember.removeGroupMemberships(batch) + + // Android 7 hack + changeContactIDs += currentMemberId + } + + // UID is processed, remove from pendingMembers + pendingMemberUids -= uid + } + // now pendingMemberUids contains all UIDs which are not assigned yet + + // process members which should be in this group, but aren't + for (missingMemberUid in pendingMemberUids) { + val missingMember = addressBook.findContactByUid(missingMemberUid) + if (missingMember == null) { + logger.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring") + continue + } + + logger.fine("Assigning member $missingMember to group $groupId") + missingMember.addToGroup(batch, groupId) + + // Android 7 hack + changeContactIDs += missingMember.id!! + } + + addressBook.dirtyVerifier.getOrNull()?.let { verifier -> + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + changeContactIDs + .map { id -> addressBook.findContactById(id) } + .forEach { contact -> + verifier.updateHashCode(contact, batch) + } + } + + batch.commit() + } + } + + } + + + override var scheduleTag: String? + get() = null + set(_) = throw NotImplementedError() + + override var flags: Int = 0 + + var pendingMemberships = setOf() + + + constructor(addressBook: AndroidAddressBook, values: ContentValues) : super(addressBook, values) { + flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 + values.getAsString(COLUMN_PENDING_MEMBERS)?.let { members -> + pendingMemberships = PendingMemberships.fromString(members).uids + } + } + + constructor(addressBook: AndroidAddressBook, contact: Contact, fileName: String?, eTag: String?, flags: Int) + : super(addressBook, contact, fileName, eTag) { + this.flags = flags + } + + + override fun contentValues(): ContentValues { + val values = super.contentValues() + values.put(COLUMN_FLAGS, flags) + values.put(COLUMN_PENDING_MEMBERS, PendingMemberships(getContact().members).toString()) + return values + } + + + override fun prepareForUpload(): String { + var uid: String? = null + addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + uid = cursor.getString(0).trimToNull() + } + + if (uid == null) { + // generate new UID + uid = UUID.randomUUID().toString() + + val values = contentValuesOf(AndroidContact.COLUMN_UID to uid) + addressBook.provider!!.update(groupSyncUri(), values, null, null) + + _contact?.uid = uid + } + + return "$uid.vcf" + } + + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { + if (scheduleTag != null) + throw IllegalArgumentException("Contact groups must not have a Schedule-Tag") + val id = requireNotNull(id) + + val values = ContentValues(3) + if (fileName.isPresent) + values.put(COLUMN_FILENAME, fileName.get()) + values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated + values.put(Groups.DIRTY, 0) + update(values) + + if (fileName.isPresent) + this.fileName = fileName.get() + this.eTag = null + + // update cached group memberships + val batch = ContactsBatchOperation(addressBook.provider!!) + + // delete old cached group memberships + batch += BatchOperation.CpoBuilder + .newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) + .withSelection( + CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?", + arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString()) + ) + + // insert updated cached group memberships + for (member in getMembers()) + batch += BatchOperation.CpoBuilder + .newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) + .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) + .withValue(CachedGroupMembership.RAW_CONTACT_ID, member) + .withValue(CachedGroupMembership.GROUP_ID, id) + + batch.commit() + } + + /** + * Marks all members of the current group as dirty. + */ + fun markMembersDirty() { + val batch = ContactsBatchOperation(addressBook.provider!!) + + for (member in getMembers()) + batch += BatchOperation.CpoBuilder + .newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member))) + .withValue(RawContacts.DIRTY, 1) + + batch.commit() + } + + override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { + this.fileName = fileName + this.eTag = eTag + + // processes this.{fileName, eTag, flags} and resets DIRTY flag + update(data) + } + + override fun updateFlags(flags: Int) { + val values = contentValuesOf(COLUMN_FLAGS to flags) + addressBook.provider!!.update(groupSyncUri(), values, null, null) + + this.flags = flags + } + + override fun deleteLocal() { + delete() + } + + override fun resetDeleted() { + val values = contentValuesOf(Groups.DELETED to 0) + addressBook.provider!!.update(groupSyncUri(), values, null, null) + } + + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("flags", flags) + .add("contact", + try { + getContact().toString() + } catch (e: Exception) { + e + } + ).toString() + + override fun getViewUri(context: Context) = null + + + // helpers + + private fun groupSyncUri(): Uri { + val id = requireNotNull(id) + return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id) + } + + /** + * Lists all members of this group. + * @return list of all members' raw contact IDs + * @throws RemoteException on contact provider errors + */ + internal fun getMembers(): List { + val id = requireNotNull(id) + val members = LinkedList() + addressBook.provider!!.query( + addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI), + arrayOf(Data.RAW_CONTACT_ID), + "${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?", + arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()), + null + )?.use { cursor -> + while (cursor.moveToNext()) + members += cursor.getLong(0) + } + return members + } + + + // helper class for COLUMN_PENDING_MEMBERSHIPS blob + + class PendingMemberships( + /** list of member UIDs that shall be assigned **/ + val uids: Set + ) { + + companion object { + const val SEPARATOR = '\n' + + fun fromString(value: String) = + PendingMemberships(value.split(SEPARATOR).toSet()) + } + + override fun toString() = uids.joinToString(SEPARATOR.toString()) + + } + + + // factory + + object Factory: AndroidGroupFactory { + override fun fromProvider(addressBook: AndroidAddressBook, values: ContentValues) = + LocalGroup(addressBook, values) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt new file mode 100644 index 0000000..970bf7b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt @@ -0,0 +1,84 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.JtxCollectionFactory +import at.bitfire.ical4android.JtxICalObject + +/** + * Application-specific implementation for jtx collections. + * + * [at.techbee.jtx.JtxContract.JtxCollection.SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]). + */ +class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long): + JtxCollection(account, client, LocalJtxICalObject.Factory, id), + LocalCollection{ + + override val readOnly: Boolean + get() = throw NotImplementedError() + + override val tag: String + get() = "jtx-${account.name}-$id" + + override val dbCollectionId: Long? + get() = syncId + + override val title: String + get() = displayname ?: id.toString() + + override var lastSyncState: SyncState? + get() = SyncState.fromString(syncstate) + set(value) { syncstate = value.toString() } + + + override fun findDeleted(): List { + val values = queryDeletedICalObjects() + val localJtxICalObjects = mutableListOf() + values.forEach { + localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it)) + } + return localJtxICalObjects + } + + override fun findDirty(): List { + val values = queryDirtyICalObjects() + val localJtxICalObjects = mutableListOf() + values.forEach { + localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it)) + } + return localJtxICalObjects + } + + override fun findByName(name: String): LocalJtxICalObject? { + val values = queryByFilename(name) ?: return null + return LocalJtxICalObject.Factory.fromProvider(this, values) + } + + /** + * Finds and returns a recurrence instance of a [LocalJtxICalObject] + * @param uid UID of the main VTODO + * @param recurid RECURRENCE-ID of the recurrence instance + * @return LocalJtxICalObject or null if none or multiple entries found + */ + fun findRecurInstance(uid: String, recurid: String): LocalJtxICalObject? { + val values = queryRecur(uid, recurid) ?: return null + return LocalJtxICalObject.Factory.fromProvider(this, values) + } + + override fun markNotDirty(flags: Int)= updateSetFlags(flags) + + override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags) + + override fun forgetETags() = updateSetETag(null) + + + object Factory: JtxCollectionFactory { + override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt new file mode 100644 index 0000000..856af2d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt @@ -0,0 +1,118 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.repository.PrincipalRepository +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.TaskProvider +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.asSyncAdapter +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Logger +import javax.inject.Inject + +class LocalJtxCollectionStore @Inject constructor( + @ApplicationContext val context: Context, + val accountSettingsFactory: AccountSettings.Factory, + db: AppDatabase, + val principalRepository: PrincipalRepository +): LocalDataStore { + + private val serviceDao = db.serviceDao() + + override val authority: String + get() = JtxContract.AUTHORITY + + override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try { + context.contentResolver.acquireContentProviderClient(authority) + } catch (e: SecurityException) { + if (throwOnMissingPermissions) + throw e + else + /* return */ null + } + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? { + val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + // If the collection doesn't have a color, use a default color. + val collectionWithColor = + if (fromCollection.color != null) + fromCollection + else + fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA) + + val values = valuesFromCollection( + info = collectionWithColor, + account = account, + withColor = true + ) + + val uri = JtxCollection.create(account, provider, values) + return LocalJtxCollection(account, provider, ContentUris.parseId(uri)) + } + + private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues { + val owner = info.ownerId?.let { principalRepository.getBlocking(it) } + + return ContentValues().apply { + put(JtxContract.JtxCollection.SYNC_ID, info.id) + put(JtxContract.JtxCollection.URL, info.url.toString()) + put( + JtxContract.JtxCollection.DISPLAYNAME, + info.displayName ?: info.url.lastSegment + ) + put(JtxContract.JtxCollection.DESCRIPTION, info.description) + if (owner != null) + put(JtxContract.JtxCollection.OWNER, owner.url.toString()) + else + Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner") + put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName) + if (withColor && info.color != null) + put(JtxContract.JtxCollection.COLOR, info.color) + put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT) + put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL) + put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO) + put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name) + put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type) + put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent) + } + } + + override fun getAll(account: Account, provider: ContentProviderClient): List = + JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null) + + override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) { + val accountSettings = accountSettingsFactory.create(localCollection.account) + val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors()) + localCollection.update(values) + } + + override fun updateAccount(oldAccount: Account, newAccount: Account) { + TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider -> + val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name) + val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount) + provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) + } + } + + override fun delete(localCollection: LocalJtxCollection) { + localCollection.delete() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt new file mode 100644 index 0000000..e7c1606 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -0,0 +1,88 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.ContentValues +import android.content.Context +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.JtxICalObject +import at.bitfire.ical4android.JtxICalObjectFactory +import at.techbee.jtx.JtxContract +import com.google.common.base.MoreObjects +import java.util.Optional +import kotlin.jvm.optionals.getOrNull + +class LocalJtxICalObject( + collection: JtxCollection<*>, + fileName: String?, + eTag: String?, + scheduleTag: String?, + flags: Int +) : + JtxICalObject(collection), + LocalResource { + + + init { + this.fileName = fileName + this.eTag = eTag + this.flags = flags + this.scheduleTag = scheduleTag + } + + + object Factory : JtxICalObjectFactory { + + override fun fromProvider( + collection: JtxCollection, + values: ContentValues + ): LocalJtxICalObject { + val fileName = values.getAsString(JtxContract.JtxICalObject.FILENAME) + val eTag = values.getAsString(JtxContract.JtxICalObject.ETAG) + val scheduleTag = values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG) + val flags = values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?: 0 + + val localJtxICalObject = LocalJtxICalObject(collection, fileName, eTag, scheduleTag, flags) + localJtxICalObject.populateFromContentValues(values) + + return localJtxICalObject + } + + } + + override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { + this.fileName = fileName + this.eTag = eTag + this.scheduleTag = scheduleTag + this.flags = flags + + // processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag + update(data) + } + + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { + clearDirty(fileName.getOrNull(), eTag, scheduleTag) + } + + override fun deleteLocal() { + delete() + } + + override fun resetDeleted() { + throw NotImplementedError() + } + + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("scheduleTag", scheduleTag) + .add("flags", flags) + .toString() + + override fun getViewUri(context: Context) = null + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt new file mode 100644 index 0000000..e57b108 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt @@ -0,0 +1,115 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.Context +import android.content.Intent +import android.net.Uri +import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT +import java.util.Optional + +/** + * Defines operations that are used by SyncManager for all sync data types. + */ +interface LocalResource { + + companion object { + /** + * Resource is present on remote server. This flag is used to identify resources + * which are not present on the remote server anymore and can be deleted at the end + * of the synchronization. + */ + const val FLAG_REMOTELY_PRESENT = 1 + } + + + /** + * Unique ID which identifies the resource in the local storage. May be null if the + * resource has not been saved yet. + */ + val id: Long? + + /** + * Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether + * a dirty record has just been created (in this case, [fileName] is *null*) or modified + * (in this case, [fileName] is the remote file name). + */ + val fileName: String? + + /** remote ETag for the resource */ + val eTag: String? + + /** remote Schedule-Tag for the resource */ + val scheduleTag: String? + + /** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */ + val flags: Int + + /** + * Prepares the resource for uploading: + * + * 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider. + * 2. The new file name which can be used for the upload is derived from the UID and returned, but not + * saved to the content provider. The sync manager is responsible for saving the file name that + * was actually used. + * + * @return suggestion for new file name of the resource (like ".vcf") + */ + fun prepareForUpload(): String + + /** + * Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider. + * Does not affect `this` object itself (which is immutable). + * + * @param fileName If this optional argument is present, [LocalResource.fileName] will be set to its value. + * @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one) + * @param scheduleTag CalDAV only: `Schedule-Tag` of the uploaded resource as returned by the server + * (null if not applicable or if the server didn't return one) + */ + fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String? = null) + + /** + * Sets (local) flags of the resource in the content provider. + * Does not affect `this` object itself (which is immutable). + * + * At the moment, the only allowed values are 0 and [FLAG_REMOTELY_PRESENT]. + */ + fun updateFlags(flags: Int) + + /** + * Updates the data object in the content provider and ensures that the dirty flag is clear. + * Does not affect `this` or the [data] object (which are both immutable). + * + * @return content URI of the updated row (e.g. event URI) + */ + fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) + + /** + * Deletes the data object from the content provider. + */ + fun deleteLocal() + + /** + * Undoes deletion of the data object from the content provider. + */ + fun resetDeleted() + + /** + * User-readable debug summary of this local resource (used in debug info) + */ + fun getDebugSummary(): String + + /** + * Returns the content provider URI that opens the local resource for viewing ([Intent.ACTION_VIEW]) + * in its respective app. + * + * For instance, in case of a local raw contact, this method could return the content provider URI + * that identifies the corresponding contact. + * + * @return content provider URI, or `null` if not available + */ + fun getViewUri(context: Context): Uri? + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt new file mode 100644 index 0000000..95dda53 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -0,0 +1,159 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.DmfsTask +import at.bitfire.ical4android.DmfsTaskFactory +import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.ical4android.Task +import at.bitfire.ical4android.TaskProvider +import at.bitfire.synctools.storage.BatchOperation +import com.google.common.base.Ascii +import com.google.common.base.MoreObjects +import org.dmfs.tasks.contract.TaskContract.Tasks +import java.util.Optional +import java.util.UUID + +class LocalTask: DmfsTask, LocalResource { + + companion object { + const val COLUMN_ETAG = Tasks.SYNC1 + const val COLUMN_FLAGS = Tasks.SYNC2 + } + + override var fileName: String? = null + + override var scheduleTag: String? = null + override var eTag: String? = null + + override var flags = 0 + private set + + + constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int) + : super(taskList, task) { + this.fileName = fileName + this.eTag = eTag + this.flags = flags + } + + private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) { + id = values.getAsLong(Tasks._ID) + fileName = values.getAsString(Tasks._SYNC_ID) + eTag = values.getAsString(COLUMN_ETAG) + flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 + } + + + /* process LocalTask-specific fields */ + + override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) { + super.buildTask(builder, update) + + builder .withValue(Tasks._SYNC_ID, fileName) + .withValue(COLUMN_ETAG, eTag) + .withValue(COLUMN_FLAGS, flags) + } + + + /* custom queries */ + + override fun prepareForUpload(): String { + val uid: String = task!!.uid ?: run { + // generate new UID + val newUid = UUID.randomUUID().toString() + + // update in tasks provider + val values = contentValuesOf(Tasks._UID to newUid) + taskList.provider.update(taskSyncURI(), values, null, null) + + // update this task + task!!.uid = newUid + + newUid + } + + return "$uid.ics" + } + + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { + if (scheduleTag != null) + logger.fine("Schedule-Tag for tasks not supported yet, won't save") + + val values = ContentValues(4) + if (fileName.isPresent) + values.put(Tasks._SYNC_ID, fileName.get()) + values.put(COLUMN_ETAG, eTag) + values.put(Tasks.SYNC_VERSION, task!!.sequence) + values.put(Tasks._DIRTY, 0) + taskList.provider.update(taskSyncURI(), values, null, null) + + if (fileName.isPresent) + this.fileName = fileName.get() + this.eTag = eTag + } + + override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { + this.fileName = fileName + this.eTag = eTag + this.scheduleTag = scheduleTag + this.flags = flags + + // processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag + update(data) + } + + override fun updateFlags(flags: Int) { + if (id != null) { + val values = contentValuesOf(COLUMN_FLAGS to flags) + taskList.provider.update(taskSyncURI(), values, null, null) + } + + this.flags = flags + } + + override fun deleteLocal() { + delete() + } + + override fun resetDeleted() { + throw NotImplementedError() + } + + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("scheduleTag", scheduleTag) + .add("flags", flags) + .add("task", + try { + Ascii.truncate(task.toString(), 1000, "…") + } catch (e: Exception) { + e + } + ).toString() + + override fun getViewUri(context: Context): Uri? { + val idNotNull = id ?: return null + if (taskList.providerName == TaskProvider.ProviderName.OpenTasks) { + val contentUri = Tasks.getContentUri(taskList.providerName.authority) + return ContentUris.withAppendedId(contentUri, idNotNull) + } + return null + } + + + object Factory: DmfsTaskFactory { + override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) = + LocalTask(taskList, values) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt new file mode 100644 index 0000000..d87753c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt @@ -0,0 +1,129 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.ical4android.DmfsTaskListFactory +import at.bitfire.ical4android.TaskProvider +import org.dmfs.tasks.contract.TaskContract.TaskListColumns +import org.dmfs.tasks.contract.TaskContract.TaskLists +import org.dmfs.tasks.contract.TaskContract.Tasks +import java.util.logging.Level +import java.util.logging.Logger + +/** + * App-specific implementation of a task list. + * + * [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]). + */ +class LocalTaskList private constructor( + account: Account, + provider: ContentProviderClient, + providerName: TaskProvider.ProviderName, + id: Long +): DmfsTaskList(account, provider, providerName, LocalTask.Factory, id), LocalCollection { + + private val logger = Logger.getGlobal() + + private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED + override val readOnly + get() = + accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED && + accessLevel <= TaskListColumns.ACCESS_LEVEL_READ + + override val dbCollectionId: Long? + get() = syncId?.toLongOrNull() + + override val tag: String + get() = "tasks-${account.name}-$id" + + override val title: String + get() = name ?: id.toString() + + override var lastSyncState: SyncState? + get() { + try { + provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION), + null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + cursor.getString(0)?.let { + return SyncState.fromString(it) + } + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't read sync state", e) + } + return null + } + set(state) { + val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString()) + provider.update(taskListSyncUri(), values, null, null) + } + + + override fun populate(values: ContentValues) { + super.populate(values) + accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL) + } + + + override fun findDeleted() = queryTasks(Tasks._DELETED, null) + + override fun findDirty(): List { + val tasks = queryTasks(Tasks._DIRTY, null) + for (localTask in tasks) { + try { + val task = requireNotNull(localTask.task) + val sequence = task.sequence + if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created) + task.sequence = 0 + else // task was modified, increase sequence + task.sequence = sequence + 1 + } catch(e: Exception) { + logger.log(Level.WARNING, "Couldn't check/increase sequence", e) + } + } + return tasks + } + + override fun findByName(name: String) = + queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull() + + + override fun markNotDirty(flags: Int): Int { + val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags) + return provider.update(tasksSyncUri(), values, + "${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0", + arrayOf(id.toString())) + } + + override fun removeNotDirtyMarked(flags: Int) = + provider.delete(tasksSyncUri(), + "${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?", + arrayOf(id.toString(), flags.toString())) + + override fun forgetETags() { + val values = contentValuesOf(LocalTask.COLUMN_ETAG to null) + provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?", + arrayOf(id.toString())) + } + + + object Factory: DmfsTaskListFactory { + + override fun newInstance( + account: Account, + provider: ContentProviderClient, + providerName: TaskProvider.ProviderName, + id: Long + ) = LocalTaskList(account, provider, providerName, id) + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt new file mode 100644 index 0000000..80bd8e1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt @@ -0,0 +1,124 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.DmfsTaskList +import at.bitfire.ical4android.TaskProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import org.dmfs.tasks.contract.TaskContract.TaskListColumns +import org.dmfs.tasks.contract.TaskContract.TaskLists +import org.dmfs.tasks.contract.TaskContract.Tasks +import java.util.logging.Level +import java.util.logging.Logger + +class LocalTaskListStore @AssistedInject constructor( + @Assisted private val providerName: TaskProvider.ProviderName, + val accountSettingsFactory: AccountSettings.Factory, + @ApplicationContext val context: Context, + val db: AppDatabase, + val logger: Logger +): LocalDataStore { + + @AssistedFactory + interface Factory { + fun create(providerName: TaskProvider.ProviderName): LocalTaskListStore + } + + private val serviceDao = db.serviceDao() + + override val authority: String + get() = providerName.authority + + override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try { + context.contentResolver.acquireContentProviderClient(authority) + } catch (e: SecurityException) { + if (throwOnMissingPermissions) + throw e + else + /* return */ null + } + + override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? { + val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + logger.log(Level.INFO, "Adding local task list", fromCollection) + val uri = create(account, provider, providerName, fromCollection) + return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri)) + } + + private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri { + // If the collection doesn't have a color, use a default color. + val collectionWithColor = if (fromCollection.color != null) + fromCollection + else + fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA) + + val values = valuesFromCollectionInfo( + info = collectionWithColor, + withColor = true + ).apply { + put(TaskLists.OWNER, account.name) + put(TaskLists.SYNC_ENABLED, 1) + put(TaskLists.VISIBLE, 1) + } + return DmfsTaskList.Companion.create(account, provider, providerName, values) + } + + private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues { + val values = ContentValues(3) + values.put(TaskLists._SYNC_ID, info.id.toString()) + values.put(TaskLists.LIST_NAME, + if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName) + + if (withColor && info.color != null) + values.put(TaskLists.LIST_COLOR, info.color) + + if (info.privWriteContent && !info.forceReadOnly) + values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER) + else + values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ) + + return values + } + + override fun getAll(account: Account, provider: ContentProviderClient) = + DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null) + + override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) { + logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection) + val accountSettings = accountSettingsFactory.create(localCollection.account) + localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())) + } + + override fun updateAccount(oldAccount: Account, newAccount: Account) { + TaskProvider.acquire(context, providerName)?.use { provider -> + val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name) + val uri = Tasks.getContentUri(providerName.authority) + provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) + } + } + + override fun delete(localCollection: LocalTaskList) { + localCollection.delete() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/SyncState.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/SyncState.kt new file mode 100644 index 0000000..209b55b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/SyncState.kt @@ -0,0 +1,59 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import at.bitfire.dav4jvm.property.webdav.SyncToken +import org.json.JSONException +import org.json.JSONObject + +data class SyncState( + val type: Type, + val value: String, + + /** + * Whether this sync state occurred during an initial sync as described + * in RFC 6578, which means the initial sync is not complete yet. + */ + var initialSync: Boolean? = null +) { + + companion object { + + private const val KEY_TYPE = "type" + private const val KEY_VALUE = "value" + private const val KEY_INITIAL_SYNC = "initialSync" + + fun fromString(s: String?): SyncState? { + if (s == null) + return null + + return try { + val json = JSONObject(s) + SyncState( + Type.valueOf(json.getString(KEY_TYPE)), + json.getString(KEY_VALUE), + try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null } + ) + } catch (e: JSONException) { + null + } + } + + fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) = + SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync) + + } + + enum class Type { CTAG, SYNC_TOKEN } + + override fun toString(): String { + val json = JSONObject() + json.put(KEY_TYPE, type.name) + json.put(KEY_VALUE, value) + initialSync?.let { json.put(KEY_INITIAL_SYNC, it) } + return json.toString() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandler.kt new file mode 100644 index 0000000..f531e83 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/CachedGroupMembershipHandler.kt @@ -0,0 +1,28 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import at.bitfire.vcard4android.contactrow.DataRowHandler +import java.util.logging.Logger + +class CachedGroupMembershipHandler(val localContact: LocalContact): DataRowHandler() { + + override fun forMimeType() = CachedGroupMembership.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + if (localContact.addressBook.groupMethod == GroupMethod.GROUP_VCARDS) + localContact.cachedGroupMemberships += values.getAsLong(CachedGroupMembership.GROUP_ID) + else + Logger.getGlobal().warning("Ignoring cached group membership for group method CATEGORIES") + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilder.kt new file mode 100644 index 0000000..30b4c99 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipBuilder.kt @@ -0,0 +1,43 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.net.Uri +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import at.bitfire.vcard4android.contactrow.DataRowBuilder +import java.util.LinkedList + +class GroupMembershipBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, val addressBook: LocalAddressBook, readOnly: Boolean) + : DataRowBuilder(Factory.MIME_TYPE, dataRowUri, rawContactId, contact, readOnly) { + + override fun build(): List { + val result = LinkedList() + + if (addressBook.groupMethod == GroupMethod.CATEGORIES) + for (category in contact.categories) + result += newDataRow().withValue(GroupMembership.GROUP_ROW_ID, addressBook.findOrCreateGroup(category)) + else { + // GroupMethod.GROUP_VCARDS -> memberships are handled by LocalGroups (and not by the members = LocalContacts, which we are processing here) + // TODO: CATEGORIES <-> unknown properties + } + + return result + } + + + class Factory(val addressBook: LocalAddressBook): DataRowBuilder.Factory { + companion object { + const val MIME_TYPE = GroupMembership.CONTENT_ITEM_TYPE + } + override fun mimeType() = MIME_TYPE + override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) = + GroupMembershipBuilder(dataRowUri, rawContactId, contact, addressBook, readOnly) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandler.kt new file mode 100644 index 0000000..6bee391 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/GroupMembershipHandler.kt @@ -0,0 +1,39 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.util.trimToNull +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import at.bitfire.vcard4android.contactrow.DataRowHandler +import java.io.FileNotFoundException + +class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() { + + override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + val groupId = values.getAsLong(GroupMembership.GROUP_ROW_ID) + localContact.groupMemberships += groupId + + if (localContact.addressBook.groupMethod == GroupMethod.CATEGORIES) { + try { + val group = localContact.addressBook.findGroupById(groupId) + group.getContact().displayName.trimToNull()?.let { groupName -> + logger.fine("Adding membership in group $groupName as category") + contact.categories.add(groupName) + } + } catch (ignored: FileNotFoundException) { + logger.warning("Contact is member in group $groupId which doesn't exist anymore") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownProperties.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownProperties.kt new file mode 100644 index 0000000..593704f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownProperties.kt @@ -0,0 +1,17 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.provider.ContactsContract.RawContacts + +object UnknownProperties { + + const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties" + + const val MIMETYPE = RawContacts.Data.MIMETYPE + const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID + const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1 + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilder.kt new file mode 100644 index 0000000..41f8090 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesBuilder.kt @@ -0,0 +1,31 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.net.Uri +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.contactrow.DataRowBuilder +import java.util.LinkedList + +class UnknownPropertiesBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) + : DataRowBuilder(Factory.mimeType(), dataRowUri, rawContactId, contact, readOnly) { + + override fun build(): List { + val result = LinkedList() + contact.unknownProperties?.let { unknownProperties -> + result += newDataRow().withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties) + } + return result + } + + + object Factory: DataRowBuilder.Factory { + override fun mimeType() = UnknownProperties.CONTENT_ITEM_TYPE + override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) = + UnknownPropertiesBuilder(dataRowUri, rawContactId, contact, readOnly) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandler.kt new file mode 100644 index 0000000..4f0e8fa --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/contactrow/UnknownPropertiesHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.contactrow + +import android.content.ContentValues +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.contactrow.DataRowHandler + +object UnknownPropertiesHandler: DataRowHandler() { + + override fun forMimeType() = UnknownProperties.CONTENT_ITEM_TYPE + + override fun handle(values: ContentValues, contact: Contact) { + super.handle(values, contact) + + contact.unknownProperties = values.getAsString(UnknownProperties.UNKNOWN_PROPERTIES) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/workaround/Android7DirtyVerifier.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/workaround/Android7DirtyVerifier.kt new file mode 100644 index 0000000..87deebb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/workaround/Android7DirtyVerifier.kt @@ -0,0 +1,161 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.workaround + +import android.content.ContentValues +import android.os.Build +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.resource.LocalContact.Companion.COLUMN_HASHCODE +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.ContactsBatchOperation +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider + +/** + * Android 7.x introduced a new behavior in the Contacts provider: when metadata of a contact (like the "last contacted" time) + * changes, the contact is marked as "dirty" (i.e. the [android.provider.ContactsContract.RawContacts.DIRTY] flag is set). + * So, under Android 7.x, every time a user calls a contact or writes an SMS to a contact, the contact is marked as dirty. + * + * **This behavior is not present in Android ≤ 6.x nor in ≥ Android 8.x, where a contact is only marked as dirty + * when its data actually change.** + * + * So, as a dirty workaround for Android 7.x, we need to calculate a hash code from the contact data and group memberships every + * time we change the contact. When then a contact is marked as dirty, we compare the hash code of the current contact data with + * the previous hash code. If the hash code has changed, the contact is "really dirty" and we need to upload it. Otherwise, + * we reset the dirty flag to ignore the meta-data change. + * + * @constructor May only be called on Android 7.x, otherwise an [IllegalStateException] is thrown. + */ +class Android7DirtyVerifier @Inject constructor( + val logger: Logger +): ContactDirtyVerifier { + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + throw IllegalStateException("Android7DirtyVerifier must not be used on Android != 7.x") + } + + + // address-book level functions + + override fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean { + val reallyDirty = verifyDirtyContacts(addressBook) + + val deleted = addressBook.findDeleted().size + if (isUpload && reallyDirty == 0 && deleted == 0) { + logger.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed") + return false + } + + return true + } + + /** + * Queries all contacts with the [android.provider.ContactsContract.RawContacts.DIRTY] flag and checks whether their data + * checksum has changed, i.e. if they're "really dirty" (= data has changed, not only metadata, which is not hashed). + * + * The dirty flag is removed from contacts which are not "really dirty", i.e. from contacts whose contact data + * checksum has not changed. + * + * @return number of "really dirty" contacts + */ + private fun verifyDirtyContacts(addressBook: LocalAddressBook): Int { + var reallyDirty = 0 + for (contact in addressBook.findDirtyContacts()) { + val lastHash = getLastHashCode(addressBook, contact) + val currentHash = contactDataHashCode(contact) + if (lastHash == currentHash) { + // hash is code still the same, contact is not "really dirty" (only metadata been have changed) + logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact) + contact.resetDirty() + } else { + logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact) + reallyDirty++ + } + } + + if (addressBook.includeGroups) + reallyDirty += addressBook.findDirtyGroups().size + + return reallyDirty + } + + private fun getLastHashCode(addressBook: LocalAddressBook, contact: LocalContact): Int { + addressBook.provider!!.query(contact.rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c -> + if (c.moveToNext() && !c.isNull(0)) + return c.getInt(0) + } + return 0 + } + + + // contact level functions + + /** + * Calculates a hash code from the [at.bitfire.vcard4android.Contact] data and group memberships. + * Attention: re-reads {@link #contact} from the database, discarding all changes in memory! + * + * @return hash code of contact data (including group memberships) + */ + private fun contactDataHashCode(contact: LocalContact): Int { + contact.clearCachedContact() + + // groupMemberships is filled by getContact() + val dataHash = contact.getContact().hashCode() + val groupHash = contact.groupMemberships.hashCode() + val combinedHash = dataHash xor groupHash + logger.log(Level.FINE, "Calculated data hash = $dataHash, group memberships hash = $groupHash → combined hash = $combinedHash", contact) + return combinedHash + } + + override fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues) { + val hashCode = contactDataHashCode(contact) + toValues.put(COLUMN_HASHCODE, hashCode) + } + + override fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact) { + val values = ContentValues(1) + setHashCodeColumn(contact, values) + + addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null) + } + + override fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation) { + val hashCode = contactDataHashCode(contact) + + batch += BatchOperation.CpoBuilder + .newUpdate(contact.rawContactSyncURI()) + .withValue(COLUMN_HASHCODE, hashCode) + } + + + + // factory + + @Module + @InstallIn(SingletonComponent::class) + object Android7DirtyVerifierModule { + + /** + * Provides an [Android7DirtyVerifier] on Android 7.x, or an empty [Optional] on other versions. + */ + @Provides + fun provide(android7DirtyVerifier: Provider): Optional = + if (/* Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && */ Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + Optional.of(android7DirtyVerifier.get()) + else + Optional.empty() + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/workaround/ContactDirtyVerifier.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/workaround/ContactDirtyVerifier.kt new file mode 100644 index 0000000..b37e487 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/workaround/ContactDirtyVerifier.kt @@ -0,0 +1,54 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource.workaround + +import android.content.ContentValues +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.synctools.storage.ContactsBatchOperation + +/** + * Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8, + * this interface and all calls to it can be removed as well. + */ +interface ContactDirtyVerifier { + + // address-book level functions + + /** + * Checks whether contacts which are marked as "dirty" are really dirty, i.e. their data has changed. + * If contacts are not really dirty (because only the metadata like "last contacted" changed), the "dirty" flag is removed. + * + * Intended to be called by [at.bitfire.davdroid.sync.ContactsSyncManager.prepare]. + * + * @param addressBook the address book + * @param isUpload whether this sync is an upload + * + * @return `true` if the address book should be synced, `false` if the sync is an upload and no contacts have been changed + */ + fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean + + + // contact level functions + + /** + * Sets the [LocalContact.COLUMN_HASHCODE] column in the given [ContentValues] to the hash code of the contact data. + * + * @param contact the contact to calculate the hash code for + * @param toValues set the hash code into these values + */ + fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues) + + /** + * Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data directly in the content provider. + */ + fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact) + + /** + Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data in a content provider batch operation. + */ + fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt new file mode 100644 index 0000000..8f8ba9f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt @@ -0,0 +1,73 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.webdav.Owner +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Principal +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavCollectionRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okhttp3.OkHttpClient + +/** + * Logic for refreshing the list of collections (and their related information) + * which do not belong to a home set. + */ +class CollectionsWithoutHomeSetRefresher @AssistedInject constructor( + @Assisted private val service: Service, + @Assisted private val httpClient: OkHttpClient, + private val db: AppDatabase, + private val collectionRepository: DavCollectionRepository, +) { + + @AssistedFactory + interface Factory { + fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher + } + + /** + * Refreshes collections which don't have a homeset. + * + * It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them. + */ + internal fun refreshCollectionsWithoutHomeSet() { + val withoutHomeSet = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap() + for ((url, localCollection) in withoutHomeSet) try { + val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type) + DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ -> + if (!response.isSuccess()) { + collectionRepository.delete(localCollection) + return@propfind + } + + // Save or update the collection, if usable, otherwise delete it + Collection.fromDavResponse(response)?.let { collection -> + if (!ServiceDetectionUtils.isUsableCollection(service, collection)) + return@let + collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy( + serviceId = localCollection.serviceId, // use same service ID as previous entry + ownerId = response[Owner::class.java]?.href // save the principal id (collection owner) + ?.let { response.href.resolve(it) } + ?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) } + ?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) } + )) + } ?: collectionRepository.delete(localCollection) + } + } catch (e: HttpException) { + // delete collection locally if it was not accessible (40x) + if (e.statusCode in arrayOf(403, 404, 410)) + collectionRepository.delete(localCollection) + else + throw e + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt new file mode 100644 index 0000000..494d9c1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -0,0 +1,506 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid.servicedetection + +import android.app.ActivityManager +import android.content.Context +import androidx.core.content.getSystemService +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.dav4jvm.property.caldav.CalendarColor +import at.bitfire.dav4jvm.property.caldav.CalendarDescription +import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet +import at.bitfire.dav4jvm.property.caldav.CalendarTimezone +import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet +import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet +import at.bitfire.dav4jvm.property.carddav.AddressbookDescription +import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet +import at.bitfire.dav4jvm.property.common.HrefListProperty +import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal +import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.log.StringHandler +import at.bitfire.davdroid.network.DnsRecordResolver +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.settings.Credentials +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.xbill.DNS.Type +import java.io.InterruptedIOException +import java.net.SocketTimeoutException +import java.net.URI +import java.net.URISyntaxException +import java.util.LinkedList +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Does initial resource detection when an account is added. It uses the (user given) base URL to find + * + * - services (CalDAV and/or CardDAV), + * - principal, + * - homeset/collections (multistatus responses are handled through dav4jvm). + * + * @param context to build the HTTP client + * @param baseURI user-given base URI (either mailto: URI or http(s):// URL) + * @param credentials optional login credentials (username/password, client certificate, OAuth state) + */ +class DavResourceFinder @AssistedInject constructor( + @Assisted private val baseURI: URI, + @Assisted private val credentials: Credentials? = null, + @ApplicationContext val context: Context, + private val dnsRecordResolver: DnsRecordResolver, + httpClientBuilder: HttpClient.Builder +): AutoCloseable { + + @AssistedFactory + interface Factory { + fun create(baseURI: URI, credentials: Credentials?): DavResourceFinder + } + + enum class Service(val wellKnownName: String) { + CALDAV("caldav"), + CARDDAV("carddav"); + + override fun toString() = wellKnownName + } + + val log: Logger = Logger.getLogger(javaClass.name) + private val logBuffer: StringHandler = initLogging() + + private var encountered401 = false + + private val httpClient = httpClientBuilder + .setLogger(log) + .apply { + if (credentials != null) + authenticate( + host = null, + getCredentials = { credentials } + ) + } + .build() + + override fun close() { + httpClient.close() + } + + private fun initLogging(): StringHandler { + // don't use more than 1/4 of the available memory for a log string + val activityManager = context.getSystemService()!! + val maxLogSize = activityManager.memoryClass * (1024 * 1024 / 8) + val handler = StringHandler(maxLogSize) + + // add StringHandler to logger + log.level = Level.ALL + log.addHandler(handler) + + return handler + } + + + /** + * Finds the initial configuration (= runs the service detection process). + * + * In case of an error, it returns an empty [Configuration] with error logs + * instead of throwing an [Exception]. + * + * @return service information – if there's neither a CalDAV service nor a CardDAV service, + * service detection was not successful + */ + fun findInitialConfiguration(): Configuration { + var cardDavConfig: Configuration.ServiceInfo? = null + var calDavConfig: Configuration.ServiceInfo? = null + + try { + try { + cardDavConfig = findInitialConfiguration(Service.CARDDAV) + } catch (e: Exception) { + log.log(Level.INFO, "CardDAV service detection failed", e) + processException(e) + } + + try { + calDavConfig = findInitialConfiguration(Service.CALDAV) + } catch (e: Exception) { + log.log(Level.INFO, "CalDAV service detection failed", e) + processException(e) + } + } catch(_: Exception) { + // we have been interrupted; reset results so that an error message will be shown + cardDavConfig = null + calDavConfig = null + } + + return Configuration( + cardDAV = cardDavConfig, + calDAV = calDavConfig, + encountered401 = encountered401, + logs = logBuffer.toString() + ) + } + + private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? { + // domain for service discovery + var discoveryFQDN: String? = null + + // discovered information goes into this config + val config = Configuration.ServiceInfo() + + // Start discovering + log.info("Finding initial ${service.wellKnownName} service configuration") + when (baseURI.scheme.lowercase()) { + "http", "https" -> + baseURI.toHttpUrlOrNull()?.let { baseURL -> + // remember domain for service discovery + if (baseURL.scheme.equals("https", true)) + // service discovery will only be tried for https URLs, because only secure service discovery is implemented + discoveryFQDN = baseURL.host + + // Actual discovery process + checkBaseURL(baseURL, service, config) + + // If principal was not found already, try well known URI + if (config.principal == null) + try { + config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service) + } catch(e: Exception) { + log.log(Level.FINE, "Well-known URL detection failed", e) + processException(e) + } + } + "mailto" -> { + val mailbox = baseURI.schemeSpecificPart + val posAt = mailbox.lastIndexOf("@") + if (posAt != -1) + discoveryFQDN = mailbox.substring(posAt + 1) + } + } + + // Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY) + if (config.principal == null) + discoveryFQDN?.let { fqdn -> + log.info("No principal found at user-given URL, trying to discover for domain $fqdn") + try { + config.principal = discoverPrincipalUrl(fqdn, service) + } catch(e: Exception) { + log.log(Level.FINE, "$service service discovery failed", e) + processException(e) + } + } + + // detect email address + if (service == Service.CALDAV) + config.principal?.let { principal -> + config.emails.addAll(queryEmailAddress(principal)) + } + + // return config or null if config doesn't contain useful information + val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty() + return if (serviceAvailable) + config + else + null + } + + /** + * Entry point of the actual discovery process. + * + * Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal + * or whether it is a homeset or collection. + * + * @param baseURL base URL provided by the user + * @param service service to detect configuration for + * @param config found configuration will be written to this object + */ + private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) { + log.info("Checking user-given URL: $baseURL") + + val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log) + try { + when (service) { + Service.CARDDAV -> { + davBaseURL.propfind( + 0, + ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, + AddressbookHomeSet.NAME, + CurrentUserPrincipal.NAME + ) { response, _ -> + scanResponse(ResourceType.ADDRESSBOOK, response, config) + } + } + Service.CALDAV -> { + davBaseURL.propfind( + 0, + ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, + CalendarHomeSet.NAME, + CurrentUserPrincipal.NAME + ) { response, _ -> + scanResponse(ResourceType.CALENDAR, response, config) + } + } + } + } catch(e: Exception) { + log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e) + processException(e) + } + } + + /** + * Queries a user's email address using CalDAV scheduling: calendar-user-address-set. + * @param principal principal URL of the user + * @return list of found email addresses (empty if none) + */ + fun queryEmailAddress(principal: HttpUrl): List { + val mailboxes = LinkedList() + try { + DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> + response[CalendarUserAddressSet::class.java]?.let { addressSet -> + for (href in addressSet.hrefs) + try { + val uri = URI(href) + if (uri.scheme.equals("mailto", true)) + mailboxes.add(uri.schemeSpecificPart) + } catch(e: URISyntaxException) { + log.log(Level.WARNING, "Couldn't parse user address", e) + } + } + } + } catch(e: Exception) { + log.log(Level.WARNING, "Couldn't query user email address", e) + processException(e) + } + return mailboxes + } + + /** + * Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references + * - an address book or calendar (actual resource), and/or + * - an "address book home set" or a "calendar home set", and/or + * - whether it's a principal. + * + * Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal]. + * Collection URLs will be stored with trailing "/". + * + * @param resourceType type of service to search for in the response + * @param davResponse response whose properties are evaluated + * @param config structure storing the references + */ + fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) { + var principal: HttpUrl? = null + + // Type mapping + val homeSetClass: Class + val serviceType: Service + when (resourceType) { + ResourceType.ADDRESSBOOK -> { + homeSetClass = AddressbookHomeSet::class.java + serviceType = Service.CARDDAV + } + ResourceType.CALENDAR -> { + homeSetClass = CalendarHomeSet::class.java + serviceType = Service.CALDAV + } + else -> throw IllegalArgumentException() + } + + // check for current-user-principal + davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal -> + principal = davResponse.requestedUrl.resolve(currentUserPrincipal) + } + + davResponse[ResourceType::class.java]?.let { + // Is it a calendar or an address book, ... + if (it.types.contains(resourceType)) + Collection.fromDavResponse(davResponse)?.let { info -> + log.info("Found resource of type $resourceType at ${info.url}") + config.collections[info.url] = info + } + + // ... and/or a principal? + if (it.types.contains(ResourceType.PRINCIPAL)) + principal = davResponse.href + } + + // Is it an addressbook-home-set or calendar-home-set? + davResponse[homeSetClass]?.let { homeSet -> + for (href in homeSet.hrefs) { + davResponse.requestedUrl.resolve(href)?.let { + val location = UrlUtils.withTrailingSlash(it) + log.info("Found home-set of type $resourceType at $location") + config.homeSets += location + } + } + } + + // Is there a principal too? + principal?.let { + if (providesService(it, serviceType)) + config.principal = principal + else + log.warning("Principal $principal doesn't provide $serviceType service") + } + } + + /** + * Sends an OPTIONS request to determine whether a URL provides a given service. + * + * @param url URL to check; often a principal URL + * @param service service to check for + * + * @return whether the URL provides the given service + */ + fun providesService(url: HttpUrl, service: Service): Boolean { + var provided = false + try { + DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ -> + if ((service == Service.CARDDAV && capabilities.contains("addressbook")) || + (service == Service.CALDAV && capabilities.contains("calendar-access"))) + provided = true + } + } catch(e: Exception) { + log.log(Level.SEVERE, "Couldn't detect services on $url", e) + if (e !is HttpException && e !is DavException) + throw e + } + return provided + } + + + /** + * Try to find the principal URL by performing service discovery on a given domain name. + * Only secure services (caldavs, carddavs) will be discovered! + * + * @param domain domain name, e.g. "icloud.com" + * @param service service to discover (CALDAV or CARDDAV) + * @return principal URL, or null if none found + */ + fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? { + val scheme: String + val fqdn: String + var port = 443 + val paths = LinkedList() // there may be multiple paths to try + + val query = "_${service.wellKnownName}s._tcp.$domain" + log.fine("Looking up SRV records for $query") + + val srvRecords = dnsRecordResolver.resolve(query, Type.SRV) + val srv = dnsRecordResolver.bestSRVRecord(srvRecords) + + if (srv != null) { + // choose SRV record to use (query may return multiple SRV records) + scheme = "https" + fqdn = srv.target.toString(true) + port = srv.port + log.info("Found $service service at https://$fqdn:$port") + } else { + // no SRV records, try domain name as FQDN + log.info("Didn't find $service service, trying at https://$domain:$port") + + scheme = "https" + fqdn = domain + } + + // look for TXT record too (for initial context path) + val txtRecords = dnsRecordResolver.resolve(query, Type.TXT) + paths.addAll(dnsRecordResolver.pathsFromTXTRecords(txtRecords)) + + // in case there's a TXT record, but it's wrong, try well-known + paths.add("/.well-known/" + service.wellKnownName) + // if this fails too, try "/" + paths.add("/") + + for (path in paths) + try { + val initialContextPath = HttpUrl.Builder() + .scheme(scheme) + .host(fqdn).port(port) + .encodedPath(path) + .build() + + log.info("Trying to determine principal from initial context path=$initialContextPath") + val principal = getCurrentUserPrincipal(initialContextPath, service) + + principal?.let { return it } + } catch(e: Exception) { + log.log(Level.WARNING, "No resource found", e) + processException(e) + } + return null + } + + /** + * Queries a given URL for current-user-principal + * + * @param url URL to query with PROPFIND (Depth: 0) + * @param service required service (may be null, in which case no service check is done) + * @return current-user-principal URL that provides required service, or null if none + */ + fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? { + var principal: HttpUrl? = null + DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + response[CurrentUserPrincipal::class.java]?.href?.let { href -> + response.requestedUrl.resolve(href)?.let { + log.info("Found current-user-principal: $it") + + // service check + if (service != null && !providesService(it, service)) + log.warning("Principal $it doesn't provide $service service") + else + principal = it + } + } + } + return principal + } + + /** + * Processes a thrown exception like this: + * + * - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*. + * - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation. + */ + private fun processException(e: Exception) { + if (e is UnauthorizedException) + encountered401 = true + else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException) + throw e + } + + + // data classes + + class Configuration( + val cardDAV: ServiceInfo?, + val calDAV: ServiceInfo?, + + val encountered401: Boolean, + val logs: String + ) { + + data class ServiceInfo( + var principal: HttpUrl? = null, + val homeSets: MutableSet = HashSet(), + val collections: MutableMap = HashMap(), + + val emails: MutableList = LinkedList() + ) + + override fun toString() = + "DavResourceFinder.Configuration(cardDAV=$cardDAV, calDAV=$calDAV, encountered401=$encountered401, logs=(${logs.length} chars))" + + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt new file mode 100644 index 0000000..7788cf6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt @@ -0,0 +1,162 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.Owner +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Principal +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavHomeSetRepository +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okhttp3.OkHttpClient +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Used to update the list of synchronizable collections + */ +class HomeSetRefresher @AssistedInject constructor( + @Assisted private val service: Service, + @Assisted private val httpClient: OkHttpClient, + private val db: AppDatabase, + private val logger: Logger, + private val collectionRepository: DavCollectionRepository, + private val homeSetRepository: DavHomeSetRepository, + private val settings: SettingsManager +) { + + @AssistedFactory + interface Factory { + fun create(service: Service, httpClient: OkHttpClient): HomeSetRefresher + } + + /** + * Refreshes home-sets and their collections. + * + * Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated + * or marked as "without home-set" - in case a collection was removed from its home-set. + * + * If a home-set URL in fact points to a collection directly, the collection will be saved with this URL, + * and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet]. + */ + internal fun refreshHomesetsAndTheirCollections() { + val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap() + for ((homeSetUrl, localHomeset) in homesets) { + logger.fine("Listing home set $homeSetUrl") + + // To find removed collections in this homeset: create a queue from existing collections and remove every collection that + // is successfully rediscovered. If there are collections left, after processing is done, these are marked as "without home-set". + val localHomesetCollections = db.collectionDao() + .getByServiceAndHomeset(service.id, localHomeset.id) + .associateBy { it.url } + .toMutableMap() + + try { + val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type) + DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation -> + // Note: This callback may be called multiple times ([MultiResponseCallback]) + if (!response.isSuccess()) + return@propfind + + if (relation == Response.HrefRelation.SELF) + // this response is about the home set itself + homeSetRepository.insertOrUpdateByUrlBlocking( + localHomeset.copy( + displayName = response[DisplayName::class.java]?.displayName, + privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false + ) + ) + + // in any case, check whether the response is about a usable collection + var collection = Collection.fromDavResponse(response) ?: return@propfind + collection = collection.copy( + serviceId = service.id, + homeSetId = localHomeset.id, + sync = shouldPreselect(collection, homesets.values), + ownerId = response[Owner::class.java]?.href // save the principal id (collection owner) + ?.let { response.href.resolve(it) } + ?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) } + ?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) } + ) + logger.log(Level.FINE, "Found collection", collection) + + // save or update collection if usable (ignore it otherwise) + if (ServiceDetectionUtils.isUsableCollection(service, collection)) + collectionRepository.insertOrUpdateByUrlRememberSync(collection) + + // Remove this collection from queue - because it was found in the home set + localHomesetCollections.remove(collection.url) + } + } catch (e: HttpException) { + // delete home set locally if it was not accessible (40x) + if (e.statusCode in arrayOf(403, 404, 410)) + homeSetRepository.deleteBlocking(localHomeset) + } + + // Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association) + for ((_, collection) in localHomesetCollections) + collectionRepository.insertOrUpdateByUrlRememberSync( + collection.copy(homeSetId = null) + ) + + } + } + + /** + * Whether to preselect the given collection for synchronisation, according to the + * settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and + * [Settings.PRESELECT_COLLECTIONS_EXCLUDED]. + * + * A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets. + * + * Before a collection is pre-selected, we check whether its URL matches the regexp in + * [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned. + * + * @param collection the collection to check + * @param homeSets list of personal home-sets + * @return *true* if the collection should be preselected for synchronization; *false* otherwise + */ + internal fun shouldPreselect(collection: Collection, homeSets: Iterable): Boolean { + val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) + + val excluded by lazy { + val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) + if (!excludedRegex.isNullOrEmpty()) + Regex(excludedRegex).containsMatchIn(collection.url.toString()) + else + false + } + + return when (shouldPreselect) { + Settings.PRESELECT_COLLECTIONS_ALL -> + // preselect if collection url is not excluded + !excluded + + Settings.PRESELECT_COLLECTIONS_PERSONAL -> + // preselect if is personal (in a personal home-set), but not excluded + homeSets + .filter { homeset -> homeset.personal } + .map { homeset -> homeset.id } + .contains(collection.homeSetId) + && !excluded + + else -> // don't preselect + false + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt new file mode 100644 index 0000000..af56b68 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt @@ -0,0 +1,73 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Principal +import at.bitfire.davdroid.db.Service +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okhttp3.OkHttpClient +import java.util.logging.Logger + +/** + * Used to update the principals (their current display names) and delete those without collections. + */ +class PrincipalsRefresher @AssistedInject constructor( + @Assisted private val service: Service, + @Assisted private val httpClient: OkHttpClient, + private val db: AppDatabase, + private val logger: Logger +) { + + @AssistedFactory + interface Factory { + fun create(service: Service, httpClient: OkHttpClient): PrincipalsRefresher + } + + /** + * Principal properties to ask the server for. + */ + private val principalProperties = arrayOf( + DisplayName.NAME, + ResourceType.NAME + ) + + /** + * Refreshes the principals (get their current display names). + * Also removes principals which do not own any collections anymore. + */ + fun refreshPrincipals() { + // Refresh principals (collection owner urls) + val principals = db.principalDao().getByService(service.id) + for (oldPrincipal in principals) { + val principalUrl = oldPrincipal.url + logger.fine("Querying principal $principalUrl") + try { + DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ -> + if (!response.isSuccess()) + return@propfind + Principal.fromDavResponse(service.id, response)?.let { principal -> + logger.fine("Got principal: $principal") + db.principalDao().insertOrUpdate(service.id, principal) + } + } + } catch (e: HttpException) { + logger.info("Principal update failed with response code ${e.statusCode}. principalUrl=$principalUrl") + } + } + + // Delete principals which don't own any collections + db.principalDao().getAllWithoutCollections().forEach { principal -> + db.principalDao().delete(principal) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt new file mode 100644 index 0000000..a2a7a65 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -0,0 +1,251 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import android.accounts.Account +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.TaskStackBuilder +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.push.PushRegistrationManager +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.ui.DebugInfoActivity +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.davdroid.ui.account.AccountSettingsActivity +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runInterruptible +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Refreshes list of home sets and their respective collections of a service type (CardDAV or CalDAV). + * Called from UI, when user wants to refresh all collections of a service. + * + * Input data: + * + * - [ARG_SERVICE_ID]: service ID + * + * It queries all existing homesets and/or collections and then: + * - updates resources with found properties (overwrites without comparing) + * - adds resources if new ones are detected + * - removes resources if not found 40x (delete locally) + * + * Expedited: yes (always initiated by user) + * + * Long-running: no + * + * @throws IllegalArgumentException when there's no service with the given service ID + */ +@HiltWorker +class RefreshCollectionsWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory, + private val homeSetRefresherFactory: HomeSetRefresher.Factory, + private val httpClientBuilder: HttpClient.Builder, + private val logger: Logger, + private val notificationRegistry: NotificationRegistry, + private val principalsRefresherFactory: PrincipalsRefresher.Factory, + private val pushRegistrationManager: PushRegistrationManager, + private val serviceRefresherFactory: ServiceRefresher.Factory, + serviceRepository: DavServiceRepository +): CoroutineWorker(appContext, workerParams) { + + companion object { + + const val ARG_SERVICE_ID = "serviceId" + const val WORKER_TAG = "refreshCollectionsWorker" + + /** + * Uniquely identifies a refresh worker. Useful for stopping work, or querying its state. + * + * @param serviceId what service (CalDAV/CardDAV) the worker is running for + */ + fun workerName(serviceId: Long): String = "$WORKER_TAG-$serviceId" + + /** + * Requests immediate refresh of a given service. If not running already. this will enqueue + * a [RefreshCollectionsWorker]. + * + * @param serviceId serviceId which is to be refreshed + * @return Pair with + * + * 1. worker name, + * 2. operation of [WorkManager.enqueueUniqueWork] (can be used to wait for completion) + * + * @throws IllegalArgumentException when there's no service with this ID + */ + fun enqueue(context: Context, serviceId: Long): Pair { + val name = workerName(serviceId) + val arguments = Data.Builder() + .putLong(ARG_SERVICE_ID, serviceId) + .build() + val workRequest = OneTimeWorkRequestBuilder() + .addTag(name) + .setInputData(arguments) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + return Pair( + name, + WorkManager.getInstance(context).enqueueUniqueWork( + name, + ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one + workRequest + ) + ) + } + + /** + * Observes whether a refresh worker with given service id and state exists. + * + * @param workerName name of worker to find + * @param workState state of worker to match + * + * @return flow that emits `true` if worker with matching state was found (otherwise `false`) + */ + fun existsFlow(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) = + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(workerName).map { workInfoList -> + workInfoList.any { workInfo -> workInfo.state == workState } + } + + } + + val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1) + val service = serviceRepository.getBlocking(serviceId) + val account = service?.let { service -> + Account(service.accountName, applicationContext.getString(R.string.account_type)) + } + + override suspend fun doWork(): Result { + if (service == null || account == null) { + logger.warning("Missing service or account with service ID: $serviceId") + return Result.failure() + } + + try { + logger.info("Refreshing ${service.type} collections of service #$service") + + // cancel previous notification + NotificationManagerCompat.from(applicationContext) + .cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS) + + // create authenticating OkHttpClient (credentials taken from account settings) + httpClientBuilder + .fromAccount(account) + .build() + .use { httpClient -> + runInterruptible { + val httpClient = httpClient.okHttpClient + val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient) + + // refresh home set list (from principal url) + service.principal?.let { principalUrl -> + logger.fine("Querying principal $principalUrl for home sets") + val serviceRefresher = serviceRefresherFactory.create(service, httpClient) + serviceRefresher.discoverHomesets(principalUrl) + } + + // refresh home sets and their member collections + homeSetRefresherFactory.create(service, httpClient) + .refreshHomesetsAndTheirCollections() + + // also refresh collections without a home set + refresher.refreshCollectionsWithoutHomeSet() + + // Lastly, refresh the principals (collection owners) + val principalsRefresher = principalsRefresherFactory.create(service, httpClient) + principalsRefresher.refreshPrincipals() + } + } + + } catch(e: InvalidAccountException) { + logger.log(Level.SEVERE, "Invalid account", e) + return Result.failure() + } catch (e: UnauthorizedException) { + logger.log(Level.SEVERE, "Not authorized (anymore)", e) + // notify that we need to re-authenticate in the account settings + val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java) + .putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + notifyRefreshError( + applicationContext.getString(R.string.sync_error_authentication_failed), + settingsIntent + ) + return Result.failure() + } catch(e: Exception) { + logger.log(Level.SEVERE, "Couldn't refresh collection list", e) + + val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext) + .withCause(e) + .withAccount(account) + .build() + notifyRefreshError( + applicationContext.getString(R.string.refresh_collections_worker_refresh_couldnt_refresh), + debugIntent + ) + return Result.failure() + } + + // update push registrations + pushRegistrationManager.update(serviceId) + + // Success + return Result.success() + } + + /** + * Used by WorkManager to show a foreground service notification for expedited jobs on Android <12. + */ + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_foreground_notify) + .setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title)) + .setContentText(applicationContext.getString(R.string.foreground_service_notify_text)) + .setStyle(NotificationCompat.BigTextStyle()) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + return ForegroundInfo(NotificationRegistry.NOTIFY_SYNC_EXPEDITED, notification) + } + + private fun notifyRefreshError(contentText: String, contentIntent: Intent) { + notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS, tag = serviceId.toString()) { + NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed)) + .setContentText(contentText) + .setContentIntent( + TaskStackBuilder.create(applicationContext) + .addNextIntentWithParentStack(contentIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + .setSubText(account?.name) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt new file mode 100644 index 0000000..b6f4078 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt @@ -0,0 +1,66 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.property.caldav.CalendarColor +import at.bitfire.dav4jvm.property.caldav.CalendarDescription +import at.bitfire.dav4jvm.property.caldav.CalendarTimezone +import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId +import at.bitfire.dav4jvm.property.caldav.Source +import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet +import at.bitfire.dav4jvm.property.carddav.AddressbookDescription +import at.bitfire.dav4jvm.property.push.PushTransports +import at.bitfire.dav4jvm.property.push.Topic +import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.Owner +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.db.ServiceType + +object ServiceDetectionUtils { + + /** + * WebDAV properties to ask for in a PROPFIND request on a collection. + */ + fun collectionQueryProperties(@ServiceType serviceType: String): Array = + arrayOf( // generic WebDAV properties + CurrentUserPrivilegeSet.NAME, + DisplayName.NAME, + Owner.NAME, + ResourceType.NAME, + PushTransports.NAME, // WebDAV-Push + Topic.NAME + ) + when (serviceType) { // service-specific CalDAV/CardDAV properties + Service.TYPE_CARDDAV -> arrayOf( + AddressbookDescription.NAME + ) + + Service.TYPE_CALDAV -> arrayOf( + CalendarColor.NAME, + CalendarDescription.NAME, + CalendarTimezone.NAME, + CalendarTimezoneId.NAME, + SupportedCalendarComponentSet.NAME, + Source.NAME + ) + + else -> throw IllegalArgumentException() + } + + /** + * Finds out whether given collection is usable for synchronization, by checking that either + * + * - CalDAV/CardDAV: service and collection type match, or + * - WebCal: subscription source URL is not empty. + */ + fun isUsableCollection(service: Service, collection: Collection) = + (service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) || + (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) || + (collection.type == Collection.TYPE_WEBCAL && collection.source != null) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt new file mode 100644 index 0000000..3398fb9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt @@ -0,0 +1,178 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.servicedetection + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet +import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor +import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor +import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet +import at.bitfire.dav4jvm.property.common.HrefListProperty +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.GroupMembership +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavHomeSetRepository +import at.bitfire.davdroid.util.DavUtils.parent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import java.util.logging.Level +import java.util.logging.Logger + +/** + * ServiceRefresher is used to discover and save home sets of a given service. + */ +class ServiceRefresher @AssistedInject constructor( + @Assisted private val service: Service, + @Assisted private val httpClient: OkHttpClient, + private val logger: Logger, + private val homeSetRepository: DavHomeSetRepository +) { + + @AssistedFactory + interface Factory { + fun create(service: Service, httpClient: OkHttpClient): ServiceRefresher + } + + /** + * Home-set class to use depending on the given service type. + */ + private val homeSetClass: Class = + when (service.type) { + Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java + Service.TYPE_CALDAV -> CalendarHomeSet::class.java + else -> throw IllegalArgumentException() + } + + /** + * Home-set properties to ask for in a PROPFIND request to the principal URL, + * depending on the given service type. + */ + private val homeSetProperties: Array = + arrayOf( // generic WebDAV properties + DisplayName.NAME, + GroupMembership.NAME, + ResourceType.NAME + ) + when (service.type) { // service-specific CalDAV/CardDAV properties + Service.TYPE_CARDDAV -> arrayOf( + AddressbookHomeSet.NAME, + ) + + Service.TYPE_CALDAV -> arrayOf( + CalendarHomeSet.NAME, + CalendarProxyReadFor.NAME, + CalendarProxyWriteFor.NAME + ) + + else -> throw IllegalArgumentException() + } + + /** + * Starting at given principal URL, tries to recursively find and save all user relevant home sets. + * + * @param principalUrl URL of principal to query (user-provided principal or current-user-principal) + * @param level Current recursion level (limited to 0, 1 or 2): + * - 0: We assume found home sets belong to the current-user-principal + * - 1 or 2: We assume found home sets don't directly belong to the current-user-principal + * @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once. + * @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets + * more than once, which could overwrite the already set "personal" flag with `false`. + * + * @throws java.io.IOException on I/O errors + * @throws HttpException on HTTP errors + * @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors + */ + internal fun discoverHomesets( + principalUrl: HttpUrl, + level: Int = 0, + alreadyQueriedPrincipals: MutableSet = mutableSetOf(), + alreadySavedHomeSets: MutableSet = mutableSetOf() + ) { + logger.fine("Discovering homesets of $principalUrl") + val relatedResources = mutableSetOf() + + // Query the URL + val principal = DavResource(httpClient, principalUrl) + val personal = level == 0 + try { + principal.propfind(0, *homeSetProperties) { davResponse, _ -> + alreadyQueriedPrincipals += davResponse.href + + // If response holds home sets, save them + davResponse[homeSetClass]?.let { homeSets -> + for (homeSetHref in homeSets.hrefs) + principal.location.resolve(homeSetHref)?.let { homesetUrl -> + val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl) + if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) { + homeSetRepository.insertOrUpdateByUrlBlocking( + // HomeSet is considered personal if this is the outer recursion call, + // This is because we assume the first call to query the current-user-principal + // Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by + // other principals while still being considered "personal" (belonging to the current-user-principal) + // and an owned home set need not always be personal either. + HomeSet(0, service.id, personal, resolvedHomeSetUrl) + ) + alreadySavedHomeSets += resolvedHomeSetUrl + } + } + } + + // Add related principals to be queried afterwards + if (personal) { + val relatedResourcesTypes = listOf( + // current resource is a read/write-proxy for other principals + CalendarProxyReadFor::class.java, + CalendarProxyWriteFor::class.java, + // current resource is a member of a group (principal that can also have proxies) + GroupMembership::class.java + ) + for (type in relatedResourcesTypes) + davResponse[type]?.let { + for (href in it.hrefs) + principal.location.resolve(href)?.let { url -> + relatedResources += url + } + } + } + + // If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too. + davResponse[ResourceType::class.java]?.let { resourceType -> + val proxyProperties = arrayOf( + ResourceType.CALENDAR_PROXY_READ, + ResourceType.CALENDAR_PROXY_WRITE, + ) + if (proxyProperties.any { resourceType.types.contains(it) }) + relatedResources += davResponse.href.parent() + } + } + } catch (e: HttpException) { + if (e.isClientError) + logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e) + else + throw e + } + + // query related resources + if (level <= 1) + for (resource in relatedResources) + if (alreadyQueriedPrincipals.contains(resource)) + logger.warning("$resource already queried, skipping") + else + discoverHomesets( + principalUrl = resource, + level = level + 1, + alreadyQueriedPrincipals = alreadyQueriedPrincipals, + alreadySavedHomeSets = alreadySavedHomeSets + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt new file mode 100644 index 0000000..52ac7bb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -0,0 +1,443 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid.settings + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.os.Bundle +import android.os.Looper +import androidx.annotation.WorkerThread +import androidx.core.os.bundleOf +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.AccountSettings.Companion.CREDENTIALS_LOCK +import at.bitfire.davdroid.settings.AccountSettings.Companion.CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS +import at.bitfire.davdroid.settings.migration.AccountSettingsMigration +import at.bitfire.davdroid.sync.AutomaticSyncManager +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import at.bitfire.davdroid.util.trimToNull +import at.bitfire.vcard4android.GroupMethod +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import net.openid.appauth.AuthState +import java.util.Collections +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Provider + +/** + * Manages settings of an account. + * + * **Must not be called from main thread as it uses blocking I/O and may run migrations.** + * + * @param account account to take settings from + * @param abortOnMissingMigration whether to throw an [IllegalArgumentException] when migrations are missing (useful for testing) + * + * @throws InvalidAccountException on construction when the account doesn't exist (anymore) + * @throws IllegalArgumentException when the account is not a DAVx5 account or migrations are missing and [abortOnMissingMigration] is set + */ +@WorkerThread +class AccountSettings @AssistedInject constructor( + @Assisted val account: Account, + @Assisted val abortOnMissingMigration: Boolean, + private val automaticSyncManager: AutomaticSyncManager, + @ApplicationContext private val context: Context, + private val logger: Logger, + private val migrations: Map>, + private val settingsManager: SettingsManager +) { + + @AssistedFactory + interface Factory { + /** + * **Must not be called on main thread. Throws exceptions!** See [AccountSettings] for details. + */ + @WorkerThread + fun create(account: Account, abortOnMissingMigration: Boolean = false): AccountSettings + } + + init { + if (Looper.getMainLooper() == Looper.myLooper()) + throw IllegalThreadStateException("AccountSettings may not be used on main thread") + } + + val accountManager: AccountManager = AccountManager.get(context) + init { + val allowedAccountTypes = arrayOf( + context.getString(R.string.account_type), + "at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest + ) + if (!allowedAccountTypes.contains(account.type)) + throw IllegalArgumentException("Invalid account type for AccountSettings(): ${account.type}") + + // synchronize because account migration must only be run one time + synchronized(currentlyUpdating) { + if (currentlyUpdating.contains(account)) + logger.warning("AccountSettings created during migration of $account – not running update()") + else { + val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account) + var version = 0 + try { + version = Integer.parseInt(versionStr) + } catch (e: NumberFormatException) { + logger.log(Level.SEVERE, "Invalid account version: $versionStr", e) + } + logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION") + + if (version < CURRENT_VERSION) { + currentlyUpdating += account + try { + update(version, abortOnMissingMigration) + } finally { + currentlyUpdating -= account + } + } + } + } + } + + + // authentication settings + + fun credentials() = Credentials( + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account)?.toSensitiveString(), + + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS), + + accountManager.getUserData(account, KEY_AUTH_STATE)?.let { json -> + AuthState.jsonDeserialize(json) + } + ) + + fun credentials(credentials: Credentials) { + // Basic/Digest auth + accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username) + accountManager.setPassword(account, credentials.password?.asString()) + + // client certificate + accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + + // OAuth + credentials.authState?.let { authState -> + updateAuthState(authState) + } + } + + fun updateAuthState(authState: AuthState) { + accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString()) + } + + /** + * Returns whether users can modify credentials from the account settings screen. + * Checks the value of [CREDENTIALS_LOCK] to be `0` or not equal to [CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS]. + */ + fun changingCredentialsAllowed(): Boolean { + val credentialsLock = settingsManager.getIntOrNull(CREDENTIALS_LOCK) + return credentialsLock == null || credentialsLock != CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS + } + + + // sync. settings + + /** + * Gets the currently set sync interval for this account and data type in seconds. + * + * @param dataType data type of desired sync interval + * @return sync interval in seconds, or `null` if not set (not applicable or only manual sync) + */ + fun getSyncInterval(dataType: SyncDataType): Long? { + val key = when (dataType) { + SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS + SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS + SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS + } + val seconds = accountManager.getUserData(account, key)?.toLong() + return when (seconds) { + null -> settingsManager.getLongOrNull(Settings.DEFAULT_SYNC_INTERVAL) // no setting → default value + SYNC_INTERVAL_MANUALLY -> null // manual sync + else -> seconds + } + } + + /** + * Sets the sync interval for the given data type and updates the automatic sync. + * + * @param dataType data type of the sync interval to set + * @param seconds sync interval in seconds; _null_ for no periodic sync + */ + fun setSyncInterval(dataType: SyncDataType, seconds: Long?) { + val key = when (dataType) { + SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS + SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS + SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS + } + val newValue = seconds ?: SYNC_INTERVAL_MANUALLY + accountManager.setAndVerifyUserData(account, key, newValue.toString()) + + automaticSyncManager.updateAutomaticSync(account, dataType) + } + + fun getSyncWifiOnly() = + if (settingsManager.containsKey(KEY_WIFI_ONLY)) + settingsManager.getBoolean(KEY_WIFI_ONLY) + else + accountManager.getUserData(account, KEY_WIFI_ONLY) != null + + fun setSyncWiFiOnly(wiFiOnly: Boolean) { + accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null) + automaticSyncManager.updateAutomaticSync(account) + } + + fun getSyncWifiOnlySSIDs(): List? = + if (getSyncWifiOnly()) { + val strSsids = if (settingsManager.containsKey(KEY_WIFI_ONLY_SSIDS)) + settingsManager.getString(KEY_WIFI_ONLY_SSIDS) + else + accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS) + strSsids?.split(',') + } else + null + fun setSyncWifiOnlySSIDs(ssids: List?) = + accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY_SSIDS, ssids?.joinToString(",").trimToNull()) + + fun getIgnoreVpns(): Boolean = + when (accountManager.getUserData(account, KEY_IGNORE_VPNS)) { + null -> settingsManager.getBoolean(KEY_IGNORE_VPNS) + "0" -> false + else -> true + } + + fun setIgnoreVpns(ignoreVpns: Boolean) = + accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0") + + + // CalDAV settings + + fun getTimeRangePastDays(): Int? { + val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS) + return if (strDays != null) { + val days = strDays.toInt() + if (days < 0) + null + else + days + } else + DEFAULT_TIME_RANGE_PAST_DAYS + } + + fun setTimeRangePastDays(days: Int?) = + accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString()) + + /** + * Takes the default alarm setting (in this order) from + * + * 1. the local account settings + * 2. the settings provider (unless the value is -1 there). + * + * @return A default reminder shall be created this number of minutes before the start of every + * non-full-day event without reminder. *null*: No default reminders shall be created. + */ + fun getDefaultAlarm() = + accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?: + settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 } + + /** + * Sets the default alarm value in the local account settings, if the new value differs + * from the value of the settings provider. If the new value is the same as the value of + * the settings provider, the local setting will be deleted, so that the settings provider + * value applies. + * + * @param minBefore The number of minutes a default reminder shall be created before the + * start of every non-full-day event without reminder. *null*: No default reminders shall be created. + */ + fun setDefaultAlarm(minBefore: Int?) = + accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM, + if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }) + null + else + minBefore?.toString()) + + fun getManageCalendarColors() = + if (settingsManager.containsKey(KEY_MANAGE_CALENDAR_COLORS)) + settingsManager.getBoolean(KEY_MANAGE_CALENDAR_COLORS) + else + accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null + fun setManageCalendarColors(manage: Boolean) = + accountManager.setAndVerifyUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0") + + fun getEventColors() = + if (settingsManager.containsKey(KEY_EVENT_COLORS)) + settingsManager.getBoolean(KEY_EVENT_COLORS) + else + accountManager.getUserData(account, KEY_EVENT_COLORS) != null + fun setEventColors(useColors: Boolean) = + accountManager.setAndVerifyUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null) + + // CardDAV settings + + fun getGroupMethod(): GroupMethod { + val name = settingsManager.getString(KEY_CONTACT_GROUP_METHOD) ?: + accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD) + if (name != null) + try { + return GroupMethod.valueOf(name) + } + catch (_: IllegalArgumentException) { + } + return GroupMethod.GROUP_VCARDS + } + + fun setGroupMethod(method: GroupMethod) { + accountManager.setAndVerifyUserData(account, KEY_CONTACT_GROUP_METHOD, method.name) + } + + + // UI settings + + /** + * Whether to show only personal collections in the UI + * + * @return *true* if only personal collections shall be shown; *false* otherwise + */ + fun getShowOnlyPersonal(): Boolean = when (settingsManager.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) { + 0 -> false + 1 -> true + else /* including -1 */ -> accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null + } + + /** + * Whether the user shall be able to change the setting (= setting not locked) + * + * @return *true* if the setting is locked; *false* otherwise + */ + fun getShowOnlyPersonalLocked(): Boolean = when (settingsManager.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) { + 0, 1 -> true + else /* including -1 */ -> false + } + + fun setShowOnlyPersonal(showOnlyPersonal: Boolean) { + accountManager.setAndVerifyUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null) + } + + + // update from previous account settings + + private fun update(baseVersion: Int, abortOnMissingMigration: Boolean) { + for (toVersion in baseVersion+1 ..CURRENT_VERSION) { + val fromVersion = toVersion - 1 + logger.info("Updating account ${account.name} settings version $fromVersion → $toVersion") + + val migration = migrations[toVersion] + if (migration == null) { + logger.severe("No AccountSettings migration $fromVersion → $toVersion") + if (abortOnMissingMigration) + throw IllegalArgumentException("Missing AccountSettings migration $fromVersion → $toVersion") + } else { + try { + migration.get().migrate(account) + + logger.info("Account settings version update to $toVersion successful") + accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString()) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't run AccountSettings migration $fromVersion → $toVersion", e) + } + } + } + } + + + companion object { + + const val CURRENT_VERSION = 20 + const val KEY_SETTINGS_VERSION = "version" + + const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks" + const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars" + + /** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */ + const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks" + + const val KEY_USERNAME = "user_name" + const val KEY_CERTIFICATE_ALIAS = "certificate_alias" + + const val CREDENTIALS_LOCK = "login_credentials_lock" + const val CREDENTIALS_LOCK_NO_LOCK = 0 + const val CREDENTIALS_LOCK_AT_LOGIN = 1 + const val CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS = 2 + + /** OAuth [AuthState] (serialized as JSON) */ + const val KEY_AUTH_STATE = "auth_state" + + const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false) + const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs + const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection + + /** Time range limitation to the past [in days]. Values: + * + * - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS) + * - <0 (typically -1): no limit + * - n>0: entries more than n days in the past won't be synchronized + */ + const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days" + const val DEFAULT_TIME_RANGE_PAST_DAYS = 90 + + /** + * Whether a default alarm shall be assigned to received events/tasks which don't have an alarm. + * Value can be null (no default alarm) or an integer (default alarm shall be created this + * number of minutes before the event/task). + */ + const val KEY_DEFAULT_ALARM = "default_alarm" + + /** Whether DAVx5 sets the local calendar color to the value from service DB at every sync + value = *null* (not existing): true (default); + "0" false */ + const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors" + + /** Whether DAVx5 populates and uses CalendarContract.Colors + value = *null* (not existing) false (default); + "1" true */ + const val KEY_EVENT_COLORS = "event_colors" + + /** Contact group method: + *null (not existing)* groups as separate vCards (default); + "CATEGORIES" groups are per-contact CATEGORIES + */ + const val KEY_CONTACT_GROUP_METHOD = "contact_group_method" + + /** UI preference: Show only personal collections + value = *null* (not existing) show all collections (default); + "1" show only personal collections */ + const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal" + + internal const val SYNC_INTERVAL_MANUALLY = -1L + + /** Static property to remember which AccountSettings updates/migrations are currently running */ + val currentlyUpdating = Collections.synchronizedSet(mutableSetOf()) + + fun initialUserData(credentials: Credentials?): Bundle { + val bundle = bundleOf(KEY_SETTINGS_VERSION to CURRENT_VERSION.toString()) + + if (credentials != null) { + if (credentials.username != null) + bundle.putString(KEY_USERNAME, credentials.username) + + if (credentials.certificateAlias != null) + bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + + if (credentials.authState != null) + bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString()) + } + + return bundle + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/Credentials.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/Credentials.kt new file mode 100644 index 0000000..5278419 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/Credentials.kt @@ -0,0 +1,46 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import at.bitfire.davdroid.util.SensitiveString +import net.openid.appauth.AuthState + +/** + * Represents credentials that are used to authenticate against a CalDAV/CardDAV/WebDAV server. + * + * Note: [authState] can change from request to request, so make sure that you have an up-to-date + * copy when using it. + */ +data class Credentials( + /** username for Basic / Digest auth */ + val username: String? = null, + /** password for Basic / Digest auth */ + val password: SensitiveString? = null, + + /** alias of an client certificate that is present on the system */ + val certificateAlias: String? = null, + + /** OAuth authorization state */ + val authState: AuthState? = null +) { + + override fun toString(): String { + val s = mutableListOf() + + if (username != null) + s += "userName=$username" + if (password != null) + s += "password=*****" + + if (certificateAlias != null) + s += "certificateAlias=$certificateAlias" + + if (authState != null) // contains sensitive information (refresh token, access token) + s += "authState=${authState.jsonSerializeString()}" + + return "Credentials(" + s.joinToString(", ") + ")" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/DefaultsProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/DefaultsProvider.kt new file mode 100644 index 0000000..4755bcc --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/DefaultsProvider.kt @@ -0,0 +1,98 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import at.bitfire.davdroid.TextTable +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import java.io.Writer +import javax.inject.Inject + +class DefaultsProvider @Inject constructor(): SettingsProvider { + + val booleanDefaults = mutableMapOf( + Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false), + Pair(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false), + Pair(Settings.IGNORE_VPN_NETWORK_CAPABILITY, true) + ) + + val intDefaults = mapOf( + Pair(Settings.PRESELECT_COLLECTIONS, Settings.PRESELECT_COLLECTIONS_NONE), + Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM), + Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS + ) + + val longDefaults = mapOf( + Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */ + ) + + val stringDefaults = mapOf( + Pair(Settings.PROXY_HOST, "localhost"), + Pair(Settings.PRESELECT_COLLECTIONS_EXCLUDED, "/z-app-generated--contactsinteraction--recent/") // Nextcloud "Recently Contacted" address book + ) + + + override fun canWrite() = false + + override fun close() { + // no resources to close + } + + override fun setOnChangeListener(listener: SettingsProvider.OnChangeListener) { + // default settings never change + } + + override fun forceReload() { + // default settings never change + } + + + override fun contains(key: String) = + booleanDefaults.containsKey(key) || + intDefaults.containsKey(key) || + longDefaults.containsKey(key) || + stringDefaults.containsKey(key) + + override fun getBoolean(key: String) = booleanDefaults[key] + override fun getInt(key: String) = intDefaults[key] + override fun getLong(key: String) = longDefaults[key] + override fun getString(key: String) = stringDefaults[key] + + override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError() + override fun putInt(key: String, value: Int?) = throw NotImplementedError() + override fun putLong(key: String, value: Long?) = throw NotImplementedError() + override fun putString(key: String, value: String?) = throw NotImplementedError() + + override fun remove(key: String) = throw NotImplementedError() + + + override fun dump(writer: Writer) { + val strValues = mutableMapOf() + strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() }) + strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() }) + strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() }) + strValues.putAll(stringDefaults) + + val table = TextTable("Setting", "Value") + for ((key, value) in strValues.toSortedMap()) + table.addLine(key, value) + writer.write(table.toString()) + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class DefaultsProviderModule { + @Binds + @IntoMap + @IntKey(/* priority */ 0) + abstract fun defaultsProvider(impl: DefaultsProvider): SettingsProvider + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/Settings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/Settings.kt new file mode 100644 index 0000000..985ee85 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/Settings.kt @@ -0,0 +1,69 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import androidx.appcompat.app.AppCompatDelegate +import at.bitfire.davdroid.settings.Settings.PRESELECT_COLLECTIONS_EXCLUDED + +object Settings { + + const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs" + + const val PROXY_TYPE = "proxy_type" // Integer + const val PROXY_TYPE_SYSTEM = -1 + const val PROXY_TYPE_NONE = 0 + const val PROXY_TYPE_HTTP = 1 + const val PROXY_TYPE_SOCKS = 2 + const val PROXY_HOST = "proxy_host" // String + const val PROXY_PORT = "proxy_port" // Integer + + /** + * Whether to ignore VPNs at internet connection detection, true by default because VPN connections + * seem to include "VALIDATED" by default even without actual internet connection + */ + const val IGNORE_VPN_NETWORK_CAPABILITY = "ignore_vpns" // Boolean + + /** + * Default sync interval (Long), in seconds. + * Used to initialize an account. + */ + const val DEFAULT_SYNC_INTERVAL = "default_sync_interval" + + /** + * Preferred theme (light/dark). Value must be one of [AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM] + * (default if setting is missing), [AppCompatDelegate.MODE_NIGHT_NO] or [AppCompatDelegate.MODE_NIGHT_YES]. + */ + const val PREFERRED_THEME = "preferred_theme" + const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + + /** + * Selected tasks app. When at least one tasks app is installed, this setting is set to its sync authority. + * In case of multiple available tasks app, the user can choose one and this setting will reflect the selected one. + * + * This setting may even be set if the corresponding tasks app is not installed because it only reflects the user's choice. + */ + const val SELECTED_TASKS_PROVIDER = "preferred_tasks_provider" + + /** whether collections are automatically selected for synchronization after their initial detection */ + const val PRESELECT_COLLECTIONS = "preselect_collections" + /** collections are not automatically selected for synchronization */ + const val PRESELECT_COLLECTIONS_NONE = 0 + /** all collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */ + const val PRESELECT_COLLECTIONS_ALL = 1 + /** personal collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */ + const val PRESELECT_COLLECTIONS_PERSONAL = 2 + + /** regular expression to match URLs of collections to be excluded from pre-selection */ + const val PRESELECT_COLLECTIONS_EXCLUDED = "preselect_collections_excluded" + + + /** whether all address books are forced to be read-only */ + const val FORCE_READ_ONLY_ADDRESSBOOKS = "force_read_only_addressbooks" + + + /** max. number of accounts */ + const val MAX_ACCOUNTS = "max_accounts" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt new file mode 100644 index 0000000..72cff51 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt @@ -0,0 +1,213 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import android.util.NoSuchPropertyException +import androidx.annotation.AnyThread +import androidx.annotation.VisibleForTesting +import at.bitfire.davdroid.settings.SettingsManager.OnChangeListener +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import java.io.Writer +import java.lang.ref.WeakReference +import java.util.LinkedList +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Settings manager which coordinates [SettingsProvider]s to read/write + * application settings. + */ +@Singleton +class SettingsManager @Inject constructor( + private val logger: Logger, + providerMap: Map +): SettingsProvider.OnChangeListener { + + private val providers = LinkedList() + private var writeProvider: SettingsProvider? = null + + private val observers = LinkedList>() + + init { + providerMap // get providers from Hilt + .toSortedMap() // sort by Int key + .values.reversed() // take reverse-sorted values (because high priority numbers shall be processed first) + .forEach { provider -> + logger.info("Loading settings provider: ${provider.javaClass.name}") + + // register for changes + provider.setOnChangeListener(this) + + // add to list of available providers + providers += provider + } + + // settings will be written to the first writable provider + writeProvider = providers.firstOrNull { it.canWrite() } + logger.info("Changed settings are handled by $writeProvider") + } + + /** + * Requests all providers to reload their settings. + */ + @AnyThread + fun forceReload() { + for (provider in providers) + provider.forceReload() + + // notify possible listeners + onSettingsChanged(null) + } + + + /*** OBSERVERS ***/ + + fun addOnChangeListener(observer: OnChangeListener) { + synchronized(observers) { + observers += WeakReference(observer) + } + } + + fun removeOnChangeListener(observer: OnChangeListener) { + synchronized(observers) { + observers.removeAll { it.get() == null || it.get() == observer } + } + } + + /** + * Notifies registered listeners about changes in the configuration. + * Called by config providers when settings have changed. + */ + @AnyThread + override fun onSettingsChanged(key: String?) { + synchronized(observers) { + for (observer in observers.mapNotNull { it.get() }) + observer.onSettingsChanged() + } + } + + /** + * Returns a Flow that + * + * - always emits the initial value of the setting, and then + * - emits the new value whenever the setting changes. + * + * @param getValue used to determine the current value of the setting + */ + @VisibleForTesting + internal fun observerFlow(getValue: () -> T): Flow = callbackFlow { + // emit value on changes + val listener = OnChangeListener { + trySend(getValue()) + } + addOnChangeListener(listener) + + // get current value and emit it as first state + trySend(getValue()) + + // wait and clean up + awaitClose { removeOnChangeListener(listener) } + } + + + /*** SETTINGS ACCESS ***/ + + fun containsKey(key: String) = providers.any { it.contains(key) } + fun containsKeyFlow(key: String): Flow = observerFlow { containsKey(key) } + + private fun getValue(key: String, reader: (SettingsProvider) -> T?): T? { + logger.fine("Looking up setting $key") + val result: T? = null + for (provider in providers) + try { + val value = reader(provider) + logger.finer("${provider::class.java.simpleName}: $key = $value") + if (value != null) { + logger.fine("Looked up setting $key -> $value") + return value + } + } catch(e: Exception) { + logger.log(Level.SEVERE, "Couldn't read setting from $provider", e) + } + logger.fine("Looked up setting $key -> no result") + return result + } + + fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) } + fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(key) + fun getBooleanFlow(key: String): Flow = observerFlow { getBooleanOrNull(key) } + fun getBooleanFlow(key: String, defaultValue: Boolean): Flow = observerFlow { getBooleanOrNull(key) ?: defaultValue } + + fun getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) } + fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(key) + fun getIntFlow(key: String): Flow = observerFlow { getIntOrNull(key) } + + fun getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) } + fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key) + + fun getString(key: String) = getValue(key) { provider -> provider.getString(key) } + fun getStringFlow(key: String): Flow = observerFlow { getString(key) } + + + fun isWritable(key: String): Boolean { + for (provider in providers) { + if (provider.canWrite()) + return true + else if (provider.contains(key)) + // non-writeable provider contains this key -> setting will always be provided by this read-only provider + return false + } + return false + } + + private fun putValue(key: String, value: T?, writer: (SettingsProvider) -> Unit) { + logger.fine("Trying to write setting $key = $value") + val provider = writeProvider ?: return + try { + writer(provider) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't write setting to $writeProvider", e) + } + } + + fun putBoolean(key: String, value: Boolean?) = + putValue(key, value) { provider -> provider.putBoolean(key, value) } + + fun putInt(key: String, value: Int?) = + putValue(key, value) { provider -> provider.putInt(key, value) } + + fun putLong(key: String, value: Long?) = + putValue(key, value) { provider -> provider.putLong(key, value) } + + fun putString(key: String, value: String?) = + putValue(key, value) { provider -> provider.putString(key, value) } + + fun remove(key: String) = putString(key, null) + + + /*** HELPERS ***/ + + fun dump(writer: Writer) { + for ((idx, provider) in providers.withIndex()) { + writer.write("${idx + 1}. ${provider::class.java.simpleName} canWrite=${provider.canWrite()}\n") + provider.dump(writer) + } + } + + + fun interface OnChangeListener { + /** + * Will be called when something has changed in a [SettingsProvider]. + * May run in worker thread! + */ + @AnyThread + fun onSettingsChanged() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsProvider.kt new file mode 100644 index 0000000..b43405c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsProvider.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import androidx.annotation.AnyThread +import java.io.Writer + +/** + * Defines a settings provider, which provides settings from a certain source + * to the [SettingsManager]. + * + * Implementations must be thread-safe and synchronize get/put operations on their own. + */ +interface SettingsProvider { + + fun interface OnChangeListener { + /** + * Called when a setting has changed. + * + * @param key The key of the setting that has changed, or null if the key is not + * available. In this case, the listener should reload all settings. + */ + fun onSettingsChanged(key: String?) + } + + + /** + * Whether this provider can write settings. + * + * If this method returns false, the put...() methods will never be called for this provider. + * + * @return true = this provider provides read/write settings; + * false = this provider provides read-only settings + */ + fun canWrite(): Boolean + + /** + * Closes the provider and releases resources. + */ + fun close() + + /** + * Sets an on-changed listener. The provider calls the listener whenever a setting + * has changed. + */ + fun setOnChangeListener(listener: OnChangeListener) + + @AnyThread + fun forceReload() + + + fun contains(key: String): Boolean + + fun getBoolean(key: String): Boolean? + fun getInt(key: String): Int? + fun getLong(key: String): Long? + fun getString(key: String): String? + + fun putBoolean(key: String, value: Boolean?) + fun putInt(key: String, value: Int?) + fun putLong(key: String, value: Long?) + fun putString(key: String, value: String?) + + fun remove(key: String) + + + fun dump(writer: Writer) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt new file mode 100644 index 0000000..aa5a8b9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/SharedPreferencesProvider.kt @@ -0,0 +1,155 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import at.bitfire.davdroid.TextTable +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import java.io.Writer +import java.util.logging.Logger +import javax.inject.Inject + +class SharedPreferencesProvider @Inject constructor( + @ApplicationContext val context: Context, + private val logger: Logger +): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener { + + companion object { + private const val META_VERSION = "version" + private const val CURRENT_VERSION = 0 + } + + private var onChangeListener: SettingsProvider.OnChangeListener? = null + private val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + init { + val meta = context.getSharedPreferences("meta", MODE_PRIVATE) + val version = meta.getInt(META_VERSION, -1) + if (version == -1) { + // first call, check whether to migrate from SQLite database (DAVdroid <1.9) + firstCall() + meta.edit { + putInt(META_VERSION, CURRENT_VERSION) + } + } + + preferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun forceReload() { + } + + override fun close() { + preferences.unregisterOnSharedPreferenceChangeListener(this) + } + + override fun setOnChangeListener(listener: SettingsProvider.OnChangeListener) { + onChangeListener = listener + } + + override fun canWrite() = true + + + + override fun contains(key: String) = preferences.contains(key) + + private fun getValue(key: String, reader: (SharedPreferences) -> T): T? = + try { + if (preferences.contains(key)) + reader(preferences) + else + null + } catch(e: ClassCastException) { + null + } + + override fun getBoolean(key: String) = + getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) } + + override fun getInt(key: String) = + getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) } + + override fun getLong(key: String) = + getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) } + + override fun getString(key: String): String? = + preferences.getString(key, /* will never be used: */ null) + + + private fun putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit) { + if (value == null) + remove(key) + else { + logger.fine("Writing setting $key = $value") + preferences.edit { + writer(this, value) + } + } + } + + override fun putBoolean(key: String, value: Boolean?) = + putValue(key, value) { editor, v -> editor.putBoolean(key, v) } + + override fun putInt(key: String, value: Int?) = + putValue(key, value) { editor, v -> editor.putInt(key, v) } + + override fun putLong(key: String, value: Long?) = + putValue(key, value) { editor, v -> editor.putLong(key, v) } + + override fun putString(key: String, value: String?) = + putValue(key, value) { editor, v -> editor.putString(key, v) } + + override fun remove(key: String) { + logger.fine("Removing setting $key") + preferences.edit { + remove(key) + } + } + + + override fun dump(writer: Writer) { + val table = TextTable("Setting", "Value") + for ((key, value) in preferences.all.toSortedMap()) + table.addLine(key, value) + writer.write(table.toString()) + } + + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { + onChangeListener?.onSettingsChanged(key) + } + + + private fun firstCall() { + // remove possible artifacts from DAVdroid <1.9 + preferences.edit { + remove("override_proxy") + remove("proxy_host") + remove("proxy_port") + remove("log_to_external_storage") + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class SharedPreferencesProviderModule { + @Binds + @IntoMap + @IntKey(/* priority */ 10) + abstract fun sharedPreferencesProvider(impl: SharedPreferencesProvider): SettingsProvider + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt new file mode 100644 index 0000000..208a473 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt @@ -0,0 +1,25 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import at.bitfire.davdroid.settings.AccountSettings + +interface AccountSettingsMigration { + + /** + * Migrate the account settings from the old version to the new version. + * + * **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].** + * + * This method should depend on current architecture of [AccountSettings] as little as possible. Methods of [AccountSettings] + * may change in future and it shouldn't be necessary to change migrations as well. So it's better to operate "low-level" + * directly on the account user-data – which is also better testable. + * + * @param account The account to migrate + */ + fun migrate(account: Account) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt new file mode 100644 index 0000000..fb561c7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt @@ -0,0 +1,69 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.Context +import android.content.pm.PackageManager +import android.provider.CalendarContract +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Reminders +import androidx.core.content.ContextCompat +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.resource.LocalTask +import at.bitfire.ical4android.TaskProvider +import at.techbee.jtx.JtxContract.asSyncAdapter +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import org.dmfs.tasks.contract.TaskContract +import javax.inject.Inject +import kotlin.use + +/** + * Task synchronization now handles alarms, categories, relations and unknown properties. + * Setting task ETags to null will cause them to be downloaded (and parsed) again. + * + * Also update the allowed reminder types for calendars. + */ +class AccountSettingsMigration10 @Inject constructor( + @ApplicationContext private val context: Context +): AccountSettingsMigration { + + override fun migrate(account: Account) { + TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider -> + val tasksUri = provider.tasksUri().asSyncAdapter(account) + val emptyETag = contentValuesOf(LocalTask.COLUMN_ETAG to null) + provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null) + } + + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider -> + provider.update( + Calendars.CONTENT_URI.asSyncAdapter(account), + contentValuesOf( + Calendars.ALLOWED_REMINDERS to arrayOf( + Reminders.METHOD_DEFAULT, + Reminders.METHOD_ALERT, + Reminders.METHOD_EMAIL + ).joinToString(",") { it.toString() } + ), null, null) + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(10) + abstract fun provide(impl: AccountSettingsMigration10): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration11.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration11.kt new file mode 100644 index 0000000..530c118 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration11.kt @@ -0,0 +1,61 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentResolver +import android.content.Context +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.AccountSettings.Companion.SYNC_INTERVAL_MANUALLY +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Inject + +/** + * The tasks sync interval should be stored in account settings. It's used to set the sync interval + * again when the tasks provider is switched. + */ +class AccountSettingsMigration11 @Inject constructor( + @ApplicationContext private val context: Context, + private val tasksAppManager: TasksAppManager +): AccountSettingsMigration { + + override fun migrate(account: Account) { + val accountManager: AccountManager = AccountManager.get(context) + tasksAppManager.currentProvider()?.let { provider -> + val interval = getSyncFrameworkInterval(account, provider.authority) + if (interval != null) + accountManager.setAndVerifyUserData(account, AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString()) + } + } + + private fun getSyncFrameworkInterval(account: Account, authority: String): Long? { + if (ContentResolver.getIsSyncable(account, authority) <= 0) + return null + + return if (ContentResolver.getSyncAutomatically(account, authority)) + ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY + else + SYNC_INTERVAL_MANUALLY + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(11) + abstract fun provide(impl: AccountSettingsMigration11): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt new file mode 100644 index 0000000..684b1c4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt @@ -0,0 +1,126 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.ContentUris +import android.content.Context +import android.content.pm.PackageManager +import android.provider.CalendarContract +import android.util.Base64 +import androidx.core.content.ContextCompat +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import at.techbee.jtx.JtxContract.asSyncAdapter +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.property.Url +import java.io.ByteArrayInputStream +import java.io.ObjectInputStream +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.use + +/** + * Store event URLs as URL (extended property) instead of unknown property. At the same time, + * convert legacy unknown properties to the current format. + */ +class AccountSettingsMigration12 @Inject constructor( + @ApplicationContext private val context: Context, + private val logger: Logger +): AccountSettingsMigration { + + override fun migrate(account: Account) { + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) { + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider -> + // Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query + // to the given account! So all extended properties will be processed number-of-accounts times. + val extUri = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account) + + provider.query( + extUri, arrayOf( + CalendarContract.ExtendedProperties._ID, // idx 0 + CalendarContract.ExtendedProperties.NAME, // idx 1 + CalendarContract.ExtendedProperties.VALUE // idx 2 + ), null, null, null + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val rawValue = cursor.getString(2) + + val uri by lazy { + ContentUris.withAppendedId(CalendarContract.ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account) + } + + when (cursor.getString(1)) { + UnknownProperty.CONTENT_ITEM_TYPE -> { + // unknown property; check whether it's a URL + try { + val property = UnknownProperty.fromJsonString(rawValue) + if (property is Url) { // rewrite to MIMETYPE_URL + val newValues = contentValuesOf( + CalendarContract.ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL, + CalendarContract.ExtendedProperties.VALUE to property.value + ) + provider.update(uri, newValues, null, null) + } + } catch (e: Exception) { + logger.log( + Level.WARNING, + "Couldn't rewrite URL from unknown property to ${AndroidEvent2.EXTNAME_URL}", + e + ) + } + } + + "unknown-property" -> { + // unknown property (deprecated format); convert to current format + try { + val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP)) + ObjectInputStream(stream).use { + (it.readObject() as? Property)?.let { property -> + // rewrite to current format + val newValues = contentValuesOf( + CalendarContract.ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, + CalendarContract.ExtendedProperties.VALUE to UnknownProperty.toJsonString(property) + ) + provider.update(uri, newValues, null, null) + } + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e) + } + } + + "unknown-property.v2" -> { + // unknown property (deprecated MIME type); rewrite to current MIME type + val newValues = contentValuesOf(CalendarContract.ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE) + provider.update(uri, newValues, null, null) + } + } + } + } + } + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(12) + abstract fun provide(impl: AccountSettingsMigration12): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration13.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration13.kt new file mode 100644 index 0000000..a4bbd69 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration13.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.Context +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import at.bitfire.davdroid.settings.Settings +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Inject + +/** + * Not a per-account migration, but not a database migration, too, so it fits best there. + * Best future solution would be that SettingsManager manages versions and migrations. + * + * Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port. + */ +class AccountSettingsMigration13 @Inject constructor( + @ApplicationContext private val context: Context +): AccountSettingsMigration { + + override fun migrate(account: Account) { + // proxy settings are managed by SharedPreferencesProvider + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + // old setting names + val overrideProxy = "override_proxy" + val overrideProxyHost = "override_proxy_host" + val overrideProxyPort = "override_proxy_port" + + preferences.edit { + if (preferences.contains(overrideProxy)) { + if (preferences.getBoolean(overrideProxy, false)) + // override_proxy set, migrate to proxy_type = HTTP + putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP) + remove(overrideProxy) + } + if (preferences.contains(overrideProxyHost)) { + preferences.getString(overrideProxyHost, null)?.let { host -> + putString(Settings.PROXY_HOST, host) + } + remove(overrideProxyHost) + } + if (preferences.contains(overrideProxyPort)) { + val port = preferences.getInt(overrideProxyPort, 0) + if (port != 0) + putInt(Settings.PROXY_PORT, port) + remove(overrideProxyPort) + } + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(13) + abstract fun provide(impl: AccountSettingsMigration13): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration14.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration14.kt new file mode 100644 index 0000000..d68614a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration14.kt @@ -0,0 +1,96 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.ContentResolver +import android.provider.CalendarContract +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.ical4android.TaskProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Disables all sync adapter periodic syncs for every authority. Then enables corresponding periodic sync workers. + */ +class AccountSettingsMigration14 @Inject constructor( + private val accountSettingsFactory: AccountSettings.Factory, + private val logger: Logger +): AccountSettingsMigration { + + override fun migrate(account: Account) { + // Cancel any potentially running syncs for this account (sync framework) + ContentResolver.cancelSync(account, null) + + val authorities = listOf( + "at.bitfire.davdroid.addressbooks", + CalendarContract.AUTHORITY, + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.OpenTasks.authority, + TaskProvider.ProviderName.TasksOrg.authority + ) + + // Disable periodic syncs (sync adapter framework) + for (authority in authorities) + disableSyncFramework(account, authority) + + // Enable PeriodicSyncWorker (WorkManager), with known intervals + for (dataType in SyncDataType.entries) + enableWorkManager(account, dataType) + } + + private fun enableWorkManager(account: Account, dataType: SyncDataType) { + val accountSettings = accountSettingsFactory.create(account) + val enabled: Boolean = accountSettings.getSyncInterval(dataType)?.let { syncInterval -> + accountSettings.setSyncInterval(dataType, syncInterval) + true + } == true + logger.info("PeriodicSyncWorker for $account/$dataType enabled=$enabled") + } + + private fun disableSyncFramework(account: Account, authority: String) { + // Disable periodic syncs (sync adapter framework) + val disable: () -> Boolean = { + /* Ugly hack: because there is no callback for when the sync status/interval has been + updated, we need to make this call blocking. */ + for (sync in ContentResolver.getPeriodicSyncs(account, authority)) + ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras) + + // check whether syncs are really disabled + var result = true + for (sync in ContentResolver.getPeriodicSyncs(account, authority)) { + logger.info("Sync framework still has a periodic sync for $account/$authority: $sync") + result = false + } + result + } + // try up to 10 times with 100 ms pause + var success = false + for (idxTry in 0 until 10) { + success = disable() + if (success) + break + Thread.sleep(200) + } + logger.info("Sync framework periodic syncs for $account/$authority disabled=$success") + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(14) + abstract fun provide(impl: AccountSettingsMigration14): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration15.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration15.kt new file mode 100644 index 0000000..fa654e9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration15.kt @@ -0,0 +1,40 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import at.bitfire.davdroid.sync.AutomaticSyncManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Inject + +/** + * Updates the periodic sync workers by re-setting the same sync interval. + * + * The goal is to add the [at.bitfire.davdroid.sync.worker.BaseSyncWorker.commonTag] to all existing periodic sync workers so that they + * can be detected correctly. + */ +class AccountSettingsMigration15 @Inject constructor( + private val automaticSyncManager: AutomaticSyncManager +): AccountSettingsMigration { + + override fun migrate(account: Account) { + automaticSyncManager.updateAutomaticSync(account) + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(15) + abstract fun provide(impl: AccountSettingsMigration15): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration16.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration16.kt new file mode 100644 index 0000000..1d77d7b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration16.kt @@ -0,0 +1,67 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.Context +import androidx.work.WorkManager +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Between DAVx5 4.4.1-beta.1 and 4.4.1-rc.1 (both v15), the periodic sync workers were renamed (moved to another + * package) and thus automatic synchronization stopped (because the enqueued workers rely on the full class + * name and no new workers were enqueued). Here we enqueue all periodic sync workers again with the correct class name. + */ +class AccountSettingsMigration16 @Inject constructor( + private val accountSettingsFactory: AccountSettings.Factory, + @ApplicationContext private val context: Context, + private val logger: Logger, + private val syncWorkerManager: SyncWorkerManager +): AccountSettingsMigration { + + override fun migrate(account: Account) { + for (dataType in SyncDataType.entries) { + logger.info("Re-enqueuing periodic sync workers for $account/$dataType, if necessary") + + /* A maybe existing periodic worker references the old class name (even if it failed and/or is not active). So + we need to explicitly disable and prune all workers. Just updating the worker is not enough – WorkManager will update + the work details, but not the class name. */ + val disableOp = syncWorkerManager.disablePeriodic(account, dataType) + disableOp.result.get() // block until worker with old name is disabled + + val pruneOp = WorkManager.getInstance(context).pruneWork() + pruneOp.result.get() // block until worker with old name is removed from DB + + val accountSettings = accountSettingsFactory.create(account) + val interval = accountSettings.getSyncInterval(dataType) + if (interval != null) { + // There's a sync interval for this account/authority; a periodic sync worker should be there, too. + val onlyWifi = accountSettings.getSyncWifiOnly() + syncWorkerManager.enablePeriodic(account, dataType, interval, onlyWifi) + } + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(16) + abstract fun provide(impl: AccountSettingsMigration16): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration17.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration17.kt new file mode 100644 index 0000000..7c255dd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration17.kt @@ -0,0 +1,96 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.provider.ContactsContract +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import kotlinx.coroutines.runBlocking +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +/** + * With DAVx5 4.4.3 address book account names now contain the collection ID as a unique + * identifier. We need to update the address book account names. + */ +class AccountSettingsMigration17 @Inject constructor( + private val collectionRepository: DavCollectionRepository, + @ApplicationContext private val context: Context, + private val localAddressBookFactory: LocalAddressBook.Factory, + private val localAddressBookStore: LocalAddressBookStore, + private val logger: Logger, + private val serviceRepository: DavServiceRepository +): AccountSettingsMigration { + + override fun migrate(account: Account) { + val addressBookAccountType = context.getString(R.string.account_type_address_book) + try { + context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + } catch (e: SecurityException) { + // Not setting the collection ID will cause the address books to removed and fully re-synced as soon as there are permissions. + logger.log(Level.WARNING, "Missing permissions for contacts authority, won't set collection ID for address books", e) + null + }?.use { provider -> + runBlocking { + val service = serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV) ?: return@runBlocking + + val accountManager = AccountManager.get(context) + // Get all old address books of this account, i.e. the ones which have a "real_account_name" of this account. + // After this migration is run, address books won't be associated to accounts anymore but only to their respective collection/URL. + val oldAddressBookAccounts = accountManager.getAccountsByType(addressBookAccountType) + .filter { addressBookAccount -> + account.name == accountManager.getUserData(addressBookAccount, "real_account_name") + } + + for (oldAddressBookAccount in oldAddressBookAccounts) { + // Old address books only have a URL, so use it to determine the collection ID + logger.info("Migrating address book ${oldAddressBookAccount.name}") + val oldAddressBook = localAddressBookFactory.create(account, oldAddressBookAccount, provider) + val url = accountManager.getUserData(oldAddressBookAccount, LOCAL_ADDRESS_BOOK_ACCOUNT_USER_DATA_URL) + collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection -> + // Set collection ID and rename the account + localAddressBookStore.update(provider, oldAddressBook, collection) + // The user-data-url is not being set in localAddressBookStore.update() anymore, + // but we need to keep it for the migration + accountManager.setAndVerifyUserData( + oldAddressBook.addressBookAccount, + LOCAL_ADDRESS_BOOK_ACCOUNT_USER_DATA_URL, + collection.url.toString() + ) + } + } + } + } + } + + companion object { + private const val LOCAL_ADDRESS_BOOK_ACCOUNT_USER_DATA_URL = "url" + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(17) + abstract fun provide(impl: AccountSettingsMigration17): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt new file mode 100644 index 0000000..a855460 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration18.kt @@ -0,0 +1,77 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * v17 had removed the binding between address book accounts and accounts and introduced + * the binding to collection IDs instead. + * + * However, it turned out that the account binding is needed even with collection IDs for the case + * that the collection is not available in the database anymore (for instance, because it has been + * removed on the server). In that case, the [at.bitfire.davdroid.sync.Syncer] still needs to get + * a list of all address book accounts that belong to the account, and not _all_ address books. + * + * So this migration again assigns address book accounts to accounts. + */ +class AccountSettingsMigration18 @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase +): AccountSettingsMigration { + + override fun migrate(account: Account) { + val accountManager = AccountManager.get(context) + runBlocking { + db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service -> + db.collectionDao().getByService(service.id).forEach { collection -> + // Find associated address book account by collection ID (if it exists) + val addressBookAccount = accountManager + .getAccountsByType(context.getString(R.string.account_type_address_book)) + .firstOrNull { + accountManager.getUserData( + it, + LocalAddressBook.USER_DATA_COLLECTION_ID + ) == collection.id.toString() + } + + if (addressBookAccount != null) { + // (Re-)assign address book to account + accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name) + accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type) + } + } + } + } + + // Address books without an assigned account will be removed by AccountsCleanupWorker + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(18) + abstract fun provide(impl: AccountSettingsMigration18): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration19.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration19.kt new file mode 100644 index 0000000..344629e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration19.kt @@ -0,0 +1,59 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.Context +import android.provider.CalendarContract +import androidx.work.WorkManager +import at.bitfire.davdroid.sync.AutomaticSyncManager +import at.bitfire.ical4android.TaskProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Inject + +/** + * Sync workers are now not per authority anymore, but per [at.bitfire.davdroid.sync.SyncDataType]. So we have to + * + * 1. cancel all current periodic sync workers (which have "authority" input data), + * 2. re-enqueue periodic sync workers (now with "data type" input data), if applicable. + */ +class AccountSettingsMigration19 @Inject constructor( + @ApplicationContext private val context: Context, + private val automaticSyncManager: AutomaticSyncManager +): AccountSettingsMigration { + + override fun migrate(account: Account) { + // cancel old workers + val workManager = WorkManager.getInstance(context) + val authorities = listOf( + "at.bitfire.davdroid.addressbooks", + CalendarContract.AUTHORITY, + *TaskProvider.TASK_PROVIDERS.map { it.authority }.toTypedArray() + ) + for (authority in authorities) { + val oldWorkerName = "periodic-sync $authority ${account.type}/${account.name}" + workManager.cancelUniqueWork(oldWorkerName) + } + + // enqueue new workers + automaticSyncManager.updateAutomaticSync(account) + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(19) + abstract fun provide(impl: AccountSettingsMigration19): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration20.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration20.kt new file mode 100644 index 0000000..b2134e7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration20.kt @@ -0,0 +1,133 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.provider.CalendarContract +import androidx.annotation.OpenForTesting +import androidx.core.content.contentValuesOf +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.resource.LocalCalendarStore +import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.ical4android.JtxCollection +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import at.techbee.jtx.JtxContract +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import kotlinx.coroutines.runBlocking +import org.dmfs.tasks.contract.TaskContract.TaskLists +import javax.inject.Inject + +/** + * [at.bitfire.davdroid.sync.Syncer] now users collection IDs instead of URLs to match + * local and remote (database) collections. + * + * This migration writes the database collection IDs to the local collections. If we wouldn't do that, + * the syncer would not be able to find the correct local collection for a remote collection and + * all local collections would be deleted and re-created. + */ +class AccountSettingsMigration20 @Inject constructor( + @ApplicationContext context: Context, + private val addressBookStore: LocalAddressBookStore, + private val calendarStore: LocalCalendarStore, + private val collectionRepository: DavCollectionRepository, + private val serviceRepository: DavServiceRepository, + private val tasksAppManager: TasksAppManager +): AccountSettingsMigration { + + val accountManager = AccountManager.get(context) + + override fun migrate(account: Account) { + runBlocking { + serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { cardDavService -> + migrateAddressBooks(account, cardDavService.id) + } + + serviceRepository.getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { calDavService -> + migrateCalendars(account, calDavService.id) + migrateTaskLists(account, calDavService.id) + } + } + } + + @OpenForTesting + internal fun migrateAddressBooks(account: Account, cardDavServiceId: Long) { + addressBookStore.acquireContentProvider()?.use { provider -> + for (addressBook in addressBookStore.getAll(account, provider)) { + val url = accountManager.getUserData(addressBook.addressBookAccount, ADDRESS_BOOK_USER_DATA_URL) ?: continue + val collection = collectionRepository.getByServiceAndUrl(cardDavServiceId, url) ?: continue + addressBook.dbCollectionId = collection.id + } + } + } + + @OpenForTesting + internal fun migrateCalendars(account: Account, calDavServiceId: Long) { + calendarStore.acquireContentProvider()?.use { client -> + val calendarProvider = AndroidCalendarProvider(account, client) + // for each calendar, assign _SYNC_ID := ID if collection (identified by NAME field = URL) + for (calendar in calendarProvider.findCalendars()) { + val url = calendar.name ?: continue + collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection -> + calendar.update(contentValuesOf( + CalendarContract.Calendars._SYNC_ID to collection.id + )) + } + + } + } + } + + @OpenForTesting + internal fun migrateTaskLists(account: Account, calDavServiceId: Long) { + val taskListStore = tasksAppManager.getDataStore() ?: /* no tasks app */ return + taskListStore.acquireContentProvider()?.use { provider -> + for (taskList in taskListStore.getAll(account, provider)) { + when (taskList) { + is LocalTaskList -> { // tasks.org, OpenTasks + val url = taskList.syncId ?: continue + collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection -> + taskList.update(contentValuesOf( + TaskLists._SYNC_ID to collection.id.toString() + )) + } + } + is JtxCollection<*> -> { // jtxBoard + val url = taskList.url ?: continue + collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection -> + taskList.update(contentValuesOf( + JtxContract.JtxCollection.SYNC_ID to collection.id + )) + } + } + } + } + } + } + + companion object { + internal const val ADDRESS_BOOK_USER_DATA_URL = "url" + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(20) + abstract fun provide(impl: AccountSettingsMigration20): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration7.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration7.kt new file mode 100644 index 0000000..a6bddcf --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration7.kt @@ -0,0 +1,50 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.provider.CalendarContract +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Inject + +class AccountSettingsMigration7 @Inject constructor( + @ApplicationContext private val context: Context +): AccountSettingsMigration { + + override fun migrate(account: Account) { + // add calendar colors + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { client -> + val provider = AndroidCalendarProvider(account, client) + provider.provideCss3ColorIndices() + } + + // update allowed WiFi settings key + val accountManager = AccountManager.get(context) + val onlySSID = accountManager.getUserData(account, "wifi_only_ssid") + accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID) + accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null) + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(7) + abstract fun provide(impl: AccountSettingsMigration7): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt new file mode 100644 index 0000000..e29aeb2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.ContentUris +import android.content.Context +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.TaskProvider +import at.techbee.jtx.JtxContract.asSyncAdapter +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import org.dmfs.tasks.contract.TaskContract +import org.dmfs.tasks.contract.TaskContract.CommonSyncColumns +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class AccountSettingsMigration8 @Inject constructor( + @ApplicationContext private val context: Context, + private val logger: Logger +): AccountSettingsMigration { + + /** + * There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the + * SEQUENCE and should not be used for the eTag. + */ + override fun migrate(account: Account) { + TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider -> + // ETag is now in sync_version instead of sync1 + // UID is now in _uid instead of sync2 + provider.client.query(provider.tasksUri().asSyncAdapter(account), + arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2), + "${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?", + arrayOf(account.type, account.name), null)!!.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val eTag = cursor.getString(1) + val uid = cursor.getString(2) + val values = contentValuesOf( + TaskContract.Tasks._UID to uid, + TaskContract.Tasks.SYNC_VERSION to eTag, + TaskContract.Tasks.SYNC1 to null, + TaskContract.Tasks.SYNC2 to null + ) + logger.log(Level.FINER, "Updating task $id", values) + provider.client.update( + ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account), + values, null, null) + } + } + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(8) + abstract fun provide(impl: AccountSettingsMigration8): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration9.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration9.kt new file mode 100644 index 0000000..b3b2275 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration9.kt @@ -0,0 +1,48 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.settings.migration + +import android.accounts.Account +import android.content.ContentResolver +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import at.bitfire.ical4android.TaskProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import kotlinx.coroutines.runBlocking +import java.util.logging.Logger +import javax.inject.Inject + +/** + * It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems. + * Disable it on those accounts for the future. + */ +class AccountSettingsMigration9 @Inject constructor( + private val db: AppDatabase, + private val logger: Logger +): AccountSettingsMigration { + + override fun migrate(account: Account) = runBlocking { + val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null + if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) { + logger.info("Disabling OpenTasks sync for $account") + ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0) + } + } + + + @Module + @InstallIn(SingletonComponent::class) + abstract class AccountSettingsMigrationModule { + @Binds @IntoMap + @IntKey(9) + abstract fun provide(impl: AccountSettingsMigration9): AccountSettingsMigration + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/startup/CrashHandlerSetup.kt b/app/src/main/kotlin/at/bitfire/davdroid/startup/CrashHandlerSetup.kt new file mode 100644 index 0000000..643f1aa --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/startup/CrashHandlerSetup.kt @@ -0,0 +1,90 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.startup + +import android.content.Context +import android.os.Build +import android.os.StrictMode +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.startup.StartupPlugin.Companion.PRIORITY_DEFAULT +import at.bitfire.davdroid.startup.StartupPlugin.Companion.PRIORITY_HIGHEST +import dagger.Binds +import dagger.BindsOptionalOf +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import java.util.Optional +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +/** + * Sets up the uncaught exception (crash) handler and enables StrictMode in debug builds. + */ +class CrashHandlerSetup @Inject constructor( + @ApplicationContext private val context: Context, + private val logger: Logger, + private val crashHandler: Optional +): StartupPlugin { + + @Module + @InstallIn(SingletonComponent::class) + interface CrashHandlerSetupModule { + // allows to inject Optional + @BindsOptionalOf + fun optionalDebugInfoCrashHandler(): Thread.UncaughtExceptionHandler + + @Binds + @IntoSet + fun crashHandlerSetup(impl: CrashHandlerSetup): StartupPlugin + } + + + override fun onAppCreate() { + if (BuildConfig.DEBUG) { + logger.info("Debug build, enabling StrictMode with logging") + + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + + val builder = StrictMode.VmPolicy.Builder() // don't use detectAll() because it causes "untagged socket" warnings + .detectActivityLeaks() + .detectFileUriExposure() + .detectLeakedClosableObjects() + .detectLeakedRegistrationObjects() + .detectLeakedSqlLiteObjects() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + builder.detectContentUriWithoutPermission() + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) // often triggered by Conscrypt + builder.detectNonSdkApiUsage()*/ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + builder.detectUnsafeIntentLaunch() + StrictMode.setVmPolicy(builder.penaltyLog().build()) + + } else { + // release build + val handler = crashHandler.getOrNull() + if (handler != null) { + logger.info("Setting uncaught exception handler: ${handler.javaClass.name}") + Thread.setDefaultUncaughtExceptionHandler(handler) + } else + logger.info("Using default uncaught exception handler") + } + } + + override fun priority() = PRIORITY_HIGHEST + + override suspend fun onAppCreateAsync() { + } + + override fun priorityAsync(): Int = PRIORITY_DEFAULT + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/startup/StartupPlugin.kt b/app/src/main/kotlin/at/bitfire/davdroid/startup/StartupPlugin.kt new file mode 100644 index 0000000..a5b0ad3 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/startup/StartupPlugin.kt @@ -0,0 +1,44 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.startup + +interface StartupPlugin { + + companion object { + const val PRIORITY_DEFAULT = 100 + const val PRIORITY_HIGHEST = 0 + } + + /** + * Runs synchronously during [at.bitfire.davdroid.App.onCreate]. Use only for tasks that must be completed before + * the app can run. Causes the app to start slower. + * + * Will be run before [onAppCreateAsync]. + */ + fun onAppCreate() + + /** + * Priority of this plugin's [onAppCreate]. Lower values are executed first. + */ + fun priority(): Int + + + /** + * Runs asynchronously after [at.bitfire.davdroid.App.onCreate]. Use for tasks that can be run in the background. + * + * Will be run after [onAppCreate]. + * + * The coroutine scope will usually be `GlobalScope` (which has no end because we don't get + * a signal before the app is terminated) on the default dispatcher, but can be a custom scope + * for testing. + */ + suspend fun onAppCreateAsync() + + /** + * Priority of this plugin's [onAppCreateAsync]. Lower values are executed first. + */ + fun priorityAsync(): Int + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/startup/TasksAppWatcher.kt b/app/src/main/kotlin/at/bitfire/davdroid/startup/TasksAppWatcher.kt new file mode 100644 index 0000000..e9ea631 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/startup/TasksAppWatcher.kt @@ -0,0 +1,74 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.startup + +import android.content.Context +import at.bitfire.davdroid.startup.StartupPlugin.Companion.PRIORITY_DEFAULT +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.util.packageChangedFlow +import at.bitfire.ical4android.TaskProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Provider + +/** + * Watches whether a tasks app has been installed or uninstalled and updates + * the selected tasks app and task sync settings accordingly. + */ +class TasksAppWatcher @Inject constructor( + @ApplicationContext private val context: Context, + private val logger: Logger, + private val tasksAppManager: Provider +): StartupPlugin { + + @Module + @InstallIn(SingletonComponent::class) + interface TasksAppWatcherModule { + @Binds + @IntoSet + fun tasksAppWatcher(impl: TasksAppWatcher): StartupPlugin + } + + + override fun onAppCreate() { + } + + override fun priority() = PRIORITY_DEFAULT + + override suspend fun onAppCreateAsync() { + logger.info("Watching for package changes in order to detect tasks app changes") + packageChangedFlow(context).collect { + onPackageChanged() + } + } + + override fun priorityAsync() = PRIORITY_DEFAULT + + + private fun onPackageChanged() { + val manager = tasksAppManager.get() + val currentProvider = manager.currentProvider() + logger.info("App launched or package (un)installed; current tasks provider = $currentProvider") + + if (currentProvider == null) { + // Iterate through all supported providers and select one, if available. + var newProvider = TaskProvider.ProviderName.entries + .firstOrNull { provider -> + context.packageManager.resolveContentProvider(provider.authority, 0) != null + } + + // Select provider or clear setting and sync if now provider available + logger.info("Selecting new tasks provider: $newProvider") + manager.selectProvider(newProvider) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt new file mode 100644 index 0000000..f6deea9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt @@ -0,0 +1,132 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.provider.ContactsContract +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.account.setAndVerifyUserData +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.runBlocking +import java.util.logging.Level + +/** + * Sync logic for address books + */ +class AddressBookSyncer @AssistedInject constructor( + @Assisted account: Account, + @Assisted resync: ResyncType?, + @Assisted val syncFrameworkUpload: Boolean, + @Assisted syncResult: SyncResult, + addressBookStore: LocalAddressBookStore, + private val accountSettingsFactory: AccountSettings.Factory, + private val contactsSyncManagerFactory: ContactsSyncManager.Factory +): Syncer(account, resync, syncResult) { + + @AssistedFactory + interface Factory { + fun create( + account: Account, + resyncType: ResyncType?, + syncFrameworkUpload: Boolean, + syncResult: SyncResult + ): AddressBookSyncer + } + + override val dataStore = addressBookStore + + override val serviceType: String + get() = Service.TYPE_CARDDAV + + + override fun getDbSyncCollections(serviceId: Long): List = + collectionRepository.getByServiceAndSync(serviceId) + + override fun syncCollection(provider: ContentProviderClient, localCollection: LocalAddressBook, remoteCollection: Collection) { + logger.info("Synchronizing address book: ${localCollection.addressBookAccount.name}") + syncAddressBook( + account = account, + addressBook = localCollection, + httpClient = httpClient, + provider = provider, + syncResult = syncResult, + collection = remoteCollection + ) + } + + /** + * Synchronizes an address book + * + * @param addressBook local address book + * @param provider Content provider to access android contacts + * @param syncResult Stores hard and soft sync errors + * @param collection The database collection associated with this address book + */ + private fun syncAddressBook( + account: Account, + addressBook: LocalAddressBook, + httpClient: Lazy, + provider: ContentProviderClient, + syncResult: SyncResult, + collection: Collection + ) { + try { + // handle group method change + val accountSettings = accountSettingsFactory.create(account) + val groupMethod = accountSettings.getGroupMethod().name + + val accountManager = AccountManager.get(context) + accountManager.getUserData(addressBook.addressBookAccount, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod -> + if (previousGroupMethod != groupMethod) { + logger.info("Group method changed, deleting all local contacts/groups") + + // delete all local contacts and groups so that they will be downloaded again + provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null) + provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null) + + // reset sync state + addressBook.syncState = null + } + } + accountManager.setAndVerifyUserData(addressBook.addressBookAccount, PREVIOUS_GROUP_METHOD, groupMethod) + + val syncManager = contactsSyncManagerFactory.contactsSyncManager( + account, + httpClient.value, + syncResult, + provider, + addressBook, + collection, + resync, + syncFrameworkUpload + ) + runBlocking { + syncManager.performSync() + } + + } catch(e: Exception) { + logger.log(Level.SEVERE, "Couldn't sync contacts", e) + } + + logger.info("Contacts sync complete") + } + + + companion object { + + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt new file mode 100644 index 0000000..c8e8912 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/AutomaticSyncManager.kt @@ -0,0 +1,154 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.annotation.WorkerThread +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import javax.inject.Provider + +/** + * Manages automatic synchronization, that is: + * + * - synchronization in given intervals, and + * - synchronization on local data changes. + * + * Integrates with both the periodic sync workers and the sync framework. So this class should be used when + * the caller just wants to update the automatic sync, without needing to know about the underlying details. + * + * Automatic synchronization stands in contrast to manual synchronization, which is only triggered by the user. + */ +class AutomaticSyncManager @Inject constructor( + private val accountSettingsFactory: AccountSettings.Factory, + private val localAddressBookStore: LocalAddressBookStore, + private val serviceRepository: DavServiceRepository, + private val syncFramework: SyncFrameworkIntegration, + private val tasksAppManager: Provider, + private val workerManager: SyncWorkerManager +) { + + /** + * Disable automatic synchronization for the given account and data type. + */ + private fun disableAutomaticSync(account: Account, dataType: SyncDataType) { + workerManager.disablePeriodic(account, dataType) + + for (authority in dataType.possibleAuthorities()) { + syncFramework.disableSyncAbility(account, authority) + // no need to disable content-triggered sync, as it can't be active when sync-ability is disabled + } + } + + /** + * Enables/Disables automatic synchronization for the given account and data type and sets it to the given interval, + * based on sync interval setting in account settings: + * + * 1. Enables/Disables periodic sync worker for the given data type with the given interval. + * 2. Enables/Disables sync in the sync framework and enables or disables content-triggered syncs for the given data type + * + * @param account the account to synchronize + * @param dataType the data type to synchronize + */ + @WorkerThread + private fun enableAutomaticSync( + account: Account, + dataType: SyncDataType + ) { + val accountSettings = accountSettingsFactory.create(account) + val syncInterval = accountSettings.getSyncInterval(dataType) + + // 1. Update sync workers (needs already updated sync interval in AccountSettings). + if (syncInterval != null) { + val wifiOnly = accountSettings.getSyncWifiOnly() + workerManager.enablePeriodic(account, dataType, syncInterval, wifiOnly) + } else + workerManager.disablePeriodic(account, dataType) + + // 2. Enable/disable content-triggered syncs. + if (dataType == SyncDataType.CONTACTS) { + // Contact updates are handled by their respective address book accounts, so we must always + // disable the content-triggered sync for the main account. + syncFramework.disableSyncAbility(account, ContactsContract.AUTHORITY) + + // pass through request to update all existing address books + localAddressBookStore.acquireContentProvider()?.use { provider -> + for (addressBookAccount in localAddressBookStore.getAll(account, provider)) + addressBookAccount.updateSyncFrameworkSettings() + } + + } else { + // everything but contacts + val possibleAuthorities = dataType.possibleAuthorities() + val authority: String? = when (dataType) { + SyncDataType.CONTACTS -> throw IllegalStateException() // handled above + SyncDataType.EVENTS -> CalendarContract.AUTHORITY + SyncDataType.TASKS -> tasksAppManager.get().currentProvider()?.authority + } + if (authority != null && syncInterval != null) { + // enable given authority, but completely disable all other possible authorities + // (for instance, tasks apps which are not the current task app) + syncFramework.enableSyncOnContentChange(account, authority) + for (disableAuthority in possibleAuthorities - authority) + syncFramework.disableSyncAbility(account, disableAuthority) + } else + for (authority in possibleAuthorities) + syncFramework.disableSyncOnContentChange(account, authority) + } + } + + /** + * Updates automatic synchronization of the given account and all data types according to the account settings. + * + * If there's a [Service] for the given account and data type, automatic sync is enabled (with details from [AccountSettings]). + * Otherwise, automatic synchronization is disabled. + * + * @param account account for which automatic synchronization shall be updated + */ + @WorkerThread + fun updateAutomaticSync(account: Account) { + for (dataType in SyncDataType.entries) + updateAutomaticSync(account, dataType) + } + + /** + * Updates automatic synchronization of the given account and data type according to the account services and settings. + * + * If there's a [Service] for the given account and data type, automatic sync may be enabled if sync interval is set + * in [AccountSettings]. + * Otherwise, automatic synchronization is disabled. + * + * @param account account for which automatic synchronization shall be updated + * @param dataType sync data type for which automatic synchronization shall be updated + */ + @WorkerThread + fun updateAutomaticSync(account: Account, dataType: SyncDataType) { + val serviceType = when (dataType) { + SyncDataType.CONTACTS -> Service.TYPE_CARDDAV + SyncDataType.EVENTS, + SyncDataType.TASKS -> Service.TYPE_CALDAV + } + val hasService = runBlocking { serviceRepository.getByAccountAndType(account.name, serviceType) != null } + + val hasProvider = if (dataType == SyncDataType.TASKS) + tasksAppManager.get().currentProvider() != null + else + true + + if (hasService && hasProvider) + enableAutomaticSync(account, dataType) + else + disableAutomaticSync(account, dataType) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt new file mode 100644 index 0000000..1867b19 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -0,0 +1,319 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.text.format.Formatter +import at.bitfire.dav4jvm.DavCalendar +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.caldav.CalendarData +import at.bitfire.dav4jvm.property.caldav.GetCTag +import at.bitfire.dav4jvm.property.caldav.MaxResourceSize +import at.bitfire.dav4jvm.property.caldav.ScheduleTag +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.dav4jvm.property.webdav.SupportedReportSet +import at.bitfire.dav4jvm.property.webdav.SyncToken +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.di.SyncDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.resource.LocalCalendar +import at.bitfire.davdroid.resource.LocalEvent +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.EventReader +import at.bitfire.ical4android.EventWriter +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.synctools.exception.InvalidICalendarException +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runInterruptible +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import okhttp3.HttpUrl +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.Reader +import java.io.StringReader +import java.io.StringWriter +import java.time.Duration +import java.time.ZonedDateTime +import java.util.Optional +import java.util.logging.Level + +/** + * Synchronization manager for CalDAV collections; handles events (VEVENT). + */ +class CalendarSyncManager @AssistedInject constructor( + @Assisted account: Account, + @Assisted httpClient: HttpClient, + @Assisted syncResult: SyncResult, + @Assisted localCalendar: LocalCalendar, + @Assisted collection: Collection, + @Assisted resync: ResyncType?, + accountSettingsFactory: AccountSettings.Factory, + @SyncDispatcher syncDispatcher: CoroutineDispatcher +): SyncManager( + account, + httpClient, + SyncDataType.EVENTS, + syncResult, + localCalendar, + collection, + resync, + syncDispatcher +) { + + @AssistedFactory + interface Factory { + fun calendarSyncManager( + account: Account, + httpClient: HttpClient, + syncResult: SyncResult, + localCalendar: LocalCalendar, + collection: Collection, + resync: ResyncType? + ): CalendarSyncManager + } + + private val accountSettings = accountSettingsFactory.create(account) + + + override fun prepare(): Boolean { + davCollection = DavCalendar(httpClient.okHttpClient, collection.url) + + // if there are dirty exceptions for events, mark their master events as dirty, too + localCollection.processDirtyExceptions() + + // now find dirty events that have no instances and set them to deleted + localCollection.deleteDirtyEventsWithoutInstances() + + return true + } + + override suspend fun queryCapabilities(): SyncState? = + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + var syncState: SyncState? = null + runInterruptible { + davCollection.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[MaxResourceSize::class.java]?.maxSize?.let { maxSize -> + logger.info("Calendar accepts events up to ${Formatter.formatFileSize(context, maxSize)}") + } + + response[SupportedReportSet::class.java]?.let { supported -> + hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) + } + syncState = syncState(response) + } + } + } + + logger.info("Calendar supports Collection Sync: $hasCollectionSync") + syncState + } + + override fun syncAlgorithm() = + if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync) + SyncAlgorithm.PROPFIND_REPORT + else + SyncAlgorithm.COLLECTION_SYNC + + override suspend fun processLocallyDeleted(): Boolean { + if (localCollection.readOnly) { + var modified = false + for (event in localCollection.findDeleted()) { + logger.warning("Restoring locally deleted event (read-only calendar!)") + SyncException.wrapWithLocalResource(event) { + event.resetDeleted() + } + modified = true + } + + // This is unfortunately ugly: When an event has been inserted to a read-only calendar + // it's not enough to force synchronization (by returning true), + // but we also need to make sure all events are downloaded again. + if (modified) + localCollection.lastSyncState = null + + return modified + } + // mirror deletions to remote collection (DELETE) + return super.processLocallyDeleted() + } + + override suspend fun uploadDirty(): Boolean { + var modified = false + if (localCollection.readOnly) { + for (event in localCollection.findDirty()) { + logger.warning("Resetting locally modified event to ETag=null (read-only calendar!)") + SyncException.wrapWithLocalResource(event) { + event.clearDirty(Optional.empty(), null, null) + } + modified = true + } + + // This is unfortunately ugly: When an event has been inserted to a read-only calendar + // it's not enough to force synchronization (by returning true), + // but we also need to make sure all events are downloaded again. + if (modified) + localCollection.lastSyncState = null + } + + // generate UID/file name for newly created events + val superModified = super.uploadDirty() + + // return true when any operation returned true + return modified or superModified + } + + override fun onSuccessfulUpload(local: LocalEvent, newFileName: String, eTag: String?, scheduleTag: String?) { + super.onSuccessfulUpload(local, newFileName, eTag, scheduleTag) + + // update local SEQUENCE to new value after successful upload + local.updateSequence(local.getCachedEvent().sequence) + } + + override fun generateUpload(resource: LocalEvent): RequestBody = + SyncException.wrapWithLocalResource(resource) { + val event = resource.eventToUpload() + logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event) + + // write iCalendar to string and convert to request body + val iCalWriter = StringWriter() + EventWriter(Constants.iCalProdId).write(event, iCalWriter) + iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + } + + override suspend fun listAllRemote(callback: MultiResponseCallback) { + // calculate time range limits + val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays -> + ZonedDateTime.now().minusDays(pastDays.toLong()).toInstant() + } + + return SyncException.wrapWithRemoteResourceSuspending(collection.url) { + logger.info("Querying events since $limitStart") + runInterruptible { + davCollection.calendarQuery(Component.VEVENT, limitStart, null, callback) + } + } + } + + override suspend fun downloadRemote(bunch: List) { + logger.info("Downloading ${bunch.size} iCalendars: $bunch") + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + runInterruptible { + davCollection.multiget(bunch) { response, _ -> + /* + * Real-world servers may return: + * + * - unrelated resources + * - the collection itself + * - the requested resources, but with a different collection URL (for instance, `/cal/1.ics` instead of `/shared-cal/1.ics`). + * + * So we: + * + * - ignore unsuccessful responses, + * - ignore responses without requested calendar data (should also ignore collections and hopefully unrelated resources), and + * - take the last segment of the href as the file name and assume that it's in the requested collection. + */ + SyncException.wrapWithRemoteResource(response.href) wrapResource@{ + if (!response.isSuccess()) { + logger.warning("Ignoring non-successful multi-get response for ${response.href}") + return@wrapResource + } + + val iCal = response[CalendarData::class.java]?.iCalendar + if (iCal == null) { + logger.warning("Ignoring multi-get response without calendar-data") + return@wrapResource + } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag + + processVEvent( + response.href.lastSegment, + eTag, + scheduleTag, + StringReader(iCal) + ) + } + } + } + } + } + + override fun postProcess() {} + + + // helpers + + private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) { + val events: List + try { + events = EventReader().readEvents(reader) + } catch (e: InvalidICalendarException) { + logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + if (events.size == 1) { + val event = events.first() + + // set default reminder for non-full-day events, if requested + val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() + if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) { + val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply { + // Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider. + // Needed for calendars to actually show a notification. + properties += Action.DISPLAY + } + logger.log(Level.FINE, "${event.uid}: Adding default alarm", alarm) + event.alarms += alarm + } + + // update local event, if it exists + val local = localCollection.findByName(fileName) + SyncException.wrapWithLocalResource(local) { + if (local != null) { + logger.log(Level.INFO, "Updating $fileName in local calendar", event) + local.update( + data = event, + fileName = fileName, + eTag = eTag, + scheduleTag = scheduleTag, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + } else { + logger.log(Level.INFO, "Adding $fileName to local calendar", event) + localCollection.add( + event = event, + fileName = fileName, + eTag = eTag, + scheduleTag = scheduleTag, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ) + } + } + } else + logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName") + } + + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_event) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt new file mode 100644 index 0000000..cc34683 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt @@ -0,0 +1,74 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalCalendar +import at.bitfire.davdroid.resource.LocalCalendarStore +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.runBlocking + +/** + * Sync logic for calendars + */ +class CalendarSyncer @AssistedInject constructor( + @Assisted account: Account, + @Assisted resync: ResyncType?, + @Assisted syncResult: SyncResult, + calendarStore: LocalCalendarStore, + private val accountSettingsFactory: AccountSettings.Factory, + private val calendarSyncManagerFactory: CalendarSyncManager.Factory +): Syncer(account, resync, syncResult) { + + @AssistedFactory + interface Factory { + fun create(account: Account, resyncType: ResyncType?, syncResult: SyncResult): CalendarSyncer + } + + override val dataStore = calendarStore + + override val serviceType: String + get() = Service.TYPE_CALDAV + + + override fun prepare(provider: ContentProviderClient): Boolean { + // Update colors + val accountSettings = accountSettingsFactory.create(account) + + val calendarProvider = AndroidCalendarProvider(account, provider) + if (accountSettings.getEventColors()) + calendarProvider.provideCss3ColorIndices() + else + calendarProvider.removeColorIndices() + return true + } + + override fun getDbSyncCollections(serviceId: Long): List = + collectionRepository.getSyncCalendars(serviceId) + + override fun syncCollection(provider: ContentProviderClient, localCollection: LocalCalendar, remoteCollection: Collection) { + logger.info("Synchronizing calendar #${localCollection.androidCalendar.id}, DB Collection ID: ${localCollection.dbCollectionId}, URL: ${localCollection.androidCalendar.name}") + + val syncManager = calendarSyncManagerFactory.calendarSyncManager( + account, + httpClient.value, + syncResult, + localCollection, + remoteCollection, + resync + ) + runBlocking { + syncManager.performSync() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt new file mode 100644 index 0000000..68add45 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -0,0 +1,506 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import android.text.format.Formatter +import at.bitfire.dav4jvm.DavAddressBook +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.caldav.GetCTag +import at.bitfire.dav4jvm.property.carddav.AddressData +import at.bitfire.dav4jvm.property.carddav.MaxResourceSize +import at.bitfire.dav4jvm.property.carddav.SupportedAddressData +import at.bitfire.dav4jvm.property.webdav.GetContentType +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.dav4jvm.property.webdav.SupportedReportSet +import at.bitfire.dav4jvm.property.webdav.SyncToken +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.di.SyncDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.resource.LocalAddress +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalContact +import at.bitfire.davdroid.resource.LocalGroup +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.groups.CategoriesStrategy +import at.bitfire.davdroid.sync.groups.VCard4Strategy +import at.bitfire.davdroid.util.DavUtils +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.davdroid.util.DavUtils.sameTypeAs +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.GroupMethod +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import ezvcard.VCardVersion +import ezvcard.io.CannotParseException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runInterruptible +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.Reader +import java.io.StringReader +import java.util.Optional +import java.util.logging.Level +import kotlin.jvm.optionals.getOrNull + +/** + * Synchronization manager for CardDAV collections; handles contacts and groups. + * + * Group handling differs according to the {@link #groupMethod}. There are two basic methods to + * handle/manage groups: + * + * 1. CATEGORIES: groups memberships are attached to each contact and represented as + * "category". When a group is dirty or has been deleted, all its members have to be set to + * dirty, too (because they have to be uploaded without the respective category). This + * is done in [uploadDirty]. Empty groups can be deleted without further processing, + * which is done in [postProcess] because groups may become empty after downloading + * updated remote contacts. + * + * 2. Groups as separate VCards: individual and group contacts (with a list of member UIDs) are + * distinguished. When a local group is dirty, its members don't need to be set to dirty. + * + * However, when a contact is dirty, it has + * to be checked whether its group memberships have changed. In this case, the respective + * groups have to be set to dirty. For instance, if contact A is in group G and H, and then + * group membership of G is removed, the contact will be set to dirty because of the changed + * [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will + * then have to check whether the group memberships have actually changed, and if so, + * all affected groups have to be set to dirty. To detect changes in group memberships, + * DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership] + * data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows. + * If the cached group memberships are not the same as the current group member ships, the + * difference set (in our example G, because its in the cached memberships, but not in the + * actual ones) is marked as dirty. This is done in [uploadDirty]. + * + * When downloading remote contacts, groups (+ member information) may be received + * by the actual members. Thus, the member lists have to be cached until all VCards + * are received. This is done by caching the member UIDs of each group in + * [LocalGroup.COLUMN_PENDING_MEMBERS]. In [postProcess], + * these "pending memberships" are assigned to the actual contacts and then cleaned up. + * + * @param syncFrameworkUpload set when this sync is caused by the sync framework and [android.content.ContentResolver.SYNC_EXTRAS_UPLOAD] was set + */ +class ContactsSyncManager @AssistedInject constructor( + @Assisted account: Account, + @Assisted httpClient: HttpClient, + @Assisted syncResult: SyncResult, + @Assisted val provider: ContentProviderClient, + @Assisted localAddressBook: LocalAddressBook, + @Assisted collection: Collection, + @Assisted resync: ResyncType?, + @Assisted val syncFrameworkUpload: Boolean, + val dirtyVerifier: Optional, + accountSettingsFactory: AccountSettings.Factory, + private val httpClientBuilder: HttpClient.Builder, + @SyncDispatcher syncDispatcher: CoroutineDispatcher +): SyncManager( + account, + httpClient, + SyncDataType.CONTACTS, + syncResult, + localAddressBook, + collection, + resync, + syncDispatcher +) { + + @AssistedFactory + interface Factory { + fun contactsSyncManager( + account: Account, + httpClient: HttpClient, + syncResult: SyncResult, + provider: ContentProviderClient, + localAddressBook: LocalAddressBook, + collection: Collection, + resync: ResyncType?, + syncFrameworkUpload: Boolean + ): ContactsSyncManager + } + + companion object { + infix fun Set.disjunct(other: Set) = (this - other) union (other - this) + } + + private val accountSettings = accountSettingsFactory.create(account) + + private var hasVCard4 = false + private var hasJCard = false + private val groupStrategy = when (accountSettings.getGroupMethod()) { + GroupMethod.GROUP_VCARDS -> VCard4Strategy(localAddressBook) + GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook) + } + + /** + * Used to download images which are referenced by URL + */ + private lateinit var resourceDownloader: ResourceDownloader + + + override fun prepare(): Boolean { + if (dirtyVerifier.isPresent) { + logger.info("Sync will verify dirty contacts (Android 7.x workaround)") + if (!dirtyVerifier.get().prepareAddressBook(localCollection, isUpload = syncFrameworkUpload)) + return false + } + + davCollection = DavAddressBook(httpClient.okHttpClient, collection.url) + resourceDownloader = ResourceDownloader(davCollection.location) + + logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}") + return true + } + + override suspend fun queryCapabilities(): SyncState? { + return SyncException.wrapWithRemoteResourceSuspending(collection.url) { + var syncState: SyncState? = null + runInterruptible { + davCollection.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[MaxResourceSize::class.java]?.maxSize?.let { maxSize -> + logger.info("Address book accepts vCards up to ${Formatter.formatFileSize(context, maxSize)}") + } + + response[SupportedAddressData::class.java]?.let { supported -> + hasVCard4 = supported.hasVCard4() + + // temporarily disable jCard because of https://github.com/nextcloud/server/issues/29693 + // hasJCard = supported.hasJCard() + } + response[SupportedReportSet::class.java]?.let { supported -> + hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) + } + syncState = syncState(response) + } + } + } + + // logger.info("Server supports jCard: $hasJCard") + logger.info("Address book supports vCard4: $hasVCard4") + logger.info("Address book supports Collection Sync: $hasCollectionSync") + + syncState + } + } + + override fun syncAlgorithm() = + if (hasCollectionSync) + SyncAlgorithm.COLLECTION_SYNC + else + SyncAlgorithm.PROPFIND_REPORT + + override suspend fun processLocallyDeleted() = + if (localCollection.readOnly) { + var modified = false + for (group in localCollection.findDeletedGroups()) { + logger.warning("Restoring locally deleted group (read-only address book!)") + SyncException.wrapWithLocalResource(group) { + group.resetDeleted() + } + modified = true + } + + for (contact in localCollection.findDeletedContacts()) { + logger.warning("Restoring locally deleted contact (read-only address book!)") + SyncException.wrapWithLocalResource(contact) { + contact.resetDeleted() + } + modified = true + } + + /* This is unfortunately dirty: When a contact has been inserted to a read-only address book + that supports Collection Sync, it's not enough to force synchronization (by returning true), + but we also need to make sure all contacts are downloaded again. */ + if (modified) + localCollection.lastSyncState = null + + modified + } else + // mirror deletions to remote collection (DELETE) + super.processLocallyDeleted() + + override suspend fun uploadDirty(): Boolean { + var modified = false + + if (localCollection.readOnly) { + for (group in localCollection.findDirtyGroups()) { + logger.warning("Resetting locally modified group to ETag=null (read-only address book!)") + SyncException.wrapWithLocalResource(group) { + group.clearDirty(Optional.empty(), null) + } + modified = true + } + + for (contact in localCollection.findDirtyContacts()) { + logger.warning("Resetting locally modified contact to ETag=null (read-only address book!)") + SyncException.wrapWithLocalResource(contact) { + contact.clearDirty(Optional.empty(), null) + } + modified = true + } + + // see same position in processLocallyDeleted + if (modified) + localCollection.lastSyncState = null + + } else + // we only need to handle changes in groups when the address book is read/write + groupStrategy.beforeUploadDirty() + + // generate UID/file name for newly created contacts + val superModified = super.uploadDirty() + + // return true when any operation returned true + return modified or superModified + } + + override fun generateUpload(resource: LocalAddress): RequestBody = + SyncException.wrapWithLocalResource(resource) { + val contact: Contact = when (resource) { + is LocalContact -> resource.getContact() + is LocalGroup -> resource.getContact() + else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup") + } + + logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact) + + val os = ByteArrayOutputStream() + val mimeType: MediaType + when { + hasJCard -> { + mimeType = DavAddressBook.MIME_JCARD + contact.writeJCard(os, Constants.vCardProdId) + } + hasVCard4 -> { + mimeType = DavAddressBook.MIME_VCARD4 + contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId) + } + else -> { + mimeType = DavAddressBook.MIME_VCARD3_UTF8 + contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId) + } + } + + return@wrapWithLocalResource os.toByteArray().toRequestBody(mimeType) + } + + override suspend fun listAllRemote(callback: MultiResponseCallback) = + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + runInterruptible { + davCollection.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback) + } + } + + override suspend fun downloadRemote(bunch: List) { + logger.info("Downloading ${bunch.size} vCard(s): $bunch") + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + val contentType: String? + val version: String? + when { + hasJCard -> { + contentType = DavUtils.MEDIA_TYPE_JCARD.toString() + version = VCardVersion.V4_0.version + } + hasVCard4 -> { + contentType = DavUtils.MEDIA_TYPE_VCARD.toString() + version = VCardVersion.V4_0.version + } + else -> { + contentType = DavUtils.MEDIA_TYPE_VCARD.toString() + version = null // 3.0 is the default version; don't request 3.0 explicitly because maybe some vCard3-only servers don't understand it + } + } + runInterruptible { + davCollection.multiget(bunch, contentType, version) { response, _ -> + // See CalendarSyncManager for more information about the multi-get response + SyncException.wrapWithRemoteResource(response.href) wrapResource@{ + if (!response.isSuccess()) { + logger.warning("Ignoring non-successful multi-get response for ${response.href}") + return@wrapResource + } + + val card = response[AddressData::class.java]?.card + if (card == null) { + logger.warning("Ignoring multi-get response without address-data") + return@wrapResource + } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + + var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it) + response[GetContentType::class.java]?.type?.let { type -> + isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD) + } + + processCard( + response.href.lastSegment, + eTag, + StringReader(card), + isJCard, + resourceDownloader + ) + } + } + } + } + } + + override fun postProcess() { + groupStrategy.postProcess() + } + + + // helpers + + private fun processCard(fileName: String, eTag: String, reader: Reader, jCard: Boolean, downloader: Contact.Downloader) { + logger.info("Processing CardDAV resource $fileName") + + val contacts = try { + Contact.fromReader(reader, jCard, downloader) + } catch (e: CannotParseException) { + logger.log(Level.SEVERE, "Received invalid vCard, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + if (contacts.isEmpty()) { + logger.warning("Received vCard without data, ignoring") + return + } else if (contacts.size > 1) + logger.warning("Received multiple vCards, using first one") + + val newData = contacts.first() + groupStrategy.verifyContactBeforeSaving(newData) + + var updated: LocalAddress? = null + + val existing = localCollection.findByName(fileName) + if (existing == null) { + // create new contact/group + if (newData.group) { + logger.log(Level.INFO, "Creating local group", newData) + val newGroup = LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newGroup) { + newGroup.add() + updated = newGroup + } + + } else { + logger.log(Level.INFO, "Creating local contact", newData) + val newContact = LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newContact) { + newContact.add() + updated = newContact + } + } + + } else { + // update existing local contact/group + logger.log(Level.INFO, "Updating $fileName in local address book", newData) + + SyncException.wrapWithLocalResource(existing) { + if ((existing is LocalGroup && newData.group) || (existing is LocalContact && !newData.group)) { + // update contact / group + + existing.update( + data = newData, + fileName = fileName, + eTag = eTag, + flags = LocalResource.FLAG_REMOTELY_PRESENT, + scheduleTag = null + ) + updated = existing + + } else { + // group has become an individual contact or vice versa, delete and create with new type + existing.deleteLocal() + + if (newData.group) { + logger.log(Level.INFO, "Creating local group (was contact before)", newData) + val newGroup = LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newGroup) { + newGroup.add() + updated = newGroup + } + + } else { + logger.log(Level.INFO, "Creating local contact (was group before)", newData) + val newContact = LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newContact) { + newContact.add() + updated = newContact + } + } + } + } + } + + // update hash code of updated contact, if applicable + (updated as? LocalContact)?.let { updatedContact -> + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + dirtyVerifier.getOrNull()?.updateHashCode(localCollection, updatedContact) + } + } + + + // downloader helper class + + private inner class ResourceDownloader( + val baseUrl: HttpUrl + ): Contact.Downloader { + + override fun download(url: String, accepts: String): ByteArray? { + val httpUrl = url.toHttpUrlOrNull() + if (httpUrl == null) { + logger.log(Level.SEVERE, "Invalid external resource URL", url) + return null + } + + // authenticate only against a certain host, and only upon request + httpClientBuilder + .fromAccount(account, onlyHost = baseUrl.host) + .followRedirects(true) // allow redirects + .build() + .use { httpClient -> + try { + val response = httpClient.okHttpClient.newCall(Request.Builder() + .get() + .url(httpUrl) + .build()).execute() + + if (response.isSuccessful) + return response.body.bytes() + else + logger.warning("Couldn't download external resource") + } catch(e: IOException) { + logger.log(Level.SEVERE, "Couldn't download external resource", e) + } + } + + return null + } + } + + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_contact) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt new file mode 100644 index 0000000..2c76b8b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -0,0 +1,217 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.text.format.Formatter +import androidx.annotation.OpenForTesting +import at.bitfire.dav4jvm.DavCalendar +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.caldav.CalendarData +import at.bitfire.dav4jvm.property.caldav.GetCTag +import at.bitfire.dav4jvm.property.caldav.MaxResourceSize +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.dav4jvm.property.webdav.SyncToken +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.di.SyncDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.resource.LocalJtxICalObject +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.JtxICalObject +import at.bitfire.synctools.exception.InvalidICalendarException +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runInterruptible +import okhttp3.HttpUrl +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.io.Reader +import java.io.StringReader +import java.util.logging.Level + +class JtxSyncManager @AssistedInject constructor( + @Assisted account: Account, + @Assisted httpClient: HttpClient, + @Assisted syncResult: SyncResult, + @Assisted localCollection: LocalJtxCollection, + @Assisted collection: Collection, + @Assisted resync: ResyncType?, + @SyncDispatcher syncDispatcher: CoroutineDispatcher +): SyncManager( + account, + httpClient, + SyncDataType.TASKS, + syncResult, + localCollection, + collection, + resync, + syncDispatcher +) { + + @AssistedFactory + interface Factory { + fun jtxSyncManager( + account: Account, + httpClient: HttpClient, + syncResult: SyncResult, + localCollection: LocalJtxCollection, + collection: Collection, + resync: ResyncType? + ): JtxSyncManager + } + + + override fun prepare(): Boolean { + davCollection = DavCalendar(httpClient.okHttpClient, collection.url) + + return true + } + + override suspend fun queryCapabilities() = + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + var syncState: SyncState? = null + runInterruptible { + davCollection.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[MaxResourceSize::class.java]?.maxSize?.let { maxSize -> + logger.info("Collection accepts resources up to ${Formatter.formatFileSize(context, maxSize)}") + } + + syncState = syncState(response) + } + } + } + syncState + } + + override fun generateUpload(resource: LocalJtxICalObject): RequestBody = + SyncException.wrapWithLocalResource(resource) { + logger.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource) + val os = ByteArrayOutputStream() + resource.write(os, Constants.iCalProdId) + os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + } + + override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT + + override suspend fun listAllRemote(callback: MultiResponseCallback) { + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + if (localCollection.supportsVTODO) { + logger.info("Querying tasks") + runInterruptible { + davCollection.calendarQuery("VTODO", null, null, callback) + } + } + + if (localCollection.supportsVJOURNAL) { + logger.info("Querying journals") + runInterruptible { + davCollection.calendarQuery("VJOURNAL", null, null, callback) + } + } + } + } + + override suspend fun downloadRemote(bunch: List) { + logger.info("Downloading ${bunch.size} iCalendars: $bunch") + // multiple iCalendars, use calendar-multi-get + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + runInterruptible { + davCollection.multiget(bunch) { response, _ -> + // See CalendarSyncManager for more information about the multi-get response + SyncException.wrapWithRemoteResource(response.href) wrapResource@{ + if (!response.isSuccess()) { + logger.warning("Ignoring non-successful multi-get response for ${response.href}") + return@wrapResource + } + + val iCal = response[CalendarData::class.java]?.iCalendar + if (iCal == null) { + logger.warning("Ignoring multi-get response without calendar-data") + return@wrapResource + } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + + processICalObject(response.href.lastSegment, eTag, StringReader(iCal)) + } + } + } + } + } + + override fun postProcess() { + localCollection.updateLastSync() + } + + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_event) + + + @OpenForTesting + internal fun processICalObject(fileName: String, eTag: String, reader: Reader) { + val icalobjects: MutableList = mutableListOf() + try { + // parse the reader content and return the list of ICalObjects + icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection)) + } catch (e: InvalidICalendarException) { + logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + logger.log(Level.INFO, "Found ${icalobjects.size} entries in $fileName", icalobjects) + + icalobjects.forEach { jtxICalObject -> + // if the entry is a recurring entry (and therefore has a recurid) + // we update the existing (generated) entry + val recurid = jtxICalObject.recurid + if(recurid != null) { + val local = localCollection.findRecurInstance(jtxICalObject.uid, recurid) + SyncException.wrapWithLocalResource(local) { + logger.log(Level.INFO, "Updating $fileName with recur instance $recurid in local list", jtxICalObject) + if(local != null) { + local.update(jtxICalObject) + } else { + val newLocal = LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newLocal) { + newLocal.applyNewData(jtxICalObject) + newLocal.add() + } + } + } + } else { + // otherwise we insert or update the main entry + val local = localCollection.findByName(fileName) + SyncException.wrapWithLocalResource(local) { + if (local != null) { + logger.log(Level.INFO, "Updating $fileName in local list", jtxICalObject) + local.eTag = eTag + local.update(jtxICalObject) + } else { + logger.log(Level.INFO, "Adding $fileName to local list", jtxICalObject) + + val newLocal = LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newLocal) { + newLocal.applyNewData(jtxICalObject) + newLocal.add() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt new file mode 100644 index 0000000..097b8d8 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt @@ -0,0 +1,85 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.os.Build +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.resource.LocalJtxCollectionStore +import at.bitfire.ical4android.TaskProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.runBlocking + +/** + * Sync logic for jtx board + */ +class JtxSyncer @AssistedInject constructor( + @Assisted account: Account, + @Assisted resync: ResyncType?, + @Assisted syncResult: SyncResult, + localJtxCollectionStore: LocalJtxCollectionStore, + private val jtxSyncManagerFactory: JtxSyncManager.Factory, + private val tasksAppManager: dagger.Lazy +): Syncer(account, resync, syncResult) { + + @AssistedFactory + interface Factory { + fun create(account: Account, resyncType: ResyncType?, syncResult: SyncResult): JtxSyncer + } + + override val dataStore = localJtxCollectionStore + + override val serviceType: String + get() = Service.TYPE_CALDAV + + + override fun prepare(provider: ContentProviderClient): Boolean { + // check whether jtx Board is new enough + try { + TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard) + } catch (e: TaskProvider.ProviderTooOldException) { + tasksAppManager.get().notifyProviderTooOld(e) + syncResult.contentProviderError = true + return false // Don't sync + } + + // make sure account can be seen by task provider + if (Build.VERSION.SDK_INT >= 26) { + /* Warning: If setAccountVisibility is called, Android 12 broadcasts the + AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs + and starts them again! So make sure setAccountVisibility is only called when necessary. */ + val am = AccountManager.get(context) + if (am.getAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName) != AccountManager.VISIBILITY_VISIBLE) + am.setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE) + } + return true + } + + override fun getDbSyncCollections(serviceId: Long): List = + collectionRepository.getSyncJtxCollections(serviceId) + + override fun syncCollection(provider: ContentProviderClient, localCollection: LocalJtxCollection, remoteCollection: Collection) { + logger.info("Synchronizing jtx collection $localCollection") + + val syncManager = jtxSyncManagerFactory.jtxSyncManager( + account, + httpClient.value, + syncResult, + localCollection, + remoteCollection, + resync + ) + runBlocking { + syncManager.performSync() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/ResyncType.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResyncType.kt new file mode 100644 index 0000000..f8c0623 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResyncType.kt @@ -0,0 +1,34 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +/** + * Used to signal that re-synchronization is requested during a sync. + * + * Re-synchronization means that synchronization shouldn't skip listing/downloading + * the entries even when then `sync-token` (or CTag) didn't change since the last sync. + */ +enum class ResyncType { + + /** + * **(Normal) re-synchronization**: all remote entries shall be listed regardless of the + * sync-token (or CTag) of the collection. Modified entries will then be downloaded as usual. + * + * Sample use-case: the past event time range setting has been modified, and we want + * to get the new list of all events (regardless of the sync-token). + */ + RESYNC_LIST, + + /** + * **Full re-synchronization**: all remote entries shall be listed regardless of the + * sync-token (or CTag) of the collection, and all entries will be downloaded again, + * either if they were not changed on the server since the last sync. + * + * Sample use-case: Contact group type setting is changed, and all vCards have to + * be downloaded and parsed again to determine their group memberships. + */ + RESYNC_ENTRIES + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncConditions.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncConditions.kt new file mode 100644 index 0000000..58bc565 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncConditions.kt @@ -0,0 +1,165 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import androidx.core.content.getSystemService +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.davdroid.ui.account.WifiPermissionsActivity +import at.bitfire.davdroid.util.PermissionUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Provides methods to check whether a sync shall be run for a given account. + */ +class SyncConditions @AssistedInject constructor( + @Assisted private val accountSettings: AccountSettings, + @ApplicationContext private val context: Context, + private val logger: Logger, + private val notificationRegistry: NotificationRegistry +) { + + @AssistedFactory + interface Factory { + fun create(accountSettings: AccountSettings): SyncConditions + } + + + /** + * Checks whether we are connected to the correct wifi (SSID) defined by user in the + * account settings. + * + * Note: Should be connected to some wifi before calling. + * + * @return *true* if connected to the correct wifi OR no wifi names were specified in + * account settings; *false* otherwise + */ + internal fun correctWifiSsid(): Boolean { + accountSettings.getSyncWifiOnlySSIDs()?.let { onlySSIDs -> + // check required permissions and location status + if (!PermissionUtils.canAccessWifiSsid(context)) { + // not all permissions granted; show notification + val intent = Intent(context, WifiPermissionsActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, accountSettings.account) + notificationRegistry.notifyPermissions(intent) + + logger.warning("Can't access WiFi SSID, aborting sync") + return false + } + + val wifi = context.getSystemService()!! + @Suppress("DEPRECATION") val info = wifi.connectionInfo + if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) { + logger.info("Connected to wrong WiFi network (${info.ssid}), aborting sync") + return false + } + logger.fine("Connected to WiFi network ${info.ssid}") + } + return true + } + + /** + * Checks whether we are connected to the Internet. + * + * On API 26+ devices, if a VPN is used, WorkManager might start the SyncWorker without an + * Internet connection (because [NetworkCapabilities.NET_CAPABILITY_VALIDATED] is always set for VPN connections). + * To prevent the start without internet access, we don't check for VPN connections by default + * (by using [NetworkCapabilities.NET_CAPABILITY_NOT_VPN]). + * + * However in special occasions (when syncing over a VPN without validated Internet on the + * underlying connection) we do not want to exclude VPNs. + * + * This method uses [AccountSettings.getIgnoreVpns]: if `true`, it filters VPN connections in the Internet check; + * `false` allows them as valid connection. + * + * @return whether we are connected to the Internet + */ + internal fun internetAvailable(): Boolean { + val connectivityManager = context.getSystemService()!! + @Suppress("DEPRECATION") + return connectivityManager.allNetworks.any { network -> + val capabilities = connectivityManager.getNetworkCapabilities(network) + logger.log( + Level.FINE, "Looking for validated Internet over this connection.", + arrayOf(connectivityManager.getNetworkInfo(network), capabilities) + ) + + if (capabilities != null) { + if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + logger.fine("Missing network capability: INTERNET") + return@any false + } + + if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) { + logger.fine("Missing network capability: VALIDATED") + return@any false + } + + val ignoreVpns = accountSettings.getIgnoreVpns() + if (ignoreVpns) + if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { + logger.fine("Missing network capability: NOT_VPN") + return@any false + } + + logger.fine("This connection can be used.") + /* return@any */ true + } else + // no network capabilities available, we can't use this connection + /* return@any */ false + } + } + + /** + * Checks whether we are connected to validated WiFi + */ + internal fun wifiAvailable(): Boolean { + val connectivityManager = context.getSystemService()!! + @Suppress("DEPRECATION") + connectivityManager.allNetworks.forEach { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + return true + } + } + return false + } + + /** + * Checks whether user imposed sync conditions from settings are met: + * - Sync only on WiFi? + * - Sync only on specific WiFi (SSID)? + * + * @return *true* if conditions are met; *false* if not + */ + fun wifiConditionsMet(): Boolean { + // May we sync without WiFi? + if (!accountSettings.getSyncWifiOnly()) + return true // yes, continue + + // WiFi required, is it available? + if (!wifiAvailable()) { + logger.info("Not on connected WiFi, stopping") + return false + } + // If execution reaches this point, we're on a connected WiFi + + // Check whether we are connected to the correct WiFi (in case SSID was provided) + return correctWifiSsid() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt new file mode 100644 index 0000000..9c1ce3d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncDataType.kt @@ -0,0 +1,78 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.content.Context +import android.provider.CalendarContract +import android.provider.ContactsContract +import at.bitfire.ical4android.TaskProvider +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +enum class SyncDataType { + + CONTACTS, + EVENTS, + TASKS; + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncDataTypeEntryPoint { + fun tasksAppManager(): TasksAppManager + } + + /** + * Returns authorities which exist for this sync data type. Used on [TASKS] the method + * may return an empty list if there are no tasks providers (installed tasks apps). + * + * @return list of authorities matching this data type + */ + fun possibleAuthorities(): List = + when (this) { + CONTACTS -> listOf(ContactsContract.AUTHORITY) + EVENTS -> listOf(CalendarContract.AUTHORITY) + TASKS -> TaskProvider.ProviderName.entries.map { it.authority } + } + + /** + * Returns the authority corresponding to this datatype. + * When more than one tasks provider exists (tasks apps installed) the authority for the active + * tasks provider (user selected tasks app) is returned. + * + * @param context android context used to determine the active/selected tasks provider + * @return the authority matching this data type or *null* for [TASKS] if no tasks app is installed + */ + fun currentAuthority(context: Context): String? = + when (this) { + CONTACTS -> ContactsContract.AUTHORITY + EVENTS -> CalendarContract.AUTHORITY + TASKS -> EntryPointAccessors.fromApplication(context) + .tasksAppManager() + .currentProvider() + ?.authority + } + + + companion object { + + fun fromAuthority(authority: String): SyncDataType { + return when (authority) { + ContactsContract.AUTHORITY -> + CONTACTS + CalendarContract.AUTHORITY -> + EVENTS + TaskProvider.ProviderName.JtxBoard.authority, + TaskProvider.ProviderName.TasksOrg.authority, + TaskProvider.ProviderName.OpenTasks.authority -> + TASKS + else -> throw IllegalArgumentException("Unknown authority: $authority") + } + } + + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt new file mode 100644 index 0000000..e17e2f0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt @@ -0,0 +1,94 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import at.bitfire.davdroid.resource.LocalResource +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl + +/** + * Exception that wraps another notification together with potential information about + * a local and/or remote resource that is related to the exception. + */ +class SyncException(cause: Throwable) : Exception(cause) { + + companion object { + + // provide lambda wrappers for setting the local/remote resource + + fun wrapWithLocalResource(localResource: LocalResource<*>?, body: () -> T): T = + runBlocking { + wrapWithLocalResourceSuspending(localResource, body) + } + + suspend fun wrapWithLocalResourceSuspending(localResource: LocalResource<*>?, body: suspend () -> T): T { + try { + return body() + } catch (e: SyncException) { + if (localResource != null) + e.setLocalResourceIfNull(localResource) + throw e + } catch (e: Throwable) { + throw if (localResource != null) + SyncException(e).setLocalResourceIfNull(localResource) + else + e + } + } + + fun wrapWithRemoteResource(remoteResource: HttpUrl?, body: () -> T): T = + runBlocking { + wrapWithRemoteResourceSuspending(remoteResource, body) + } + + suspend fun wrapWithRemoteResourceSuspending(remoteResource: HttpUrl?, body: suspend () -> T): T { + try { + return body() + } catch (e: SyncException) { + if (remoteResource != null) + e.setRemoteResourceIfNull(remoteResource) + throw e + } catch (e: Throwable) { + throw if (remoteResource != null) + SyncException(e).setRemoteResourceIfNull(remoteResource) + else + e + } + } + + fun unwrap(e: Throwable, contextReceiver: (SyncException) -> Unit) = + if (e is SyncException) { + contextReceiver(e) + e.cause!! + } else + e + + } + + + var localResource: LocalResource<*>? = null + private set + var remoteResource: HttpUrl? = null + private set + + fun setLocalResourceIfNull(local: LocalResource<*>): SyncException { + if (localResource == null) + localResource = local + + return this + } + + fun setRemoteResourceIfNull(remote: HttpUrl): SyncException { + if (remoteResource == null) + remoteResource = remote + + return this + } + + override fun toString(): String { + return "SyncException(localResource=$localResource, remoteResource=$remoteResource, cause=$cause)" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt new file mode 100644 index 0000000..9d8391a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -0,0 +1,813 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.Context +import android.os.DeadObjectException +import android.os.RemoteException +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Error +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.QuotedStringUtils +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.ConflictException +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.ForbiddenException +import at.bitfire.dav4jvm.exception.GoneException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.exception.PreconditionFailedException +import at.bitfire.dav4jvm.exception.ServiceUnavailableException +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.dav4jvm.property.caldav.GetCTag +import at.bitfire.dav4jvm.property.caldav.ScheduleTag +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.dav4jvm.property.webdav.SyncToken +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.repository.DavSyncStatsRepository +import at.bitfire.davdroid.resource.LocalCollection +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.synctools.storage.LocalStorageException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.RequestBody +import java.io.IOException +import java.net.HttpURLConnection +import java.security.cert.CertificateException +import java.util.LinkedList +import java.util.Optional +import java.util.concurrent.CancellationException +import java.util.concurrent.LinkedBlockingQueue +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.net.ssl.SSLHandshakeException + +/** + * Synchronizes a local collection with a remote collection. + * + * @param ResourceType type of local resources + * @param CollectionType type of local collection + * @param RemoteType type of remote collection + * + * @param account account to synchronize + * @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account] + * @param dataType data type to synchronize + * @param syncResult receiver for result of the synchronization (will be updated by [performSync]) + * @param localCollection local collection to synchronize (interface to content provider) + * @param collection collection info in the database + * @param resync whether re-synchronization is requested + */ +abstract class SyncManager, out CollectionType: LocalCollection, RemoteType: DavCollection>( + val account: Account, + val httpClient: HttpClient, + val dataType: SyncDataType, + val syncResult: SyncResult, + val localCollection: CollectionType, + val collection: Collection, + val resync: ResyncType?, + val syncDispatcher: CoroutineDispatcher +) { + + enum class SyncAlgorithm { + PROPFIND_REPORT, + COLLECTION_SYNC + } + + + @Inject + lateinit var accountRepository: AccountRepository + + @Inject + lateinit var collectionRepository: DavCollectionRepository + + @Inject + @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var syncStatsRepository: DavSyncStatsRepository + + @Inject + lateinit var serviceRepository: DavServiceRepository + + @Inject + lateinit var syncNotificationManagerFactory: SyncNotificationManager.Factory + + + protected lateinit var davCollection: RemoteType + + protected var hasCollectionSync = false + + private val syncNotificationManager by lazy { + syncNotificationManagerFactory.create(account) + } + + /** + * Push-Dont-Notify header, added to PUT and DELETE requests if subscription exists. + */ + private val pushDontNotifyHeader by lazy { + collection.pushSubscription?.let { pushSubscription -> + mapOf("Push-Dont-Notify" to QuotedStringUtils.asQuotedString(pushSubscription)) + } ?: emptyMap() + } + + suspend fun performSync() = withContext(syncDispatcher) { + // dismiss previous error notifications + syncNotificationManager.dismissInvalidResource(localCollectionTag = localCollection.tag) + + try { + logger.info("Preparing synchronization") + if (!prepare()) { + logger.info("No reason to synchronize, aborting") + return@withContext + } + syncStatsRepository.logSyncTime(collection.id, dataType) + + logger.info("Querying server capabilities") + var remoteSyncState = queryCapabilities() + + logger.info("Processing local deletes/updates") + val modificationsPresent = + processLocallyDeleted() or uploadDirty() // bitwise OR guarantees that both expressions are evaluated + + if (resync == ResyncType.RESYNC_ENTRIES) { + logger.info("Forcing re-synchronization of all entries") + + // forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC) + localCollection.lastSyncState = null + remoteSyncState = null + + // forget sync state of members (→ download all members again and update them locally) + localCollection.forgetETags() + } + + if (modificationsPresent || syncRequired(remoteSyncState)) + when (syncAlgorithm()) { + SyncAlgorithm.PROPFIND_REPORT -> { + logger.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)") + resetPresentRemotely() + + // get current sync state + if (modificationsPresent) + remoteSyncState = querySyncState() + + // list and process all entries at current sync state (which may be the same as or newer than remoteSyncState) + logger.info("Processing remote entries") + syncRemote { callback -> + listAllRemote(callback) + } + + logger.info("Deleting entries which are not present remotely anymore") + deleteNotPresentRemotely() + + logger.info("Post-processing") + postProcess() + + logger.log(Level.INFO, "Saving sync state", remoteSyncState) + localCollection.lastSyncState = remoteSyncState + } + + SyncAlgorithm.COLLECTION_SYNC -> { + var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN } + + var initialSync = false + if (syncState == null) { + logger.info("Starting initial sync") + initialSync = true + resetPresentRemotely() + } else if (syncState.initialSync == true) { + logger.info("Continuing initial sync") + initialSync = true + } + + var furtherChanges = false + do { + logger.info("Listing changes since $syncState") + syncRemote { callback -> + try { + val result = listRemoteChanges(syncState, callback) + syncState = SyncState.fromSyncToken(result.first, initialSync) + furtherChanges = result.second + } catch (e: HttpException) { + if (e.errors.contains(Error.VALID_SYNC_TOKEN)) { + logger.info("Sync token invalid, performing initial sync") + initialSync = true + resetPresentRemotely() + + val result = listRemoteChanges(null, callback) + syncState = SyncState.fromSyncToken(result.first, initialSync) + furtherChanges = result.second + } else + throw e + } + } + + logger.log(Level.INFO, "Saving sync state", syncState) + localCollection.lastSyncState = syncState + + logger.info("Server has further changes: $furtherChanges") + } while (furtherChanges) + + if (initialSync) { + // initial sync is finished, remove all local resources which have not been listed by server + logger.info("Deleting local resources which are not on server (anymore)") + deleteNotPresentRemotely() + + // remove initial sync flag + syncState!!.initialSync = false + logger.log(Level.INFO, "Initial sync completed, saving sync state", syncState) + localCollection.lastSyncState = syncState + } + + logger.info("Post-processing") + postProcess() + } + } + else + logger.info("Remote collection didn't change, no reason to sync") + + } catch (potentiallyWrappedException: Throwable) { + var local: LocalResource<*>? = null + var remote: HttpUrl? = null + + val e = SyncException.unwrap(potentiallyWrappedException) { + local = it.localResource + remote = it.remoteResource + } + + when (e) { + // DeadObjectException (may occur when syncing takes too long and process is demoted to cached): + // re-throw to base Syncer → will cause soft error and restart the sync process + is DeadObjectException -> + throw e + + // sync was cancelled or account has been removed: re-throw to Syncer + is CancellationException, + is InvalidAccountException -> + throw e + + // specific I/O errors + is SSLHandshakeException -> { + logger.log(Level.WARNING, "SSL handshake failed", e) + + // when a certificate is rejected by cert4android, the cause will be a CertificateException + if (e.cause !is CertificateException) + handleException(e, local, remote) + } + + // specific HTTP errors + is ServiceUnavailableException -> { + logger.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e) + // determine when to retry + syncResult.delayUntil = e.getDelayUntil().epochSecond + syncResult.numServiceUnavailableExceptions++ // Indicate a soft error occurred + } + + // all others + else -> + handleException(e, local, remote) + } + } + } + + + /** + * Prepares synchronization. Sets the lateinit property [davCollection]. + * + * @return whether synchronization shall be performed + */ + protected abstract fun prepare(): Boolean + + /** + * Queries the server for synchronization capabilities like specific report types, + * data formats etc. + * + * Should also query and save the initial sync state (e.g. CTag/sync-token). + * + * @return current sync state + */ + protected abstract suspend fun queryCapabilities(): SyncState? + + /** + * Processes locally deleted entries. This can mean: + * + * - forwarding them to the server (HTTP `DELETE`) + * - resetting their local state so that they will be downloaded again because they're read-only + * + * @return whether local resources have been processed so that a synchronization is always necessary + */ + protected open suspend fun processLocallyDeleted(): Boolean { + var numDeleted = 0 + + // Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before), + // but only if they don't have changed on the server. Then finally remove them from the local address book. + val localList = localCollection.findDeleted() + for (local in localList) { + SyncException.wrapWithLocalResourceSuspending(local) { + val fileName = local.fileName + if (fileName != null) { + val lastScheduleTag = local.scheduleTag + val lastETag = if (lastScheduleTag == null) local.eTag else null + logger.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)") + + val url = collection.url.newBuilder().addPathSegment(fileName).build() + val remote = DavResource(httpClient.okHttpClient, url) + SyncException.wrapWithRemoteResourceSuspending(url) { + try { + runInterruptible { + remote.delete( + ifETag = lastETag, + ifScheduleTag = lastScheduleTag, + headers = pushDontNotifyHeader, + ) {} + } + numDeleted++ + } catch (_: HttpException) { + logger.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)") + } + } + } else + logger.info("Removing local record #${local.id} which has been deleted locally and was never uploaded") + local.deleteLocal() + } + } + logger.info("Removed $numDeleted record(s) from server") + return numDeleted > 0 + } + + /** + * Processes locally modified resources to the server. This can mean: + * + * - uploading them to the server (HTTP `PUT`) + * - resetting their local state so that they will be downloaded again because they're read-only + * + * @return whether local resources have been processed so that a synchronization is always necessary + */ + protected open suspend fun uploadDirty(): Boolean { + var numUploaded = 0 + + coroutineScope { // structured concurrency + for (local in localCollection.findDirty()) + launch { + SyncException.wrapWithLocalResourceSuspending(local) { + uploadDirty(local) + numUploaded++ + } + } + } + logger.info("Sent $numUploaded record(s) to server") + return numUploaded > 0 + } + + /** + * Uploads a dirty local resource. + * + * @param local resource to upload + * @param forceAsNew whether the ETag (and Schedule-Tag) of [local] are ignored and the resource + * is created as a new resource on the server + */ + protected open suspend fun uploadDirty(local: ResourceType, forceAsNew: Boolean = false) { + val existingFileName = local.fileName + val fileName = if (existingFileName != null) { + // prepare upload (for UID etc), but ignore returned file name suggestion + local.prepareForUpload() + existingFileName + } else { + // prepare upload and use returned file name suggestion as new file name + local.prepareForUpload() + } + + val uploadUrl = collection.url.newBuilder().addPathSegment(fileName).build() + val remote = DavResource(httpClient.okHttpClient, uploadUrl) + + try { + SyncException.wrapWithRemoteResourceSuspending(uploadUrl) { + if (existingFileName == null || forceAsNew) { + // create new resource on server + logger.info("Uploading new resource ${local.id} -> $fileName") + val bodyToUpload = generateUpload(local) + + var newETag: String? = null + var newScheduleTag: String? = null + runInterruptible { + remote.put( + bodyToUpload, + ifNoneMatch = true, // fails if there's already a resource with that name + callback = { response -> + newETag = GetETag.fromResponse(response)?.eTag + newScheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag + }, + headers = pushDontNotifyHeader + ) + } + + logger.fine("Upload successful; new ETag=$newETag / Schedule-Tag=$newScheduleTag") + + // success (no exception thrown) + onSuccessfulUpload(local, fileName, newETag, newScheduleTag) + + } else { + // update resource on server + val ifScheduleTag = local.scheduleTag + val ifETag = if (ifScheduleTag == null) local.eTag else null + + logger.info("Uploading modified resource ${local.id} -> $fileName (if ETag=$ifETag / Schedule-Tag=$ifScheduleTag)") + val bodyToUpload = generateUpload(local) + + var updatedETag: String? = null + var updatedScheduleTag: String? = null + runInterruptible { + remote.put( + bodyToUpload, + ifETag = ifETag, + ifScheduleTag = ifScheduleTag, + callback = { response -> + updatedETag = GetETag.fromResponse(response)?.eTag + updatedScheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag + }, + headers = pushDontNotifyHeader + ) + } + + logger.fine("Upload successful; updated ETag=$updatedETag / Schedule-Tag=$updatedScheduleTag") + + // success (no exception thrown) + onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag) + } + } + + } catch (e: SyncException) { + when (val ex = e.cause) { + is ForbiddenException -> { + // HTTP 403 Forbidden + // If and only if the upload failed because of missing permissions, treat it like 412. + if (ex.errors.contains(Error.NEED_PRIVILEGES)) + logger.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex) + else + throw e + } + is NotFoundException, is GoneException -> { + // HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore) + if (!forceAsNew) { // first try; if this fails with 404, too, the collection is gone + logger.info("Original version of locally modified resource is not there (anymore), trying as fresh upload") + uploadDirty(local, forceAsNew = true) + return + } else { + // we tried with forceAsNew, collection probably gone + throw e + } + } + is ConflictException -> { + // HTTP 409 Conflict + // We can't interact with the user to resolve the conflict, so we treat 409 like 412. + logger.info("Edit conflict, ignoring") + } + is PreconditionFailedException -> { + // HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile. + // Ignore this condition so that the resource can be downloaded and reset again. + logger.info("Resource has been modified on the server before upload, ignoring") + } + else -> throw e + } + } + } + + /** + * Called after a successful upload (either of a new or an updated resource) so that the local + * _dirty_ state can be reset. + * + * Note: [CalendarSyncManager] overrides this method to additionally store the updated SEQUENCE. + */ + protected open fun onSuccessfulUpload(local: ResourceType, newFileName: String, eTag: String?, scheduleTag: String?) { + local.clearDirty(Optional.of(newFileName), eTag, scheduleTag) + } + + /** + * Generates the request body (iCalendar or vCard) from a local resource. + * + * @param resource local resource to generate the body from + * + * @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server + */ + protected abstract fun generateUpload(resource: ResourceType): RequestBody + + + /** + * Determines whether a sync is required because there were changes on the server. + * For instance, this method can compare the collection's `CTag`/`sync-token` with + * the last known local value. + * + * When local changes have been uploaded ([processLocallyDeleted] and/or + * [uploadDirty] were true), a sync is always required and this method + * should *not* be evaluated. + * + * Will return _true_ if [resync] is non-null and thus indicates re-synchronization. + * + * @param state remote sync state to compare local sync state with + * + * @return whether data has been changed on the server, i.e. whether running the + * sync algorithm is required + */ + protected open fun syncRequired(state: SyncState?): Boolean { + if (resync != null) + return true + + val localState = localCollection.lastSyncState + logger.info("Local sync state = $localState, remote sync state = $state") + return when (state?.type) { + SyncState.Type.SYNC_TOKEN -> { + val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value + lastKnownToken != state.value + } + SyncState.Type.CTAG -> { + val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value + lastKnownCTag != state.value + } + else -> true + } + } + + /** + * Determines which sync algorithm to use. + * @return + * - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV + * PROPFIND or specific REPORT requests), then compare and synchronize + * - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578) + */ + protected abstract fun syncAlgorithm(): SyncAlgorithm + + /** + * Marks all local resources which shall be taken into consideration for this + * sync as "synchronizing". Purpose of marking is that resources which have been marked + * and are not present remotely anymore can be deleted. + * + * Used together with [deleteNotPresentRemotely]. + */ + protected open fun resetPresentRemotely() { + val number = localCollection.markNotDirty(0) + logger.info("Number of local non-dirty entries: $number") + } + + /** + * Calls a callback to list remote resources. All resources from the returned + * list are downloaded and processed. + * + * @param listRemote function to list remote resources (for instance, all since a certain sync-token) + */ + protected open suspend fun syncRemote(listRemote: suspend (MultiResponseCallback) -> Unit) = coroutineScope { // structured concurrency + // download queue + val toDownload = LinkedBlockingQueue() + fun download(url: HttpUrl?) { + if (url != null) + toDownload += url + + if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) { + while (toDownload.isNotEmpty()) { + val bunch = LinkedList() + toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES) + launch { + downloadRemote(bunch) + } + } + } + } + + coroutineScope { // structured concurrency + listRemote { response, relation -> + // ignore non-members + if (relation != Response.HrefRelation.MEMBER) + return@listRemote + + // ignore collections + if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true) + return@listRemote + + val name = response.hrefName() + + if (response.isSuccess()) { + logger.fine("Found remote resource: $name") + + launch { + val local = localCollection.findByName(name) + SyncException.wrapWithLocalResource(local) { + if (local == null) { + logger.info("$name has been added remotely, queueing download") + download(response.href) + } else { + val localETag = local.eTag + val remoteETag = response[GetETag::class.java]?.eTag + ?: throw DavException("Server didn't provide ETag") + if (localETag == remoteETag) { + logger.info("$name has not been changed on server (ETag still $remoteETag)") + } else { + logger.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)") + download(response.href) + } + + // mark as remotely present, so that this resource won't be deleted at the end + local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT) + } + } + } + + } else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) { + // collection sync: resource has been deleted on remote server + launch { + localCollection.findByName(name)?.let { local -> + SyncException.wrapWithLocalResource(local) { + logger.info("$name has been deleted on server, deleting locally") + local.deleteLocal() + } + } + } + } + } + } + + // download remaining resources + download(null) + } + + protected abstract suspend fun listAllRemote(callback: MultiResponseCallback) + + protected open suspend fun listRemoteChanges(syncState: SyncState?, callback: MultiResponseCallback): Pair { + var furtherResults = false + + val report = runInterruptible { + davCollection.reportChanges( + syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value, + false, null, + GetETag.NAME + ) { response, relation -> + when (relation) { + Response.HrefRelation.SELF -> + furtherResults = response.status?.code == 507 + + Response.HrefRelation.MEMBER -> + callback.onResponse(response, relation) + + else -> + logger.fine("Unexpected sync-collection response: $response") + } + } + } + + var syncToken: SyncToken? = null + report.filterIsInstance().firstOrNull()?.let { + syncToken = it + } + if (syncToken == null) + throw DavException("Received sync-collection response without sync-token") + + return Pair(syncToken, furtherResults) + } + + /** + * Downloads and processes resources, given as a list of URLs. Will be called with a list + * of changed/new remote resources. + * + * Implementations should not use GET to fetch single resources, but always multi-get, even + * for single resources for these reasons: + * + * 1. GET can only be used without HTTP compression, because it may change the ETag. + * multi-get sends the ETag in the XML body, so there's no problem with compression. + * 2. Some servers are wrongly configured to suppress the ETag header in the response. + * With multi-get, the ETag is in the XML body, so it won't be affected by that. + * 3. If there are two methods to download resources (GET and multi-get), both methods + * have to be implemented, tested and maintained. Given that multi-get is required + * in any case, it's better to have only one method. + * 4. For users, it's strange behavior when DAVx5 can download multiple remote changes, + * but not a single one (or vice versa). So only one method is more user-friendly. + * 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests. + */ + protected abstract suspend fun downloadRemote(bunch: List) + + /** + * Locally deletes entries which are + * 1. not dirty and + * 2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT]. + * + * Used together with [resetPresentRemotely] when a full listing has been received from + * the server to locally delete resources which are not present remotely (anymore). + */ + protected open fun deleteNotPresentRemotely() { + val removed = localCollection.removeNotDirtyMarked(0) + logger.info("Removed $removed local resources which are not present on the server anymore") + } + + /** + * Post-processing of synchronized entries, for instance contact group membership operations. + */ + protected abstract fun postProcess() + + + // sync helpers + + protected fun syncState(dav: Response) = + dav[SyncToken::class.java]?.token?.let { + SyncState(SyncState.Type.SYNC_TOKEN, it) + } ?: + dav[GetCTag::class.java]?.cTag?.let { + SyncState(SyncState.Type.CTAG, it) + } + + private suspend fun querySyncState(): SyncState? { + var state: SyncState? = null + runInterruptible { + davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) + state = syncState(response) + } + } + return state + } + + /** + * Logs the exception, updates sync result and shows a notification to the user. + */ + private fun handleException(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?) { + var message: String + when (e) { + is IOException -> { + logger.log(Level.WARNING, "I/O error", e) + syncResult.numIoExceptions++ + message = context.getString(R.string.sync_error_io, e.localizedMessage) + } + + is UnauthorizedException -> { + logger.log(Level.SEVERE, "Not authorized anymore", e) + syncResult.numAuthExceptions++ + message = context.getString(R.string.sync_error_authentication_failed) + } + + is HttpException, is DavException -> { + logger.log(Level.SEVERE, "HTTP/DAV exception", e) + syncResult.numHttpExceptions++ + message = context.getString(R.string.sync_error_http_dav, e.localizedMessage) + } + + is LocalStorageException, is RemoteException -> { + logger.log(Level.SEVERE, "Couldn't access local storage", e) + syncResult.localStorageError = true + message = context.getString(R.string.sync_error_local_storage, e.localizedMessage) + } + + else -> { + logger.log(Level.SEVERE, "Unclassified sync error", e) + syncResult.numUnclassifiedErrors++ + message = e.localizedMessage ?: e::class.java.simpleName + } + } + + syncNotificationManager.notifyException( + dataType, + localCollection.tag, + message, + localCollection, + e, + local, + remote + ) + } + + protected fun notifyInvalidResource(e: Throwable, fileName: String) = + syncNotificationManager.notifyInvalidResource( + dataType, + localCollection.tag, + collection, + e, + fileName, + notifyInvalidResourceTitle() + ) + + protected abstract fun notifyInvalidResourceTitle(): String + + + companion object { + + /** Maximum number of resources that are requested with one multiget request. */ + const val MAX_MULTIGET_RESOURCES = 10 + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt new file mode 100644 index 0000000..8f9a205 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt @@ -0,0 +1,246 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.resource.LocalCollection +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.ui.DebugInfoActivity +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.davdroid.ui.account.AccountSettingsActivity +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger + +class SyncNotificationManager @AssistedInject constructor( + @Assisted val account: Account, + @ApplicationContext private val context: Context, + private val logger: Logger, + private val notificationRegistry: NotificationRegistry +) { + + @AssistedFactory + interface Factory { + fun create(account: Account): SyncNotificationManager + } + + /** + * Tries to inform the user that the content provider is missing or disabled. + * Use [dismissProviderError] to dismiss the notification. + * + * @param authority The authority of the content provider. + */ + fun notifyProviderError(authority: String) { + val (titleResource, textResource) = when (authority) { + ContactsContract.AUTHORITY -> + R.string.sync_warning_contacts_storage_disabled_title to + R.string.sync_warning_contacts_storage_disabled_description + CalendarContract.AUTHORITY -> + R.string.sync_warning_calendar_storage_disabled_title to + R.string.sync_warning_calendar_storage_disabled_description + else -> { + logger.log(Level.WARNING, "Content provider error for unknown authority: $authority") + return + } + } + + notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = authority) { + NotificationCompat.Builder(context, notificationRegistry.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(titleResource)) + .setContentText(context.getString(textResource)) + .setSubText(account.name) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .addAction(NotificationCompat.Action( + android.R.drawable.ic_menu_view, + context.getString(R.string.sync_warning_manage_apps), + PendingIntent.getActivity(context, 0, + Intent(Settings.ACTION_APPLICATION_SETTINGS), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + )) + .build() + } + } + + /** + * Dismisses the notification for content provider errors. + * + * @param authority The authority of the content provider used as notification tag. + */ + fun dismissProviderError(authority: String) = + dismissNotification(authority) + + /** + * Tries to inform the user that an exception occurred during synchronization. Includes the affected + * local resource, its collection, the URL, the exception and a user message. + * + * @param syncDataType The type of data which was synced. + * @param notificationTag The tag to use for the notification. + * @param message The message to show to the user. + * @param localCollection The affected local collection. + * @param e The exception that occurred. + * @param local The affected local resource. + * @param remote The remote URL that caused the exception. + */ + fun notifyException( + syncDataType: SyncDataType, + notificationTag: String, + message: String, + localCollection: LocalCollection<*>, + e: Throwable, + local: LocalResource<*>?, + remote: HttpUrl? + ) = notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = notificationTag) { + val contentIntent: Intent + if (e is UnauthorizedException) { + contentIntent = Intent(context, AccountSettingsActivity::class.java) + contentIntent.putExtra( + AccountSettingsActivity.EXTRA_ACCOUNT, + account + ) + } else { + contentIntent = buildDebugInfoIntent(syncDataType, e, local, remote) + } + + // to make the PendingIntent unique + contentIntent.data = "davdroid:exception/${e.hashCode()}".toUri() + + val channel: String + val priority: Int + if (e is IOException) { + channel = notificationRegistry.CHANNEL_SYNC_IO_ERRORS + priority = NotificationCompat.PRIORITY_MIN + } else { + channel = notificationRegistry.CHANNEL_SYNC_ERRORS + priority = NotificationCompat.PRIORITY_DEFAULT + } + + val builder = NotificationCompat.Builder(context, channel) + builder.setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(localCollection.title) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle(builder).bigText(message)) + .setSubText(account.name) + .setOnlyAlertOnce(true) + .setContentIntent( + TaskStackBuilder.create(context) + .addNextIntentWithParentStack(contentIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + .setPriority(priority) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + builder.build() + } + + /** + * Sends a notification to inform the user that a push notification has been received, the + * sync has been scheduled, but it still has not run. + * Use [dismissInvalidResource] to dismiss the notification. + * + * @param dataType The type of data which was synced. + * @param notificationTag The tag to use for the notification. + * @param collection The affected collection. + * @param fileName The name of the file containing the invalid resource. + * @param title The title of the notification. + */ + fun notifyInvalidResource( + dataType: SyncDataType, + notificationTag: String, + collection: Collection, + e: Throwable, + fileName: String, + title: String + ) { + notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_INVALID_RESOURCE, tag = notificationTag) { + val intent = buildDebugInfoIntent(dataType, e, null, collection.url.resolve(fileName)) + + val builder = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_SYNC_WARNINGS) + builder.setSmallIcon(R.drawable.ic_warning_notify) + .setContentTitle(title) + .setContentText(context.getString(R.string.sync_invalid_resources_ignoring)) + .setSubText(account.name) + .setContentIntent( + TaskStackBuilder.create(context) + .addNextIntent(intent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .priority = NotificationCompat.PRIORITY_LOW + builder.build() + } + } + + /** + * Dismisses the (error) notification for a specific collection. + * + * @param localCollectionTag The tag of the local collection which is used as notification tag also. + */ + fun dismissInvalidResource(localCollectionTag: String) = + dismissNotification(localCollectionTag) + + + // helpers + + /** + * Dismisses the sync error notification for a specific tag. + */ + private fun dismissNotification(tag: String) = NotificationManagerCompat.from(context) + .cancel(tag, NotificationRegistry.NOTIFY_SYNC_ERROR) + + /** + * Builds intent to go to debug information with the given exception, resource and remote address. + */ + private fun buildDebugInfoIntent( + dataType: SyncDataType, + e: Throwable, + local: LocalResource<*>?, + remote: HttpUrl? + ): Intent { + val builder = DebugInfoActivity.IntentBuilder(context) + .withAccount(account) + .withSyncDataType(dataType) + .withCause(e) + + if (local != null) + try { + // Add local resource summary, if available + builder.withLocalResource(local.getDebugSummary()) + + // Add URI to view local resource, if available + builder.withLocalResourceUri(local.getViewUri(context)) + } catch (_: Throwable) { + // Ignore all potential exceptions that arise from providing information about the local resource + } + + if (remote != null) + builder.withRemoteResource(remote) + + return builder.build() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncResult.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncResult.kt new file mode 100644 index 0000000..efdd5ee --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncResult.kt @@ -0,0 +1,55 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +/** + * This class represents the results of a sync operation from [Syncer]. + * + * Used by [at.bitfire.davdroid.sync.worker.BaseSyncWorker] to determine whether or not there will be retries etc. + */ +data class SyncResult( + // hard errors by Syncer + var contentProviderError: Boolean = false, + var localStorageError: Boolean = false, + + // hard errors by SyncManager + var numAuthExceptions: Long = 0, + var numHttpExceptions: Long = 0, + var numUnclassifiedErrors: Long = 0, + + // soft errors by SyncMAnager + var numDeadObjectExceptions: Long = 0, + var numIoExceptions: Long = 0, + var numServiceUnavailableExceptions: Long = 0, + + // Other values + var delayUntil: Long = 0 +) { + + /** + * Whether a hard error occurred. + */ + fun hasHardError(): Boolean = + contentProviderError + || localStorageError + || numAuthExceptions > 0 + || numHttpExceptions > 0 + || numUnclassifiedErrors > 0 + + /** + * Whether a soft error occurred. + */ + fun hasSoftError(): Boolean = + numDeadObjectExceptions > 0 + || numIoExceptions > 0 + || numServiceUnavailableExceptions > 0 + + /** + * Whether a hard or a soft error occurred. + */ + fun hasError(): Boolean = + hasHardError() || hasSoftError() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt new file mode 100644 index 0000000..30389b2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt @@ -0,0 +1,283 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.Context +import android.os.DeadObjectException +import androidx.annotation.VisibleForTesting +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.ServiceType +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalCollection +import at.bitfire.davdroid.resource.LocalDataStore +import at.bitfire.davdroid.sync.account.InvalidAccountException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.runBlocking +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Base class for sync code. + * + * Contains generic sync code, equal for all sync authorities. + * + * @param account account to synchronize + * @param resync whether re-synchronization is requested (`null` for normal sync) + * @param syncResult synchronization result, to be modified during sync + */ +abstract class Syncer, CollectionType: LocalCollection<*>>( + protected val account: Account, + protected val resync: ResyncType?, + protected val syncResult: SyncResult +) { + + abstract val dataStore: StoreType + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var collectionRepository: DavCollectionRepository + + @Inject + lateinit var httpClientBuilder: HttpClient.Builder + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var serviceRepository: DavServiceRepository + + @Inject + lateinit var syncNotificationManagerFactory: SyncNotificationManager.Factory + + @ServiceType + abstract val serviceType: String + + val syncNotificationManager by lazy { + syncNotificationManagerFactory.create(account) + } + + val httpClient = lazy { + httpClientBuilder.fromAccount(account).build() + } + + /** + * Creates, updates and/or deletes local collections (calendars, address books, etc) according to + * remote collection information. Then syncs the actual entries (events, tasks, contacts, etc) + * of the remaining, now up-to-date, collections. + */ + @VisibleForTesting + internal fun sync(provider: ContentProviderClient) { + // Collection type specific preparations + if (!prepare(provider)) { + logger.log(Level.WARNING, "Failed to prepare sync. Won't run sync.") + return + } + + // Find collections in database and provider which should be synced (are sync-enabled) + val dbCollections = getSyncEnabledCollections() + val localCollections = dataStore.getAll(account, provider) + + // Create/update/delete local collections according to DB + val updatedLocalCollections = updateCollections(provider, localCollections, dbCollections) + + // Sync local collection contents (events, contacts, tasks) + syncCollectionContents(provider, updatedLocalCollections, dbCollections) + } + + /** + * Finds sync enabled collections in database. They contain collection info which might have + * been updated by collection refresh [at.bitfire.davdroid.servicedetection.DavResourceFinder]. + * + * @return The sync enabled database collections as hash map identified by their ID + */ + @VisibleForTesting + internal fun getSyncEnabledCollections(): Map = runBlocking { + val dbCollections = mutableMapOf() + serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service -> + for (dbCollection in getDbSyncCollections(service.id)) + dbCollections[dbCollection.id] = dbCollection + } + + dbCollections + } + + /** + * Updates and deletes local collections. + * + * - Updates local collections with possibly new info from corresponding database collections. + * - Deletes local collections without a corresponding database collection. + * - Creates local collections for database collections without local match. + * + * @param provider Content provider client, used to create local collections + * @param localCollections The current local collections + * @param dbCollections The current database collections, possibly containing new information + * + * @return Updated list of local collections (obsolete collections removed, new collections added) + */ + @VisibleForTesting + internal fun updateCollections( + provider: ContentProviderClient, + localCollections: List, + dbCollections: Map + ): List { + // create mutable copies of input + val updatedLocalCollections = localCollections.toMutableList() + val newDbCollections = dbCollections.toMutableMap() + + for (localCollection in localCollections) { + val dbCollection = dbCollections.getOrDefault(localCollection.dbCollectionId, null) + if (dbCollection == null) { + // Collection not available in db = on server (anymore), delete and remove from the updated list + logger.info("Deleting local collection ${localCollection.title} without matching remote collection") + dataStore.delete(localCollection) + updatedLocalCollections -= localCollection + } else { + // Collection exists locally, update local collection and remove it from "to be created" map + logger.fine("Updating local collection ${localCollection.title} with $dbCollection") + dataStore.update(provider, localCollection, dbCollection) + newDbCollections -= dbCollection.id + } + } + + // Create local collections which are in DB, but don't exist locally yet + if (newDbCollections.isNotEmpty()) { + val toBeCreated = newDbCollections.values.toList() + logger.log(Level.INFO, "Creating new local collections", toBeCreated.toTypedArray()) + val newLocalCollections = createLocalCollections(provider, toBeCreated) + // Add the newly created collections to the updated list + updatedLocalCollections.addAll(newLocalCollections) + } + + return updatedLocalCollections + } + + /** + * Creates new local collections from database collections. + * + * @param provider Content provider client to access local collections + * @param dbCollections Database collections to be created as local collections + * + * @return Newly created local collections + */ + @VisibleForTesting + internal fun createLocalCollections( + provider: ContentProviderClient, + dbCollections: List + ): List = + dbCollections.map { collection -> + dataStore.create(provider, collection) + ?: throw IllegalStateException("Couldn't create local collection for $collection") + } + + /** + * Synchronize the actual collection contents. + * + * @param provider Content provider client to access local collections + * @param localCollections Collections to be synchronized + * @param dbCollections Remote collection information + */ + @VisibleForTesting + internal fun syncCollectionContents( + provider: ContentProviderClient, + localCollections: List, + dbCollections: Map + ) = localCollections.forEach { localCollection -> + dbCollections[localCollection.dbCollectionId]?.let { dbCollection -> + syncCollection(provider, localCollection, dbCollection) + } + } + + /** + * For collection specific sync preparations. + * + * @param provider Content provider for data store + * + * @return *true* to run the sync; *false* to abort + */ + open fun prepare(provider: ContentProviderClient): Boolean = true + + /** + * Get the local database collections which are sync-enabled (should by synchronized). + * + * @param serviceId The CalDAV or CardDAV service (account) to be synchronized + * + * @return Database collections to be synchronized + */ + abstract fun getDbSyncCollections(serviceId: Long): List + + /** + * Synchronizes local with remote collection contents. + * + * @param provider The content provider client to access the local collection to be updated + * @param localCollection The local collection to be synchronized + * @param remoteCollection The database collection representing the remote collection. Contains + * remote address of the collection to be synchronized. + */ + abstract fun syncCollection(provider: ContentProviderClient, localCollection: CollectionType, remoteCollection: Collection) + + /** + * Prepares the sync: + * + * - acquire content provider + * - handle occurring sync errors + */ + operator fun invoke() { + logger.info("${dataStore.authority} sync of $account initiated (resync=$resync)") + + try { + dataStore.acquireContentProvider(throwOnMissingPermissions = true) + } catch (e: SecurityException) { + logger.log(Level.WARNING, "Missing permissions for content provider authority ${dataStore.authority}", e) + /* Don't show a notification here without possibility to permanently dismiss it! + Some users intentionally don't grant all permissions for what is syncable. */ + return + }.use { provider -> + if (provider == null) { + /* Content provider is not available at all. + I.E. system app (like "calendar storage") is missing or disabled */ + logger.warning("Couldn't connect to content provider of authority ${dataStore.authority}") + syncNotificationManager.notifyProviderError(dataStore.authority) + syncResult.contentProviderError = true + return // Don't continue without provider + } + + // Dismiss previous content provider error notification + syncNotificationManager.dismissProviderError(dataStore.authority) + + // run sync + try { + val runSync = /* ose */ true + if (runSync) + sync(provider) + Unit + } catch (e: DeadObjectException) { + /* May happen when the remote process dies or (since Android 14) when IPC (for instance with the calendar provider) + is suddenly forbidden because our sync process was demoted from a "service process" to a "cached process". */ + logger.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e) + syncResult.numDeadObjectExceptions++ + + } catch (e: InvalidAccountException) { + logger.log(Level.WARNING, "Account was removed during synchronization", e) + + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't sync ${dataStore.authority}", e) + syncResult.numUnclassifiedErrors++ // Hard sync error + + } finally { + if (httpClient.isInitialized()) + httpClient.value.close() + logger.info("${dataStore.authority} sync of $account finished") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt new file mode 100644 index 0000000..bd8981a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt @@ -0,0 +1,86 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.os.Build +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.resource.LocalTaskListStore +import at.bitfire.ical4android.TaskProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.runBlocking + +/** + * Sync logic for tasks in CalDAV collections ({@code VTODO}). + */ +class TaskSyncer @AssistedInject constructor( + @Assisted account: Account, + @Assisted val providerName: TaskProvider.ProviderName, + @Assisted resync: ResyncType?, + @Assisted syncResult: SyncResult, + localTaskListStoreFactory: LocalTaskListStore.Factory, + private val tasksAppManager: dagger.Lazy, + private val tasksSyncManagerFactory: TasksSyncManager.Factory, +): Syncer(account, resync, syncResult) { + + @AssistedFactory + interface Factory { + fun create(account: Account, providerName: TaskProvider.ProviderName, resyncType: ResyncType?, syncResult: SyncResult): TaskSyncer + } + + override val dataStore = localTaskListStoreFactory.create(providerName) + + override val serviceType: String + get() = Service.TYPE_CALDAV + + + override fun prepare(provider: ContentProviderClient): Boolean { + // Don't sync if task provider is too old + try { + TaskProvider.checkVersion(context, providerName) + } catch (e: TaskProvider.ProviderTooOldException) { + tasksAppManager.get().notifyProviderTooOld(e) + syncResult.contentProviderError = true + return false // Don't sync + } + + // make sure account can be seen by task provider + if (Build.VERSION.SDK_INT >= 26) { + /* Warning: If setAccountVisibility is called, Android 12 broadcasts the + AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs + and starts them again! So make sure setAccountVisibility is only called when necessary. */ + val am = AccountManager.get(context) + if (am.getAccountVisibility(account, providerName.packageName) != AccountManager.VISIBILITY_VISIBLE) + am.setAccountVisibility(account, providerName.packageName, AccountManager.VISIBILITY_VISIBLE) + } + return true + } + + override fun getDbSyncCollections(serviceId: Long): List = + collectionRepository.getSyncTaskLists(serviceId) + + override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) { + logger.info("Synchronizing task list ${localCollection.id} with database collection ID: ${localCollection.dbCollectionId}") + + val syncManager = tasksSyncManagerFactory.tasksSyncManager( + account, + httpClient.value, + syncResult, + localCollection, + remoteCollection, + resync + ) + runBlocking { + syncManager.performSync() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksAppManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksAppManager.kt new file mode 100644 index 0000000..5017978 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksAppManager.kt @@ -0,0 +1,149 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import androidx.annotation.WorkerThread +import androidx.core.app.NotificationCompat +import androidx.core.app.TaskStackBuilder +import androidx.core.net.toUri +import at.bitfire.davdroid.R +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.resource.LocalDataStore +import at.bitfire.davdroid.resource.LocalJtxCollectionStore +import at.bitfire.davdroid.resource.LocalTaskListStore +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.ical4android.TaskProvider +import at.bitfire.ical4android.TaskProvider.ProviderName +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Responsible for setting/getting the currently used tasks app, and for communicating with it. + */ +class TasksAppManager @Inject constructor( + @ApplicationContext private val context: Context, + private val accountRepository: Lazy, + private val automaticSyncManager: AutomaticSyncManager, + private val logger: Logger, + private val notificationRegistry: Lazy, + private val settingsManager: SettingsManager, + private val localTaskListStoreFactory: LocalTaskListStore.Factory, + private val localJtxCollectionStore: Lazy, +) { + + /** + * Gets the currently selected tasks app, if installed. + * + * @return currently selected tasks app (when installed), or `null` if no tasks app is selected or the selected app is not installed + */ + fun currentProvider(): ProviderName? { + val authority = settingsManager.getString(Settings.SELECTED_TASKS_PROVIDER) ?: return null + return authorityToProviderName(authority) + } + + /** + * Like [currentProvider, but as a [Flow]. + */ + fun currentProviderFlow(): Flow = + settingsManager.getStringFlow(Settings.SELECTED_TASKS_PROVIDER).map { preferred -> + if (preferred != null) + authorityToProviderName(preferred) + else + null + } + + /** + * Converts an authority to a [ProviderName], if the authority is known and the provider is installed. + */ + private fun authorityToProviderName(authority: String): ProviderName? = + ProviderName.entries + .firstOrNull { it.authority == authority } + .takeIf { context.packageManager.resolveContentProvider(authority, 0) != null } + + + /** + * Sets up sync for the selected TaskProvider. + */ + @WorkerThread + fun selectProvider(selectedProvider: ProviderName?) { + logger.info("Selecting tasks app: $selectedProvider") + + val selectedAuthority = selectedProvider?.authority + settingsManager.putString(Settings.SELECTED_TASKS_PROVIDER, selectedAuthority) + + // check permission + if (selectedProvider != null && !PermissionUtils.havePermissions(context, selectedProvider.permissions)) + notificationRegistry.get().notifyPermissions() + + // check all accounts and update task sync + for (account in accountRepository.get().getAll()) + automaticSyncManager.updateAutomaticSync(account, SyncDataType.TASKS) + } + + + /** + * Show a notification that starts an Intent and redirects the user to the tasks app in the app store. + * + * @param e the TaskProvider.ProviderTooOldException to be shown + */ + fun notifyProviderTooOld(e: TaskProvider.ProviderTooOldException) { + val registry = notificationRegistry.get() + registry.notifyIfPossible(NotificationRegistry.NOTIFY_TASKS_PROVIDER_TOO_OLD) { + val message = context.getString(R.string.sync_error_tasks_required_version, e.provider.minVersionName) + + val pm = context.packageManager + val tasksAppInfo = pm.getPackageInfo(e.provider.packageName, 0) + val tasksAppLabel = tasksAppInfo.applicationInfo?.loadLabel(pm) + + val notify = NotificationCompat.Builder(context, registry.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_tasks_too_old, tasksAppLabel)) + .setContentText(message) + .setSubText("$tasksAppLabel ${e.installedVersionName}") + .setCategory(NotificationCompat.CATEGORY_ERROR) + + try { + val icon = pm.getApplicationIcon(e.provider.packageName) + if (icon is BitmapDrawable) + notify.setLargeIcon(icon.bitmap) + } catch (_: PackageManager.NameNotFoundException) { + // couldn't get provider app icon + } + + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${e.provider.packageName}".toUri()) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + if (intent.resolveActivity(pm) != null) + notify.setContentIntent( + TaskStackBuilder.create(context) + .addNextIntent(intent) + .getPendingIntent(0, flags) + ) + + notify.build() + } + } + + fun getDataStore(): LocalDataStore<*>? { + val provider = currentProvider() ?: return null + return when (provider) { + ProviderName.TasksOrg, ProviderName.OpenTasks -> localTaskListStoreFactory.create(provider) + ProviderName.JtxBoard -> localJtxCollectionStore.get() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt new file mode 100644 index 0000000..9dec0fb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt @@ -0,0 +1,195 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.text.format.Formatter +import at.bitfire.dav4jvm.DavCalendar +import at.bitfire.dav4jvm.MultiResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.caldav.CalendarData +import at.bitfire.dav4jvm.property.caldav.GetCTag +import at.bitfire.dav4jvm.property.caldav.MaxResourceSize +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.dav4jvm.property.webdav.SyncToken +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.di.SyncDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.resource.LocalTask +import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.resource.SyncState +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.ical4android.Task +import at.bitfire.synctools.exception.InvalidICalendarException +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runInterruptible +import okhttp3.HttpUrl +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.io.Reader +import java.io.StringReader +import java.util.logging.Level + +/** + * Synchronization manager for CalDAV collections; handles tasks (VTODO) + */ +class TasksSyncManager @AssistedInject constructor( + @Assisted account: Account, + @Assisted httpClient: HttpClient, + @Assisted syncResult: SyncResult, + @Assisted localCollection: LocalTaskList, + @Assisted collection: Collection, + @Assisted resync: ResyncType?, + @SyncDispatcher syncDispatcher: CoroutineDispatcher +): SyncManager( + account, + httpClient, + SyncDataType.TASKS, + syncResult, + localCollection, + collection, + resync, + syncDispatcher +) { + + @AssistedFactory + interface Factory { + fun tasksSyncManager( + account: Account, + httpClient: HttpClient, + syncResult: SyncResult, + localCollection: LocalTaskList, + collection: Collection, + resync: ResyncType? + ): TasksSyncManager + } + + + override fun prepare(): Boolean { + davCollection = DavCalendar(httpClient.okHttpClient, collection.url) + + return true + } + + override suspend fun queryCapabilities() = + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + var syncState: SyncState? = null + runInterruptible { + davCollection.propfind(0, MaxResourceSize.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[MaxResourceSize::class.java]?.maxSize?.let { maxSize -> + logger.info("Calendar accepts tasks up to ${Formatter.formatFileSize(context, maxSize)}") + } + + syncState = syncState(response) + } + } + } + + syncState + } + + override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT + + override fun generateUpload(resource: LocalTask): RequestBody = + SyncException.wrapWithLocalResource(resource) { + val task = requireNotNull(resource.task) + logger.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task) + + val os = ByteArrayOutputStream() + task.write(os, Constants.iCalProdId) + + os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + } + + override suspend fun listAllRemote(callback: MultiResponseCallback) { + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + logger.info("Querying tasks") + runInterruptible { + davCollection.calendarQuery("VTODO", null, null, callback) + } + } + } + + override suspend fun downloadRemote(bunch: List) { + logger.info("Downloading ${bunch.size} iCalendars: $bunch") + // multiple iCalendars, use calendar-multi-get + SyncException.wrapWithRemoteResourceSuspending(collection.url) { + runInterruptible { + davCollection.multiget(bunch) { response, _ -> + // See CalendarSyncManager for more information about the multi-get response + SyncException.wrapWithRemoteResource(response.href) wrapResource@{ + if (!response.isSuccess()) { + logger.warning("Ignoring non-successful multi-get response for ${response.href}") + return@wrapResource + } + + val iCal = response[CalendarData::class.java]?.iCalendar + if (iCal == null) { + logger.warning("Ignoring multi-get response without calendar-data") + return@wrapResource + } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + + processVTodo(response.href.lastSegment, eTag, StringReader(iCal)) + } + } + } + } + } + + override fun postProcess() { + val touched = localCollection.touchRelations() + logger.info("Touched $touched relations") + } + + // helpers + + private fun processVTodo(fileName: String, eTag: String, reader: Reader) { + val tasks: List + try { + tasks = Task.tasksFromReader(reader) + } catch (e: InvalidICalendarException) { + logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + if (tasks.size == 1) { + val newData = tasks.first() + + // update local task, if it exists + val local = localCollection.findByName(fileName) + SyncException.wrapWithLocalResource(local) { + if (local != null) { + logger.log(Level.INFO, "Updating $fileName in local task list", newData) + local.eTag = eTag + local.update(newData) + } else { + logger.log(Level.INFO, "Adding $fileName to local task list", newData) + val newLocal = LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT) + SyncException.wrapWithLocalResource(newLocal) { + newLocal.add() + } + } + } + } else + logger.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName") + } + + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_task) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AccountAuthenticatorService.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AccountAuthenticatorService.kt new file mode 100644 index 0000000..ca7b57e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AccountAuthenticatorService.kt @@ -0,0 +1,53 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid.sync.account + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.os.bundleOf +import at.bitfire.davdroid.R + + +/** + * Account authenticator for the DAVx5 account type. + */ +class AccountAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + private class AccountAuthenticator( + val context: Context + ): AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?) = + bundleOf( + AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE to response, + AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, + AccountManager.KEY_ERROR_MESSAGE to context.getString(R.string.account_prefs_use_app) + ) + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AccountsCleanupWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AccountsCleanupWorker.kt new file mode 100644 index 0000000..5278237 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AccountsCleanupWorker.kt @@ -0,0 +1,125 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.account + +import android.accounts.AccountManager +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.hilt.work.HiltWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.resource.LocalAddressBook +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.time.Duration +import java.util.concurrent.Semaphore +import java.util.logging.Level +import java.util.logging.Logger + +@HiltWorker +class AccountsCleanupWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted workerParameters: WorkerParameters, + private val accountRepository: AccountRepository, + private val db: AppDatabase, + private val logger: Logger +): Worker(context, workerParameters) { + + @AssistedFactory + @VisibleForTesting + interface Factory { + fun create(appContext: Context, workerParams: WorkerParameters): AccountsCleanupWorker + } + + private val accountManager = AccountManager.get(context) + + override fun doWork(): Result { + lockAccountsCleanup() + try { + cleanUpServices() + cleanUpAddressBooks() + } finally { + unlockAccountsCleanup() + } + return Result.success() + } + + /** + * Deletes services in the database which are not associated to a valid account. + */ + @VisibleForTesting + internal fun cleanUpServices() { + // Later, accounts which are not in the DB should be deleted here + + // Delete orphaned services in DB – only necessary as long as accounts are implemented as system accounts (not in DB) + val accounts = accountRepository.getAll() + logger.log(Level.INFO, "Cleaning up accounts. Currently existing accounts:", accounts) + val serviceDao = db.serviceDao() + if (accounts.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(accounts.map { it.name }.toTypedArray()) + } + + /** + * Deletes address book accounts which are not assigned to a valid account. + */ + @VisibleForTesting + internal fun cleanUpAddressBooks() { + val accounts = accountRepository.getAll() + for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) { + val accountName = accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) + val accountType = accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) + if (!accounts.any { it.name == accountName && it.type == accountType }) { + // If no valid account exists for this address book, we can delete it + logger.info("Deleting address book account without valid account: $addressBookAccount") + accountManager.removeAccountExplicitly(addressBookAccount) + } + } + } + + + companion object { + + const val NAME = "accounts-cleanup" + + private val mutex = Semaphore(1) + /** + * Prevents account cleanup from being run until `unlockAccountsCleanup` is called. + * Can only be active once at the same time globally (blocking). + */ + fun lockAccountsCleanup() = mutex.acquire() + /** Must be called exactly one time after calling `lockAccountsCleanup`. */ + fun unlockAccountsCleanup() = mutex.release() + + /** + * Enqueues [AccountsCleanupWorker] to be run once as soon as possible. + */ + fun enqueue(context: Context) { + // run once + val rq = OneTimeWorkRequestBuilder() + WorkManager.getInstance(context).enqueue(rq.build()) + } + + /** + * Enqueues [AccountsCleanupWorker] to be run regularly (but not necessarily now). + */ + fun enable(context: Context) { + // run every day + val rq = PeriodicWorkRequestBuilder(Duration.ofDays(1)) + WorkManager.getInstance(context).enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, rq.build()) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AddressBookAuthenticatorService.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AddressBookAuthenticatorService.kt new file mode 100644 index 0000000..245275d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/AddressBookAuthenticatorService.kt @@ -0,0 +1,48 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ +package at.bitfire.davdroid.sync.account + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.os.bundleOf +import at.bitfire.davdroid.R + +class AddressBookAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + private class AccountAuthenticator( + val context: Context + ): AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?) = bundleOf( + AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE to response, + AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, + AccountManager.KEY_ERROR_MESSAGE to context.getString(R.string.account_prefs_use_app) + ) + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/account/InvalidAccountException.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/InvalidAccountException.kt new file mode 100644 index 0000000..c7a4cfc --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/InvalidAccountException.kt @@ -0,0 +1,12 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.account + +import android.accounts.Account + +/** + * Thrown when an account is invalid (usually because it doesn't exist anymore). + */ +class InvalidAccountException(account: Account): Exception("Invalid account: $account") \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/account/SystemAccountUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/SystemAccountUtils.kt new file mode 100644 index 0000000..f361cd9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/account/SystemAccountUtils.kt @@ -0,0 +1,71 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.account + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.os.Bundle +import at.bitfire.davdroid.util.SensitiveString +import java.util.logging.Logger + +object SystemAccountUtils { + + /** + * Creates a system account and makes sure the user data are set correctly. + * + * @param context operating context + * @param account account to create + * @param userData user data to set + * @param password password to set + * + * @return whether the account has been created + * + * @throws IllegalArgumentException when user data contains non-String values + * @throws IllegalStateException if user data can't be set + */ + fun createAccount(context: Context, account: Account, userData: Bundle, password: SensitiveString? = null): Boolean { + // validate user data + for (key in userData.keySet()) { + userData.get(key)?.let { entry -> + if (entry !is String) + throw IllegalArgumentException("userData[$key] is ${entry::class.java} (expected: String)") + } + } + + // create account + val manager = AccountManager.get(context) + if (!manager.addAccountExplicitly(account, password?.asString(), userData)) + return false + + // Android seems to lose the initial user data sometimes, so make sure that the values are set + for (key in userData.keySet()) + manager.setAndVerifyUserData(account, key, userData.getString(key)) + + return true + } + +} + +/** + * [AccountManager.setUserData] has been found to be unreliable at times. This extension function + * checks whether the user data has actually been set and retries up to ten times before failing silently. + * + * It should only be used to store the reference to the database (like the collection ID that this account represents). + * Everything else should be in the DB. + */ +fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: String?) { + for (i in 1..10) { + if (getUserData(account, key) == value) + /* already set / success */ + return + + setUserData(account, key, value) + + // wait a bit because AccountManager access sometimes seems a bit asynchronous + Thread.sleep(100) + } + Logger.getGlobal().warning("AccountManager failed to set $account user data $key := $value") +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapter.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapter.kt new file mode 100644 index 0000000..d99fd41 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapter.kt @@ -0,0 +1,19 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.adapter + +import android.os.IBinder + +/** + * Interface for an Android sync adapter, as created by [SyncAdapterService]. + * + * Sync adapters are bound services that communicate over IPC, so the only method is + * [getBinder], which returns the sync adapter binder. + */ +interface SyncAdapter { + + fun getBinder(): IBinder + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt new file mode 100644 index 0000000..3249f85 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt @@ -0,0 +1,186 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.adapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.IBinder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import at.bitfire.davdroid.R +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.SyncConditions +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.sync.worker.BaseSyncWorker +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Entry point for the Sync Adapter Framework. + * + * Handles incoming sync requests from the Sync Adapter Framework. + * + * Although we do not use the sync adapter for syncing anymore, we keep this sole + * adapter to provide exported services, which allow android system components and calendar, + * contacts or task apps to sync via DAVx5. + * + * All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration]. + */ +class SyncAdapterImpl @Inject constructor( + private val accountSettingsFactory: AccountSettings.Factory, + private val collectionRepository: DavCollectionRepository, + private val serviceRepository: DavServiceRepository, + @ApplicationContext context: Context, + private val logger: Logger, + private val syncConditionsFactory: SyncConditions.Factory, + private val syncWorkerManager: SyncWorkerManager +): AbstractThreadedSyncAdapter( + /* context = */ context, + /* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set. + // Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters. +), SyncAdapter { + + /** + * Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework + * requests cancellation. + */ + private val waitScope = CoroutineScope(Dispatchers.Default) + + override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // We have to pass this old SyncFramework extra for an Android 7 workaround + val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) + logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)") + + // If we should sync an address book account - find the account storing the settings + val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book)) + AccountManager.get(context) + .getUserData(accountOrAddressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) + ?.toLongOrNull() + ?.let { collectionId -> + collectionRepository.get(collectionId)?.let { collection -> + serviceRepository.getBlocking(collection.serviceId)?.let { service -> + Account(service.accountName, context.getString(R.string.account_type)) + } + } + } + else + accountOrAddressBookAccount + + if (account == null) { + logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection") + return + } + + // Check sync conditions + val accountSettings = try { + accountSettingsFactory.create(account) + } catch (e: InvalidAccountException) { + logger.log(Level.WARNING, "Account doesn't exist anymore", e) + return + } + val syncConditions = syncConditionsFactory.create(accountSettings) + // Should we run the sync at all? + if (!syncConditions.wifiConditionsMet()) { + logger.info("Sync conditions not met. Aborting sync framework initiated sync") + return + } + + logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it") + val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.Companion.fromAuthority(authority), fromUpload = upload) + + // Android 14+ does not handle pending sync state correctly. + // As a defensive workaround, we can cancel specifically this still pending sync only + // See: https://github.com/bitfireAT/davx5-ose/issues/1458 +// if (Build.VERSION.SDK_INT >= 34) { +// logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " + +// "account=$accountOrAddressBookAccount authority=$authority upload=$upload") +// syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras) +// } + + /* Because we are not allowed to observe worker state on a background thread, we can not + use it to block the sync adapter. Instead we use a Flow to get notified when the sync + has finished. */ + val workManager = WorkManager.getInstance(context) + + try { + val waitJob = waitScope.launch { + // wait for finished worker state + workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList -> + for (info in infoList) + if (info.state.isFinished) { + if (info.state == WorkInfo.State.FAILED) { + if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false)) + syncResult.tooManyRetries = true + else + syncResult.databaseError = true + } + cancel("$workerName has finished") + } + } + } + + runBlocking { + withTimeout(10 * 60 * 1000) { // block max. 10 minutes + waitJob.join() // wait until worker has finished + } + } + } catch (_: CancellationException) { + // waiting for work was cancelled, either by timeout or because the worker has finished + logger.fine("Not waiting for OneTimeSyncWorker anymore.") + } + + logger.log(Level.INFO, "Returning to sync framework.", syncResult) + } + + override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) { + logger.log(Level.WARNING, "Security exception for $account/$authority") + } + + override fun onSyncCanceled() { + logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore") + + // unblock sync framework + waitScope.cancel() + } + + override fun onSyncCanceled(thread: Thread) = onSyncCanceled() + + + // SyncAdapter implementation and Hilt module + + override fun getBinder(): IBinder = syncAdapterBinder + + @Module + @InstallIn(SingletonComponent::class) + abstract class RealSyncAdapterModule { + @Binds + abstract fun provide(impl: SyncAdapterImpl): SyncAdapter + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt new file mode 100644 index 0000000..0d2f3f5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterServices.kt @@ -0,0 +1,41 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.adapter + +import android.app.Service +import android.content.Intent +import dagger.hilt.InstallIn +import dagger.hilt.android.EarlyEntryPoint +import dagger.hilt.android.EarlyEntryPoints +import dagger.hilt.components.SingletonComponent + +abstract class SyncAdapterService: Service() { + + /** + * We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously + * create a [SyncAdapterService] instance before Hilt is initialized by the HiltTestRunner. + */ + @EarlyEntryPoint + @InstallIn(SingletonComponent::class) + interface SyncAdapterServicesEntryPoint { + fun syncAdapter(): SyncAdapter + } + + // create syncAdapter on demand and cache it + val syncAdapter by lazy { + val entryPoint = EarlyEntryPoints.get(applicationContext, SyncAdapterServicesEntryPoint::class.java) + entryPoint.syncAdapter() + } + + override fun onBind(intent: Intent?) = syncAdapter.getBinder() + +} + +// exported sync adapter services; we need a separate class for each authority +class CalendarsSyncAdapterService: SyncAdapterService() +class ContactsSyncAdapterService: SyncAdapterService() +class JtxSyncAdapterService: SyncAdapterService() +class OpenTasksSyncAdapterService: SyncAdapterService() +class TasksOrgSyncAdapterService: SyncAdapterService() \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt new file mode 100644 index 0000000..5197194 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt @@ -0,0 +1,270 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.adapter + +import android.accounts.Account +import android.content.ContentResolver +import android.content.Context +import android.content.SyncRequest +import android.os.Build +import android.os.Bundle +import androidx.annotation.WorkerThread +import at.bitfire.davdroid.resource.LocalAddressBookStore +import at.bitfire.davdroid.sync.SyncDataType +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Handles all Sync Adapter Framework related interaction. Other classes should never call + * `ContentResolver.setIsSyncable()` or something similar themselves. Everything sync-framework + * related must be handled by this class. + * + * Sync requests from the Sync Adapter Framework are handled by [SyncAdapterService]. + */ +class SyncFrameworkIntegration @Inject constructor( + @ApplicationContext private val context: Context, + private val localAddressBookStore: Lazy, + private val logger: Logger +) { + + /** + * Gets the global auto-sync setting that applies to all the providers and accounts. If this is + * false then the per-provider auto-sync setting is ignored. + */ + fun getMasterSyncAutomatically() = + ContentResolver.getMasterSyncAutomatically() + + /** + * Check if this account/provider is syncable. + */ + fun isSyncable(account: Account, authority: String): Boolean = + ContentResolver.getIsSyncable(account, authority) > 0 + + /** + * Enable this account/provider to be syncable. + */ + fun enableSyncAbility(account: Account, authority: String) { + logger.fine("Enabling sync framework for account=$account, authority=$authority") + if (ContentResolver.getIsSyncable(account, authority) != 1) + ContentResolver.setIsSyncable(account, authority, 1) + } + + /** + * Disable this account/provider to be syncable. + * + * If an authority is not syncable, this implies that there's no sync on content changes, too. + */ + fun disableSyncAbility(account: Account, authority: String) { + logger.fine("Disabling sync framework for account=$account, authority=$authority") + if (ContentResolver.getIsSyncable(account, authority) != 0) + ContentResolver.setIsSyncable(account, authority, 0) + } + + /** + * Check if the provider should be synced when content (contact, calendar event or task) changes. + */ + fun syncsOnContentChange(account: Account, authority: String) = + ContentResolver.getSyncAutomatically(account, authority) + + /** + * Enable syncing on content (contact, calendar event or task) changes. + * + * This implies that the [authority] is syncable, so this method makes the [authority] + * syncable if required. + */ + fun enableSyncOnContentChange(account: Account, authority: String) { + if (!isSyncable(account, authority)) + enableSyncAbility(account, authority) + + if (!ContentResolver.getSyncAutomatically(account, authority)) + setSyncOnContentChange(account, authority, true) + } + + /** + * Disable syncing on content (contact, calendar event or task) changes. + */ + fun disableSyncOnContentChange(account: Account, authority: String) { + if (ContentResolver.getSyncAutomatically(account, authority)) + setSyncOnContentChange(account, authority, false) + } + + /** + * Cancels the sync request in the Sync Framework for Android 14+. + * This is a workaround for the bug that the sync framework does not handle pending syncs correctly + * on Android 14+ (API level 34+). + * + * See: https://github.com/bitfireAT/davx5-ose/issues/1458 + * + * @param account The account for which the sync request should be canceled. + * @param authority The authority for which the sync request should be canceled. + * @param extras The original extras Bundle used to start the sync. + */ + fun cancelSync(account: Account, authority: String, extras: Bundle) { + // Recreate the sync request which was used to start this sync + val syncRequest = SyncRequest.Builder() + .setSyncAdapter(account, authority) + .setExtras(extras) + .syncOnce() + .build() + + // Cancel it + ContentResolver.cancelSync(syncRequest) + } + + /** + * Enables/disables sync adapter automatic sync (content triggered sync) for the given + * account and authority. Does *not* call [ContentResolver.setIsSyncable]. + * + * We use the sync adapter framework only for the trigger, actual syncing is implemented + * with WorkManager. The trigger comes in through SyncAdapterService. + * + * Because there is no callback for when the sync status/interval has been updated, this method + * blocks until the sync-on-content-change has been enabled or disabled, so it should not be + * called from the UI thread. + * + * @param account account to enable/disable content change sync triggers for + * @param enable *true* enables automatic sync; *false* disables it + * @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY]) + * @return whether the content triggered sync was enabled successfully + */ + @WorkerThread + private fun setSyncOnContentChange(account: Account, authority: String, enable: Boolean): Boolean { + logger.fine("Setting content-triggered syncs (sync framework) for account=$account, authority=$authority to enable=$enable") + // Try up to 10 times with 100 ms pause + repeat(10) { + if (setContentTrigger(account, authority, enable)) { + // Remove periodic syncs created by ContentResolver.setSyncAutomatically + ContentResolver.getPeriodicSyncs(account, authority).forEach { periodicSync -> + ContentResolver.removePeriodicSync( + periodicSync.account, + periodicSync.authority, + periodicSync.extras + ) + } + // Set successfully + return true + } + Thread.sleep(100) + } + // Failed to set + return false + } + + /** + * Enable or disable content change sync triggers of the Sync Adapter Framework. + * + * @param account account to enable/disable content change sync triggers for + * @param enable *true* enables automatic sync; *false* disables it + * @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY]) + * @return whether the content triggered sync was enabled successfully + */ + private fun setContentTrigger(account: Account, authority: String, enable: Boolean): Boolean = + if (enable) { + ContentResolver.setSyncAutomatically(account, authority, true) + /* return */ ContentResolver.getSyncAutomatically(account, authority) + } else { + ContentResolver.setSyncAutomatically(account, authority, false) + /* return */ !ContentResolver.getSyncAutomatically(account, authority) + } + + /** + * Observe whether any of the given data types is currently pending for sync. + * + * Note: On Android 14+ finished syncs stay by default pending. This is why we + * explicitly cancel the active sync in [SyncAdapterImpl] for Android 14+. Doing + * so allows us to have a reliable "pending" flag again, which is used in this method. + * + * @param account account to observe sync status for + * @param dataTypes data types to observe sync status for + * + * @return flow emitting true if any of the given data types has a sync pending, false otherwise + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun isSyncPending(account: Account, dataTypes: Iterable): Flow { + // Android 14+ does not handle pending sync state correctly. + // For now we simply always return false + // See also sync cancellation in [SyncAdapterImpl.onPerformSync] + if (Build.VERSION.SDK_INT >= 34) + return flowOf(false) + + // Determine the pending state for each data type of the account as separate flows + val pendingStateFlows: List> = dataTypes.mapNotNull { dataType -> + // Map datatype to authority + dataType.currentAuthority(context)?.let { authority -> + // If checking contacts, we need to check all address book accounts instead of the single main account + val accountsFlow: Flow> = when (dataType) { + SyncDataType.CONTACTS -> localAddressBookStore.get().getAddressBookAccountsFlow(account) + else -> flowOf(listOf(account)) + } + + // Return the pending state flow for accounts with this authority + anyPendingSyncFlow(accountsFlow, authority) + } + } + + // Combine the different per data type pending state flows into one + return combine(pendingStateFlows) { pendingStates -> + pendingStates.any { pending -> pending } + }.distinctUntilChanged() + } + + /** + * Maps the given accounts flow to a simple boolean flow telling us whether any of the accounts + * has a pending sync for given authority. + * + * @param accountsFlow accounts to check sync status for + * @param authority authority to check sync status for + * + * @return returns flow which emits *true* if any of the accounts has a sync pending for + * the given authority and *false* otherwise + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun anyPendingSyncFlow( + accountsFlow: Flow>, + authority: String + ): Flow = accountsFlow.flatMapLatest { accounts -> + // Observe sync pending state for the given accounts and data types + callbackFlow { + // Observe sync pending state + val listener = ContentResolver.addStatusChangeListener( + ContentResolver.SYNC_OBSERVER_TYPE_PENDING + ) { + trySend(anyPendingSync(accounts, authority)) + } + + // Emit initial value + trySend(anyPendingSync(accounts, authority)) + + // Clean up listener on close + awaitClose { ContentResolver.removeStatusChangeListener(listener) } + } + } + + /** + * Check if any of the given accounts have a sync pending for given authority. + * + * @param accounts accounts to check sync status for + * @param authority authority to check sync status for + * + * @return *true* if any of the given accounts has a sync pending for given authority; *false* otherwise + */ + private fun anyPendingSync(accounts: List, authority: String): Boolean = + accounts.any { account -> + ContentResolver.isSyncPending(account, authority).also { pending -> + logger.finer("Sync pending($account, $authority) = $pending") + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/CategoriesStrategy.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/CategoriesStrategy.kt new file mode 100644 index 0000000..b86997e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/CategoriesStrategy.kt @@ -0,0 +1,46 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.groups + +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.vcard4android.Contact +import java.util.Optional +import java.util.logging.Logger + +class CategoriesStrategy(val addressBook: LocalAddressBook): ContactGroupStrategy { + + private val logger: Logger + get() = Logger.getGlobal() + + override fun beforeUploadDirty() { + // groups with DELETED=1: set all members to dirty, then remove group + for (group in addressBook.findDeletedGroups()) { + logger.fine("Finally removing group $group") + group.markMembersDirty() + group.delete() + } + + // groups with DIRTY=1: mark all members as dirty, then clean DIRTY flag of group + for (group in addressBook.findDirtyGroups()) { + logger.fine("Marking members of modified group $group as dirty") + group.markMembersDirty() + group.clearDirty(Optional.empty(), null) + } + } + + override fun verifyContactBeforeSaving(contact: Contact) { + if (contact.group || contact.members.isNotEmpty()) { + logger.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact") + contact.group = false + contact.members.clear() + } + } + + override fun postProcess() { + logger.info("Removing empty groups") + addressBook.removeEmptyGroups() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/ContactGroupStrategy.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/ContactGroupStrategy.kt new file mode 100644 index 0000000..4e7dbaa --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/ContactGroupStrategy.kt @@ -0,0 +1,15 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.groups + +import at.bitfire.vcard4android.Contact + +interface ContactGroupStrategy { + + fun beforeUploadDirty() + fun verifyContactBeforeSaving(contact: Contact) + fun postProcess() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/VCard4Strategy.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/VCard4Strategy.kt new file mode 100644 index 0000000..9773446 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/groups/VCard4Strategy.kt @@ -0,0 +1,54 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.groups + +import android.content.ContentUris +import android.provider.ContactsContract +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalGroup +import at.bitfire.davdroid.sync.ContactsSyncManager.Companion.disjunct +import at.bitfire.synctools.storage.BatchOperation +import at.bitfire.synctools.storage.ContactsBatchOperation +import at.bitfire.vcard4android.Contact +import java.io.FileNotFoundException +import java.util.logging.Logger + +class VCard4Strategy(val addressBook: LocalAddressBook): ContactGroupStrategy { + + private val logger: Logger + get() = Logger.getGlobal() + + override fun beforeUploadDirty() { + /* Mark groups with changed members as dirty: + 1. Iterate over all dirty contacts. + 2. Check whether group memberships have changed by comparing group memberships and cached group memberships. + 3. Mark groups which have been added to/removed from the contact as dirty so that they will be uploaded. + 4. Successful upload will reset dirty flag and update cached group memberships. + */ + val batch = ContactsBatchOperation(addressBook.provider!!) + for (contact in addressBook.findDirtyContacts()) + try { + logger.fine("Looking for changed group memberships of contact ${contact.fileName}") + val cachedGroups = contact.getCachedGroupMemberships() + val currentGroups = contact.getGroupMemberships() + for (groupID in cachedGroups disjunct currentGroups) { + logger.fine("Marking group as dirty: $groupID") + batch += BatchOperation.CpoBuilder + .newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI, groupID))) + .withValue(ContactsContract.Groups.DIRTY, 1) + } + } catch(_: FileNotFoundException) { + } + batch.commit() + } + + override fun verifyContactBeforeSaving(contact: Contact) { + } + + override fun postProcess() { + LocalGroup.applyPendingMemberships(addressBook) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt new file mode 100644 index 0000000..ae8cf49 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/BaseSyncWorker.kt @@ -0,0 +1,284 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import android.os.Build +import androidx.annotation.IntDef +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import at.bitfire.davdroid.R +import at.bitfire.davdroid.push.PushNotificationManager +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.AddressBookSyncer +import at.bitfire.davdroid.sync.CalendarSyncer +import at.bitfire.davdroid.sync.JtxSyncer +import at.bitfire.davdroid.sync.ResyncType +import at.bitfire.davdroid.sync.SyncConditions +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.SyncResult +import at.bitfire.davdroid.sync.TaskSyncer +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.NO_RESYNC +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.RESYNC_ENTRIES +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.RESYNC_LIST +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.commonTag +import at.bitfire.davdroid.ui.NotificationRegistry +import at.bitfire.ical4android.TaskProvider +import dagger.Lazy +import kotlinx.coroutines.delay +import java.util.Collections +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +abstract class BaseSyncWorker( + context: Context, + private val workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + @Inject + lateinit var accountSettingsFactory: AccountSettings.Factory + + @Inject + lateinit var addressBookSyncer: AddressBookSyncer.Factory + + @Inject + lateinit var calendarSyncer: CalendarSyncer.Factory + + @Inject + lateinit var jtxSyncer: JtxSyncer.Factory + + @Inject + lateinit var logger: Logger + + @Inject + lateinit var notificationRegistry: NotificationRegistry + + @Inject + lateinit var pushNotificationManager: PushNotificationManager + + @Inject + lateinit var syncConditionsFactory: SyncConditions.Factory + + @Inject + lateinit var tasksAppManager: Lazy + + @Inject + lateinit var taskSyncer: TaskSyncer.Factory + + + override suspend fun doWork(): Result { + // ensure we got the required arguments + val account = Account( + inputData.getString(INPUT_ACCOUNT_NAME) ?: throw IllegalArgumentException("INPUT_ACCOUNT_NAME required"), + inputData.getString(INPUT_ACCOUNT_TYPE) ?: throw IllegalArgumentException("INPUT_ACCOUNT_TYPE required") + ) + val dataType = SyncDataType.valueOf(inputData.getString(INPUT_DATA_TYPE) ?: throw IllegalArgumentException("INPUT_SYNC_DATA_TYPE required")) + + val syncTag = commonTag(account, dataType) + logger.info("${javaClass.simpleName} called for $syncTag") + + if (!runningSyncs.add(syncTag)) { + logger.info("There's already another worker running for $syncTag, skipping") + return Result.success() + } + + // Dismiss any pending push notification + pushNotificationManager.dismiss(account, dataType) + + try { + val accountSettings = try { + accountSettingsFactory.create(account) + } catch (_: InvalidAccountException) { + val workId = workerParams.id + logger.warning("No valid account settings for account $account, cancelling worker $workId") + + val workManager = WorkManager.getInstance(applicationContext) + workManager.cancelWorkById(workId) + + return Result.failure() + } + + if (inputData.getBoolean(INPUT_MANUAL, false)) + logger.info("Manual sync, skipping network checks") + else { + val syncConditions = syncConditionsFactory.create(accountSettings) + + // check internet connection + if (!syncConditions.internetAvailable()) { + logger.info("WorkManager started SyncWorker without Internet connection. Aborting.") + return Result.success() + } + + // check WiFi restriction + if (!syncConditions.wifiConditionsMet()) { + logger.info("WiFi conditions not met. Won't run periodic sync.") + return Result.success() + } + } + + return doSyncWork(account, dataType) + } finally { + logger.info("${javaClass.simpleName} finished for $syncTag") + runningSyncs -= syncTag + + if (Build.VERSION.SDK_INT >= 31 && stopReason != WorkInfo.STOP_REASON_NOT_STOPPED) + logger.warning("Worker was stopped with reason: $stopReason") + } + } + + suspend fun doSyncWork(account: Account, dataType: SyncDataType): Result { + logger.info("Running ${javaClass.name}: account=$account, dataType=$dataType") + + // pass supplied parameters to the selected syncer + val resyncType: ResyncType? = when (inputData.getInt(INPUT_RESYNC, NO_RESYNC)) { + RESYNC_ENTRIES -> ResyncType.RESYNC_ENTRIES + RESYNC_LIST -> ResyncType.RESYNC_LIST + else -> null + } + + // Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround. + val syncFrameworkUpload = inputData.getBoolean(INPUT_UPLOAD, false) + + val syncResult = SyncResult() + + // What are we going to sync? Select syncer based on authority + val syncer = when (dataType) { + SyncDataType.CONTACTS -> + addressBookSyncer.create(account, resyncType, syncFrameworkUpload, syncResult) + SyncDataType.EVENTS -> + calendarSyncer.create(account, resyncType, syncResult) + SyncDataType.TASKS -> { + val currentProvider = tasksAppManager.get().currentProvider() + when (currentProvider) { + TaskProvider.ProviderName.JtxBoard -> + jtxSyncer.create(account, resyncType, syncResult) + TaskProvider.ProviderName.OpenTasks, + TaskProvider.ProviderName.TasksOrg -> + taskSyncer.create(account, currentProvider, resyncType, syncResult) + else -> { + logger.warning("No valid tasks provider found, aborting sync") + return Result.failure() + } + } + } + } + + // Start syncing + syncer() + + // convert SyncResult from Syncers to worker Data + val output = Data.Builder() + .putString("syncresult", syncResult.toString()) + + // Check for errors + if (syncResult.hasError()) { + val softErrorNotificationTag = "${account.type}-${account.name}-$dataType" + + // On soft errors the sync is retried a few times before considered failed + if (syncResult.hasSoftError()) { + logger.log(Level.WARNING, "Soft error while syncing", syncResult) + if (runAttemptCount < MAX_RUN_ATTEMPTS) { + val blockDuration = syncResult.delayUntil - System.currentTimeMillis() / 1000 + logger.warning("Waiting for $blockDuration seconds, before retrying ...") + + // We block the SyncWorker here so that it won't be started by the sync framework immediately again. + // This should be replaced by proper work scheduling as soon as we don't depend on the sync framework anymore. + if (blockDuration > 0) + delay(blockDuration * 1000) + + logger.warning("Retrying on soft error (attempt $runAttemptCount of $MAX_RUN_ATTEMPTS)") + return Result.retry() + } + + logger.warning("Max retries on soft errors reached ($runAttemptCount of $MAX_RUN_ATTEMPTS). Treating as failed") + notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = softErrorNotificationTag) { + NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_SYNC_IO_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(account.name) + .setContentText(applicationContext.getString(R.string.sync_error_retry_limit_reached)) + .setSubText(account.name) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() + } + + output.putBoolean(OUTPUT_TOO_MANY_RETRIES, true) + return Result.failure(output.build()) + } + + // If no soft error found, dismiss sync error notification + val notificationManager = NotificationManagerCompat.from(applicationContext) + notificationManager.cancel( + softErrorNotificationTag, + NotificationRegistry.NOTIFY_SYNC_ERROR + ) + + // On a hard error - fail with an error message + // Note: SyncManager should have notified the user + if (syncResult.hasHardError()) { + logger.log(Level.WARNING, "Hard error while syncing", syncResult) + return Result.failure(output.build()) + } + } + + logger.log(Level.INFO, "Sync worker succeeded", syncResult) + return Result.success(output.build()) + } + + + companion object { + + // common worker input parameters + internal const val INPUT_ACCOUNT_NAME = "accountName" + internal const val INPUT_ACCOUNT_TYPE = "accountType" + internal const val INPUT_DATA_TYPE = "dataType" + + /** set to `true` for user-initiated sync that skips network checks */ + internal const val INPUT_MANUAL = "manual" + + /** set to `true` for syncs that are caused because the sync framework notified us about local changes */ + internal const val INPUT_UPLOAD = "upload" + + /** Whether re-synchronization is requested. One of [NO_RESYNC] (default), [RESYNC_LIST] or [RESYNC_ENTRIES]. */ + internal const val INPUT_RESYNC = "resync" + @IntDef(NO_RESYNC, RESYNC_LIST, RESYNC_ENTRIES) + annotation class InputResync + internal const val NO_RESYNC = 0 + /** Re-synchronization is requested. See [ResyncType.RESYNC_LIST] for details. */ + internal const val RESYNC_LIST = 1 + /** Full re-synchronization is requested. See [ResyncType.RESYNC_ENTRIES] for details. */ + internal const val RESYNC_ENTRIES = 2 + + const val OUTPUT_TOO_MANY_RETRIES = "tooManyRetries" + + /** + * How often this work will be retried to run after soft (network) errors. + */ + internal const val MAX_RUN_ATTEMPTS = 5 + + /** + * Set of currently running syncs, identified by their [commonTag]. + */ + private val runningSyncs = Collections.synchronizedSet(HashSet()) + + /** + * This tag shall be added to every worker that is enqueued by a subclass. + */ + fun commonTag(account: Account, dataType: SyncDataType): String = + "sync-$dataType ${account.type}/${account.name}" + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/OneTimeSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/OneTimeSyncWorker.kt new file mode 100644 index 0000000..e0f0713 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/OneTimeSyncWorker.kt @@ -0,0 +1,68 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.hilt.work.HiltWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import at.bitfire.davdroid.R +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.ui.NotificationRegistry +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * One-time sync worker. + * + * Expedited: yes + * + * Long-running: no + */ +@HiltWorker +class OneTimeSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters +) : BaseSyncWorker(appContext, workerParams) { + + /** + * Used by WorkManager to show a foreground service notification for expedited jobs on Android <12. + */ + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_foreground_notify) + .setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title)) + .setContentText(applicationContext.getString(R.string.foreground_service_notify_text)) + .setStyle(NotificationCompat.BigTextStyle()) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) + .build() + return ForegroundInfo(NotificationRegistry.NOTIFY_SYNC_EXPEDITED, notification) + } + + + companion object { + + /** + * Unique work name of this worker. Can also be used as tag. + * + * Mainly used to query [WorkManager] for work state (by unique work name or tag). + * + * @param account the account this worker is running for + * @param dataType data type to be synchronized + * + * @return Name of this worker composed as "onetime-sync $authority ${account.type}/${account.name}" + */ + fun workerName(account: Account, dataType: SyncDataType): String = + "onetime-sync $dataType ${account.type}/${account.name}" + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/PeriodicSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/PeriodicSyncWorker.kt new file mode 100644 index 0000000..4f6d183 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/PeriodicSyncWorker.kt @@ -0,0 +1,63 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.hilt.work.HiltWorker +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import at.bitfire.davdroid.sync.SyncDataType +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Handles scheduled sync requests. + * + * The different periodic sync workers each carry a unique work name composed of the account and + * authority which they are responsible for. For each account there will be multiple dedicated periodic + * sync workers for each authority. See [PeriodicSyncWorker.workerName] for more information. + * + * Deferrable: yes (periodic) + * + * Expedited: no (→ no [getForegroundInfo]) + * + * Long-running: no + * + * **Important:** If this class is renamed (or its package is changed), already enqueued workers won't + * run anymore because WorkManager references the work by the full class name. + */ +@HiltWorker +class PeriodicSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters +) : BaseSyncWorker(appContext, workerParams) { + + @AssistedFactory + @VisibleForTesting + interface Factory { + fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker + } + + companion object { + + /** + * Unique work name of this worker. Can also be used as tag. + * + * Mainly used to query [WorkManager] for work state (by unique work name or tag). + * + * @param account the account this worker is running for + * @param dataType data type to be synchronized + * + * @return Name of this worker composed as "periodic-sync $authority ${account.type}/${account.name}" + */ + fun workerName(account: Account, dataType: SyncDataType): String = + "periodic-sync $dataType ${account.type}/${account.name}" + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/SyncWorkerManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/SyncWorkerManager.kt new file mode 100644 index 0000000..0bec97f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/worker/SyncWorkerManager.kt @@ -0,0 +1,309 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync.worker + +import android.accounts.Account +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkRequest +import at.bitfire.davdroid.push.PushNotificationManager +import at.bitfire.davdroid.sync.ResyncType +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_NAME +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_TYPE +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_DATA_TYPE +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_MANUAL +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_RESYNC +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_UPLOAD +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.RESYNC_ENTRIES +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.RESYNC_LIST +import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.commonTag +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.concurrent.TimeUnit +import java.util.logging.Logger +import javax.inject.Inject + +/** + * For building and managing synchronization workers (both one-time and periodic). + * + * One-time sync workers can be enqueued. Periodic sync workers can be enabled and disabled. + */ +class SyncWorkerManager @Inject constructor( + @ApplicationContext val context: Context, + val logger: Logger, + val pushNotificationManager: Lazy, + val tasksAppManager: Lazy +) { + + // one-time sync workers + + /** + * Builds a one-time sync worker for a specific account and authority. + * + * Arguments: see [enqueueOneTime] + * + * @return one-time sync work request for the given arguments + */ + fun buildOneTime( + account: Account, + dataType: SyncDataType, + manual: Boolean = false, + resync: ResyncType? = null, + fromUpload: Boolean = false + ): OneTimeWorkRequest { + // worker arguments + val argumentsBuilder = Data.Builder() + .putString(INPUT_DATA_TYPE, dataType.toString()) + .putString(INPUT_ACCOUNT_NAME, account.name) + .putString(INPUT_ACCOUNT_TYPE, account.type) + + if (manual) + argumentsBuilder.putBoolean(INPUT_MANUAL, true) + + when (resync) { + ResyncType.RESYNC_ENTRIES -> argumentsBuilder.putInt(INPUT_RESYNC, RESYNC_ENTRIES) + ResyncType.RESYNC_LIST -> argumentsBuilder.putInt(INPUT_RESYNC, RESYNC_LIST) + else -> { /* no explicit re-synchronization */ } + } + + argumentsBuilder.putBoolean(INPUT_UPLOAD, fromUpload) + + // build work request + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection + .build() + return OneTimeWorkRequestBuilder() + .addTag(OneTimeSyncWorker.workerName(account, dataType)) + .addTag(commonTag(account, dataType)) + .setInputData(argumentsBuilder.build()) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, // 30 sec + TimeUnit.MILLISECONDS + ) + .setConstraints(constraints) + + /* OneTimeSyncWorker is started by user or sync framework when there are local changes. + In both cases, synchronization should be done as soon as possible, so we set expedited. */ + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + + // build work request + .build() + } + + /** + * Requests immediate synchronization of an account with a specific authority. + * + * If there is no currently running one-time sync, the sync is enqueued normally. + * + * If there is a currently running one-time sync, another sync is appended to make sure + * a complete sync is run. This method makes however sure that there's only _one_ + * further sync in the queue. + * + * @param account account to sync + * @param dataType type of data to synchronize + * @param manual user-initiated sync (ignores network checks) + * @param resync whether to request (full) re-synchronization (`null` for normal sync) + * @param fromUpload whether this sync is initiated by a local change + * @param fromPush whether this sync is initiated by a push notification + * + * @return existing or newly created worker name + */ + fun enqueueOneTime( + account: Account, + dataType: SyncDataType, + manual: Boolean = false, + resync: ResyncType? = null, + fromUpload: Boolean = false, + fromPush: Boolean = false + ): String { + logger.info("Enqueueing unique worker for account=$account, dataType=$dataType, manual=$manual, resync=$resync, fromUpload=$fromUpload, fromPush=$fromPush") + + // enqueue and start syncing + val name = OneTimeSyncWorker.workerName(account, dataType) + val request = buildOneTime( + account = account, + dataType = dataType, + manual = manual, + resync = resync, + fromUpload = fromUpload + ) + + if (fromPush) + pushNotificationManager.get().notify(account, dataType) + + /* We want to append only one work request, regardless of how many sync requests came in. + So we have to append the work one time, and as soon as there is already a pending + appended work, stop adding more work. */ + + val workManager = WorkManager.getInstance(context) + synchronized(SyncWorkerManager::class.java) { + val currentWork = workManager.getWorkInfosForUniqueWork(name).get() + val alreadyAppended = currentWork.any { + it.state in setOf(WorkInfo.State.BLOCKED, WorkInfo.State.ENQUEUED) + } + if (!alreadyAppended) { + val op = workManager.enqueueUniqueWork(name, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + // for synchronization: wait until work is actually enqueued + op.result + } else + logger.fine("Another one-time sync already waiting, not adding more of $name") + } + + return name + } + + /** + * Requests immediate synchronization of an account with all applicable + * authorities (contacts, calendars, …). + * + * Arguments: see [enqueueOneTime] + */ + fun enqueueOneTimeAllAuthorities( + account: Account, + manual: Boolean = false, + resync: ResyncType? = null, + fromUpload: Boolean = false, + fromPush: Boolean = false + ) { + for (dataType in SyncDataType.entries) + enqueueOneTime( + account = account, + dataType = dataType, + manual = manual, + resync = resync, + fromUpload = fromUpload, + fromPush = fromPush + ) + } + + + // periodic sync workers + + /** + * Builds a periodic sync worker for a specific account and authority. + * + * Arguments: see [enablePeriodic] + * + * @return periodic sync work request for the given arguments + */ + fun buildPeriodic(account: Account, dataType: SyncDataType, interval: Long, syncWifiOnly: Boolean): PeriodicWorkRequest { + val arguments = Data.Builder() + .putString(INPUT_DATA_TYPE, dataType.toString()) + .putString(INPUT_ACCOUNT_NAME, account.name) + .putString(INPUT_ACCOUNT_TYPE, account.type) + .build() + val constraints = Constraints.Builder() + .setRequiredNetworkType( + if (syncWifiOnly) + NetworkType.UNMETERED + else + NetworkType.CONNECTED + ).build() + return PeriodicWorkRequestBuilder(interval, TimeUnit.SECONDS) + .addTag(PeriodicSyncWorker.workerName(account, dataType)) + .addTag(commonTag(account, dataType)) + .setInputData(arguments) + .setConstraints(constraints) + .build() + } + + /** + * Activate periodic synchronization of an account with a specific authority. + * + * @param account account to sync + * @param dataType type of data to synchronize + * @param interval interval between recurring syncs in seconds + * @return operation object to check when and whether activation was successful + */ + fun enablePeriodic(account: Account, dataType: SyncDataType, interval: Long, syncWifiOnly: Boolean): Operation { + logger.fine("Updating periodic worker for account=$account, dataType=$dataType, interval=$interval, syncWifiOnly=$syncWifiOnly") + val workRequest = buildPeriodic(account, dataType, interval, syncWifiOnly) + return WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PeriodicSyncWorker.workerName(account, dataType), + // if a periodic sync exists already, we want to update it with the new interval + // and/or new required network type (applies on next iteration of periodic worker) + ExistingPeriodicWorkPolicy.UPDATE, + workRequest + ) + } + + /** + * Disables periodic synchronization of an account for a specific authority. + * + * @param account account to sync + * @param dataType type of data to synchronize + * @return operation object to check process state of work cancellation + */ + fun disablePeriodic(account: Account, dataType: SyncDataType): Operation { + logger.fine("Disabling periodic worker for account=$account, dataType=$dataType") + return WorkManager.getInstance(context) + .cancelUniqueWork(PeriodicSyncWorker.workerName(account, dataType)) + } + + + // common / helpers + + /** + * Stops running sync workers and removes pending sync workers from queue, for all authorities. + */ + fun cancelAllWork(account: Account) { + val workManager = WorkManager.getInstance(context) + for (dataType in SyncDataType.entries) { + workManager.cancelUniqueWork(OneTimeSyncWorker.workerName(account, dataType)) + workManager.cancelUniqueWork(PeriodicSyncWorker.workerName(account, dataType)) + } + } + + /** + * Observes whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker]) + * exist, belonging to given account and authorities, and which are/is in the given worker state. + * + * @param workStates list of states of workers to match + * @param account the account which the workers belong to + * @param dataTypes data types of sync work + * @param whichTag function to generate tag that should be observed for given account and authority + * + * @return flow that emits `true` if at least one worker with matching query was found; `false` otherwise + */ + fun hasAnyFlow( + workStates: List, + account: Account? = null, + dataTypes: Iterable? = null, + whichTag: (account: Account, dataType: SyncDataType) -> String = { account, dataType -> + commonTag(account, dataType) + } + ): Flow { + val workQuery = WorkQuery.Builder.fromStates(workStates) + if (account != null && dataTypes != null) + workQuery.addTags( + dataTypes.map { dataType -> whichTag(account, dataType) } + ) + return WorkManager.getInstance(context) + .getWorkInfosFlow(workQuery.build()) + .map { workInfoList -> + workInfoList.isNotEmpty() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt new file mode 100644 index 0000000..e944cac --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt @@ -0,0 +1,360 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Context +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.composable.PixelBoxes +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.mikepenz.aboutlibraries.util.withContext +import dagger.BindsOptionalOf +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.Collator +import java.util.LinkedList +import java.util.Locale +import java.util.Optional +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +@AndroidEntryPoint +class AboutActivity: AppCompatActivity() { + + val model by viewModels() + + @Inject + lateinit var licenseInfoProvider: Optional + + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppTheme { + val uriHandler = LocalUriHandler.current + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { onSupportNavigateUp() }) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.navigate_up) + ) + } + }, + title = { + Text(stringResource(R.string.navigation_drawer_about)) + }, + actions = { + IconButton(onClick = { + uriHandler.openUri(ExternalUris.Homepage.baseUrl + .buildUpon() + .withStatParams(javaClass.simpleName) + .build().toString()) + }) { + Icon( + Icons.Default.Home, + contentDescription = stringResource(R.string.navigation_drawer_website) + ) + } + } + ) + } + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + val scope = rememberCoroutineScope() + val state = rememberPagerState(pageCount = { 3 }) + + TabRow(state.currentPage) { + Tab(state.currentPage == 0, onClick = { + scope.launch { state.scrollToPage(0) } + }) { + Text( + stringResource(R.string.app_name), + modifier = Modifier.padding(8.dp) + ) + } + Tab(state.currentPage == 1, onClick = { + scope.launch { state.scrollToPage(1) } + }) { + Text( + stringResource(R.string.about_translations), + modifier = Modifier.padding(8.dp) + ) + } + Tab(state.currentPage == 2, onClick = { + scope.launch { state.scrollToPage(2) } + }) { + Text( + stringResource(R.string.about_libraries), + modifier = Modifier.padding(8.dp) + ) + } + } + + HorizontalPager( + state, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalAlignment = Alignment.Top + ) { index -> + when (index) { + 0 -> AboutApp(licenseInfoProvider = licenseInfoProvider.getOrNull()) + 1 -> { + val translations = model.translations.collectAsStateWithLifecycle(emptyList()) + TranslatorsGallery(translations.value) + } + + 2 -> LibrariesContainer( + modifier = Modifier.fillMaxSize(), + padding = LibraryDefaults.libraryPadding( + contentPadding = PaddingValues(8.dp) + ), + dimensions = LibraryDefaults.libraryDimensions( + itemSpacing = 8.dp + ), + libraries = Libs.Builder() + .withContext(LocalContext.current) + .build() + ) + } + } + } + } + } + } + } + + + @HiltViewModel + class Model @Inject constructor( + @ApplicationContext val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger + ): ViewModel() { + + data class Translation( + val language: String, + val translators: Set + ) + + val translations: Flow> = flow { + val translations = loadTranslations() + emit(translations) + } + + private suspend fun loadTranslations(): List = withContext(ioDispatcher) { + try { + context.resources.assets.open("translators.json").use { stream -> + val jsonTranslations = JSONObject(stream.readBytes().decodeToString()) + val result = LinkedList() + for (langCode in jsonTranslations.keys()) { + val jsonTranslators = jsonTranslations.getJSONArray(langCode) + val translators = Array(jsonTranslators.length()) { idx -> + jsonTranslators.getString(idx) + } + + val langTag = langCode.replace('_', '-') + val language = Locale.forLanguageTag(langTag).displayName + result += Translation(language, translators.toSet()) + } + + // sort translations by localized language name + val collator = Collator.getInstance() + result.sortWith { o1, o2 -> + collator.compare(o1.language, o2.language) + } + + result + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't load translators", e) + emptyList() + } + } + + } + + + interface AppLicenseInfoProvider { + @Composable + fun LicenseInfo() + } + + @Module + @InstallIn(ActivityComponent::class) + interface AppLicenseInfoProviderModule { + @BindsOptionalOf + fun appLicenseInfoProvider(): AppLicenseInfoProvider + } + +} + + +@Composable +fun AboutApp(licenseInfoProvider: AboutActivity.AppLicenseInfoProvider? = null) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState())) { + Image( + UiUtils.adaptiveIconPainterResource(R.mipmap.ic_launcher), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier + .size(128.dp) + .align(Alignment.CenterHorizontally) + ) + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + + Text( + stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(R.string.about_copyright), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.about_license_info_no_warranty), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + PixelBoxes( + arrayOf(Color(0xFFFCF434), Color.White, Color(0xFF9C59D1), Color.Black), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) + + licenseInfoProvider?.LicenseInfo() + } +} + +@Composable +@Preview +fun AboutApp_Preview() { + AboutApp(licenseInfoProvider = object : AboutActivity.AppLicenseInfoProvider { + @Composable + override fun LicenseInfo() { + Text("Some flavored License Info") + } + }) +} + + +@Composable +fun TranslatorsGallery( + translations: List +) { + val collator = Collator.getInstance() + LazyColumn(Modifier.padding(8.dp)) { + items(translations) { translation -> + Text( + translation.language, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 4.dp) + ) + Text( + translation.translators + .sortedWith { a, b -> collator.compare(a, b) } + .joinToString(" · "), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } +} + +@Composable +@Preview +fun TranslatorsGallery_Sample() { + TranslatorsGallery(listOf( + AboutActivity.Model.Translation("Some Language", setOf("User 1", "User 2")), + AboutActivity.Model.Translation("Another Language", setOf("User 3", "User 4")) + )) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt new file mode 100644 index 0000000..ad6752e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -0,0 +1,58 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import at.bitfire.davdroid.ui.account.AccountActivity +import at.bitfire.davdroid.ui.intro.IntroActivity +import at.bitfire.davdroid.ui.setup.LoginActivity +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + + +@AndroidEntryPoint +class AccountsActivity: AppCompatActivity() { + + @Inject + lateinit var accountsDrawerHandler: AccountsDrawerHandler + + private val introActivityLauncher = registerForActivityResult(IntroActivity.Contract) { cancelled -> + if (cancelled) + finish() + } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // handle "Sync all" intent from launcher shortcut + val syncAccounts = intent.action == Intent.ACTION_SYNC + + setContent { + AccountsScreen( + initialSyncAccounts = syncAccounts, + onShowAppIntro = { + introActivityLauncher.launch(null) + }, + accountsDrawerHandler = accountsDrawerHandler, + onAddAccount = { + startActivity(Intent(this, LoginActivity::class.java)) + }, + onShowAccount = { account -> + val intent = Intent(this, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + startActivity(intent) + }, + onManagePermissions = { + startActivity(Intent(this, PermissionsActivity::class.java)) + } + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt new file mode 100644 index 0000000..d9e757e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsDrawerHandler.kt @@ -0,0 +1,304 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity +import kotlinx.coroutines.launch +import java.net.URI + +val LocalCloseDrawerHandler = compositionLocalOf { + AccountsDrawerHandler.CloseDrawerHandler() +} + +abstract class AccountsDrawerHandler { + + open class CloseDrawerHandler { + open fun closeDrawer() {} + } + + + @Composable + abstract fun MenuEntries( + snackbarHostState: SnackbarHostState + ) + + + @Composable + fun AccountsDrawer( + snackbarHostState: SnackbarHostState, + onCloseDrawer: () -> Unit + ) { + Column(modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + BrandingHeader() + + val closeDrawerHandler = object : CloseDrawerHandler() { + override fun closeDrawer() { + onCloseDrawer() + } + } + CompositionLocalProvider(LocalCloseDrawerHandler provides closeDrawerHandler) { + MenuEntries(snackbarHostState) + } + } + } + + + // menu section composables + + @Composable + open fun ImportantEntries( + snackbarHostState: SnackbarHostState + ) { + val context = LocalContext.current + val isBeta = + LocalInspectionMode.current || + BuildConfig.VERSION_NAME.contains("-alpha") || + BuildConfig.VERSION_NAME.contains("-beta") || + BuildConfig.VERSION_NAME.contains("-rc") + val scope = rememberCoroutineScope() + + MenuEntry( + icon = Icons.Default.Info, + title = stringResource(R.string.navigation_drawer_about), + onClick = { + context.startActivity(Intent(context, AboutActivity::class.java)) + } + ) + + if (isBeta) + MenuEntry( + icon = Icons.Default.Feedback, + title = stringResource(R.string.navigation_drawer_beta_feedback), + onClick = { + onBetaFeedback( + context, + onShowSnackbar = { text: String, actionLabel: String, action: () -> Unit -> + scope.launch { + if (snackbarHostState.showSnackbar(text, actionLabel) == SnackbarResult.ActionPerformed) + action() + } + } + ) + } + ) + + MenuEntry( + icon = Icons.Default.Settings, + title = stringResource(R.string.navigation_drawer_settings), + onClick = { + context.startActivity(Intent(context, AppSettingsActivity::class.java)) + } + ) + } + + @Composable + fun Tools() { + val context = LocalContext.current + + MenuHeading(R.string.navigation_drawer_tools) + MenuEntry( + icon = Icons.Default.Storage, + title = stringResource(R.string.webdav_mounts_title), + onClick = { + context.startActivity(Intent(context, WebdavMountsActivity::class.java)) + } + ) + } + + + // overridable actions + + open fun onBetaFeedback( + context: Context, + onShowSnackbar: (message: String, actionLabel: String, action: () -> Unit) -> Unit + ) { + val mailto = URI( + "mailto", "play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})", null + ) + val intent = Intent(Intent.ACTION_SENDTO, mailto.toString().toUri()) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + } + } + +} + + +// generic building blocks + +@Composable +fun MenuHeading(text: String) { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Text( + text, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(8.dp) + ) +} + +@Composable +fun MenuHeading(@StringRes text: Int) = MenuHeading(stringResource(text)) + +@Composable +@Preview +fun MenuHeading_Preview() { + MenuHeading("Tools") +} + +@Composable +fun MenuEntry( + icon: Painter, + title: String, + onClick: () -> Unit +) { + val closeHandler = LocalCloseDrawerHandler.current + NavigationDrawerItem( + icon = { Icon(icon, contentDescription = title) }, + label = { Text(title, style = MaterialTheme.typography.labelLarge) }, + selected = false, + shape = RectangleShape, + onClick = { + onClick() + closeHandler.closeDrawer() + } + ) +} + +@Composable +fun MenuEntry( + icon: ImageVector, + title: String, + onClick: () -> Unit +) { + MenuEntry( + icon = rememberVectorPainter(icon), + title = title, + onClick = onClick + ) +} + +@Composable +@Preview +fun MenuEntry_Preview() { + MenuEntry( + icon = Icons.Default.Info, + title = "About", + onClick = {} + ) +} + + +// specific blocks + +@Composable +fun BrandingHeader() { + Column( + Modifier + .statusBarsPadding() + .background(Color.DarkGray) + .fillMaxWidth() + .padding(16.dp) + ) { + Spacer(Modifier.height(16.dp)) + Box( + Modifier.background( + color = M3ColorScheme.primaryLight, + shape = RoundedCornerShape(16.dp) + ) + ) { + Icon( + painterResource(R.drawable.ic_launcher_foreground), + stringResource(R.string.app_name), + tint = Color.White, + modifier = Modifier + .scale(1.2f) + .size(64.dp) + ) + } + Spacer(Modifier.height(8.dp)) + + Text( + stringResource(R.string.app_name), + color = Color.White, + style = MaterialTheme.typography.bodyLarge + ) + Text( + stringResource(R.string.navigation_drawer_subtitle), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(Modifier.height(8.dp)) +} + +@Composable +@Preview +fun BrandingHeader_Preview_Light() { + AppTheme(darkTheme = false) { + BrandingHeader() + } +} + +@Composable +@Preview +fun BrandingHeader_Preview_Dark() { + AppTheme(darkTheme = true) { + BrandingHeader() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt new file mode 100644 index 0000000..8292b7e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt @@ -0,0 +1,299 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.accounts.Account +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager.NameNotFoundException +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.PowerManager +import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.core.content.getSystemService +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.lifecycle.ViewModel +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration +import at.bitfire.davdroid.sync.worker.BaseSyncWorker +import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import at.bitfire.davdroid.ui.account.AccountProgress +import at.bitfire.davdroid.ui.intro.IntroPage +import at.bitfire.davdroid.ui.intro.IntroPageFactory +import at.bitfire.davdroid.util.broadcastReceiverFlow +import at.bitfire.davdroid.util.packageChangedFlow +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import java.text.Collator +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = AccountsModel.Factory::class) +class AccountsModel @AssistedInject constructor( + @Assisted private val syncAccountsOnInit: Boolean, + private val accountRepository: AccountRepository, + @ApplicationContext private val context: Context, + private val db: AppDatabase, + introPageFactory: IntroPageFactory, + private val logger: Logger, + private val settings: SettingsManager, + private val syncWorkerManager: SyncWorkerManager, + private val syncFrameWork: SyncFrameworkIntegration +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(syncAccountsOnInit: Boolean): AccountsModel + } + + // Accounts UI state + + enum class FABStyle { + WithText, + Standard, + None + } + + data class AccountInfo( + val name: Account, + val progress: AccountProgress + ) + + private val accounts = accountRepository.getAllFlow() + + private val maxAccounts = settings.getIntFlow(Settings.MAX_ACCOUNTS) + val showAddAccount: Flow = combine(accounts, maxAccounts) { accounts, maxAccounts -> + if (maxAccounts != null && accounts.size >= maxAccounts) + FABStyle.None + else if (accounts.isEmpty()) + FABStyle.WithText + else + FABStyle.Standard + } + val showSyncAll: Flow = accounts.map { it.isNotEmpty() } + + private val workManager = WorkManager.getInstance(context) + private val runningWorkers = workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING)) + + @OptIn(ExperimentalCoroutinesApi::class) + private val accountsSyncPending: Flow> = + accounts.flatMapLatest { accounts -> + if (accounts.isEmpty()) + flowOf(emptyList()) + else { + // To create the Flow> that emits the accounts with pending sync, + val pendingSyncAccountsFlows: List> = + // for each existing account with unknown sync pending state ... + accounts.map { account -> + // ... create a Flow which emits the sync pending state + syncFrameWork.isSyncPending(account, SyncDataType.entries) + .map { hasPendingSync -> + // ... and map this boolean answer back to its Account if it is pending, or null if not. + if (hasPendingSync) account else null + } + } + // Combine all account flows Flow in the list into a single flow, emitting a list of + // accounts with pending sync. The null values which we filter out are the non-pending accounts. + // Now, whenever any account's pending state changes, the combined flow emits the updated list. + combine(pendingSyncAccountsFlows) { combinedAccounts -> + // combinedAccounts is an Array of the most recently emitted values of the + // pendingSyncCheckFlows, with one entry for every pendingSyncCheckFlow that is either + // the account name (sync pending) or null (no sync pending). + combinedAccounts.filterNotNull() + } + } + } + + + val accountInfos: Flow> = combine( + accounts, + runningWorkers, + accountsSyncPending + ) { accounts, workInfos, accountsSyncPending -> + val collator = Collator.getInstance() + + accounts + .sortedWith { a, b -> collator.compare(a.name, b.name) } + .map { account -> + val services = db.serviceDao().getIdsByAccountAsync(account.name) + val progress = when { + workInfos.any { info -> + info.state == WorkInfo.State.RUNNING && ( + services.any { serviceId -> + info.tags.contains(RefreshCollectionsWorker.workerName(serviceId)) + } || SyncDataType.entries.any { dataType -> + info.tags.contains(BaseSyncWorker.commonTag(account, dataType)) + } + ) + } -> AccountProgress.Active + + workInfos.any { info -> + info.state == WorkInfo.State.ENQUEUED && SyncDataType.entries.any { dataType -> + info.tags.contains(OneTimeSyncWorker.workerName(account, dataType)) + } + } -> AccountProgress.Pending + + account in accountsSyncPending + -> AccountProgress.Pending + + else -> AccountProgress.Idle + } + + AccountInfo(account, progress) + } + } + + + // other UI state + + val showAppIntro: Flow = flow { + val anyShowAlwaysPage = introPageFactory.introPages.any { introPage -> + val policy = introPage.getShowPolicy() + logger.fine("Intro page ${introPage::class.java.name} policy = $policy") + + policy == IntroPage.ShowPolicy.SHOW_ALWAYS + } + + emit(anyShowAlwaysPage) + }.flowOn(Dispatchers.Default) + + + // warnings + + private val connectivityManager = context.getSystemService()!! + private val powerManager: PowerManager = context.getSystemService()!! + + /** whether a usable network connection is available (sync framework won't run synchronization otherwise) */ + val networkAvailable = callbackFlow { + val networkCallback = object: ConnectivityManager.NetworkCallback() { + val availableNetworks = hashSetOf() + + override fun onAvailable(network: Network) { + availableNetworks += network + update() + } + + override fun onLost(network: Network) { + availableNetworks -= network + update() + } + + private fun update() { + trySend(availableNetworks.isNotEmpty()) + } + } + + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + /** whether battery saver is active */ + val batterySaverActive = + broadcastReceiverFlow( + context = context, + filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED), + immediate = true + ).map { powerManager.isPowerSaveMode } + + /** whether data saver is restricting background synchronization ([ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED]) */ + val dataSaverEnabled = + broadcastReceiverFlow( + context = context, + filter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED), + immediate = true + ).map { connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED } + + /** whether storage is low (prevents sync framework from running synchronization) */ + @Suppress("DEPRECATION") + val storageLow = + broadcastReceiverFlow( + context = context, + filter = IntentFilter().apply { + addAction(Intent.ACTION_DEVICE_STORAGE_LOW) + addAction(Intent.ACTION_DEVICE_STORAGE_OK) + }, + immediate = false // "storage low" intent is sticky + ).map { intent -> + when (intent.action) { + Intent.ACTION_DEVICE_STORAGE_LOW -> true + else -> false + } + } + + /** whether the calendar storage is missing or disabled */ + val calendarStorageDisabled = packageChangedFlow(context).map { + !contentProviderAvailable(CalendarContract.AUTHORITY) + } + + /** whether the calendar storage is missing or disabled */ + val contactsStorageDisabled = packageChangedFlow(context).map { + !contentProviderAvailable(ContactsContract.AUTHORITY) + } + + + init { + if (syncAccountsOnInit) + syncAllAccounts() + } + + + // actions + + fun syncAllAccounts() { + // report shortcut action to system + ShortcutManagerCompat.reportShortcutUsed(context, UiUtils.SHORTCUT_SYNC_ALL) + + // Enqueue sync worker for all accounts and authorities. Will sync once internet is available + for (account in accountRepository.getAll()) + syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true) + } + + + // helpers + + fun contentProviderAvailable(authority: String): Boolean = + try { + // resolveContentProvider returns null if the provider app is disabled or missing; + // so we can't distinguish between "disabled" and "not found" + context.packageManager.resolveContentProvider(authority, 0) != null + } catch (_: NameNotFoundException) { + logger.fine("$authority provider app not found") + false + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt new file mode 100644 index 0000000..6e58ab9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt @@ -0,0 +1,595 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.Manifest +import android.accounts.Account +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BatterySaver +import androidx.compose.material.icons.filled.DataSaverOn +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.SignalCellularOff +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.account.AccountProgress +import at.bitfire.davdroid.ui.composable.ActionCard +import at.bitfire.davdroid.ui.composable.ProgressBar +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun AccountsScreen( + initialSyncAccounts: Boolean, + onShowAppIntro: () -> Unit, + accountsDrawerHandler: AccountsDrawerHandler, + onAddAccount: () -> Unit, + onShowAccount: (Account) -> Unit, + onManagePermissions: () -> Unit, + model: AccountsModel = hiltViewModel( + creationCallback = { factory: AccountsModel.Factory -> + factory.create(initialSyncAccounts) + } + ) +) { + val accounts by model.accountInfos.collectAsStateWithLifecycle(emptyList()) + val showSyncAll by model.showSyncAll.collectAsStateWithLifecycle(true) + val showAddAccount by model.showAddAccount.collectAsStateWithLifecycle(AccountsModel.FABStyle.Standard) + + // Remember shown state, so the intro does not restart on rotation or theme-change + var shown by rememberSaveable { mutableStateOf(false) } + val showAppIntro by model.showAppIntro.collectAsState(false) + LaunchedEffect(showAppIntro) { + if (showAppIntro && !shown) { + shown = true + onShowAppIntro() + } + } + + AccountsScreen( + accountsDrawerHandler = accountsDrawerHandler, + accounts = accounts, + showSyncAll = showSyncAll, + onSyncAll = { model.syncAllAccounts() }, + showAddAccount = showAddAccount, + onAddAccount = onAddAccount, + onShowAccount = onShowAccount, + onManagePermissions = onManagePermissions, + internetUnavailable = !model.networkAvailable.collectAsStateWithLifecycle(false).value, + batterySaverActive = model.batterySaverActive.collectAsStateWithLifecycle(false).value, + dataSaverActive = model.dataSaverEnabled.collectAsStateWithLifecycle(false).value, + storageLow = model.storageLow.collectAsStateWithLifecycle(false).value, + calendarStorageDisabled = model.calendarStorageDisabled.collectAsStateWithLifecycle(false).value, + contactsStorageDisabled = model.contactsStorageDisabled.collectAsStateWithLifecycle(false).value + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun AccountsScreen( + accountsDrawerHandler: AccountsDrawerHandler, + accounts: List, + showSyncAll: Boolean = true, + onSyncAll: () -> Unit = {}, + showAddAccount: AccountsModel.FABStyle = AccountsModel.FABStyle.Standard, + onAddAccount: () -> Unit = {}, + onShowAccount: (Account) -> Unit = {}, + onManagePermissions: () -> Unit = {}, + internetUnavailable: Boolean = false, + batterySaverActive: Boolean = false, + dataSaverActive: Boolean = false, + storageLow: Boolean = false, + calendarStorageDisabled: Boolean = false, + contactsStorageDisabled: Boolean = false +) { + val scope = rememberCoroutineScope() + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + + var isRefreshing by remember { mutableStateOf(false) } + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(300) + isRefreshing = false + } + } + + val snackbarHostState = remember { SnackbarHostState() } + AppTheme { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet(drawerState) { + accountsDrawerHandler.AccountsDrawer( + snackbarHostState = snackbarHostState, + onCloseDrawer = { + scope.launch { + drawerState.close() + } + } + ) + } + } + ) { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconToggleButton(false, onCheckedChange = { openDrawer -> + scope.launch { + if (openDrawer) + drawerState.open() + else + drawerState.close() + } + }) { + Icon( + Icons.Filled.Menu, + stringResource(androidx.compose.ui.R.string.navigation_menu) + ) + } + }, + title = { + Text(stringResource(R.string.app_name)) + } + ) + }, + floatingActionButton = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (showAddAccount == AccountsModel.FABStyle.WithText) + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.login_add_account)) }, + icon = { Icon(Icons.Filled.Add, stringResource(R.string.login_add_account)) }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onAddAccount + ) + else if (showAddAccount == AccountsModel.FABStyle.Standard) + FloatingActionButton( + onClick = onAddAccount, + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary + ) { + Icon(Icons.Filled.Add, stringResource(R.string.login_add_account)) + } + + if (showSyncAll) + FloatingActionButton( + onClick = onSyncAll, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(top = 24.dp) + ) { + Icon( + Icons.Default.Sync, + contentDescription = stringResource(R.string.accounts_sync_all) + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { isRefreshing = true; onSyncAll() }, + modifier = Modifier.padding(padding) + ) { + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // background image + Image( + painterResource(R.drawable.accounts_background), + contentDescription = null, + modifier = Modifier + .matchParentSize() + .align(Alignment.Center) + ) + + Column { + val notificationsPermissionState = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !LocalInspectionMode.current) + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + else + null + + // Warnings show as action cards + val context = LocalContext.current + SyncWarnings( + notificationsWarning = notificationsPermissionState?.status?.isGranted == false, + onManagePermissions = onManagePermissions, + internetWarning = internetUnavailable, + onManageConnections = { + val intent = Intent(Settings.ACTION_WIRELESS_SETTINGS) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + }, + batterySaverActive = batterySaverActive, + onManageBatterySaver = { + val intent = Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + }, + dataSaverActive = dataSaverActive, + onManageDataSaver = { + val intent = Intent( + /* action = */ Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, + /* uri = */ "package:${BuildConfig.APPLICATION_ID}".toUri() + ) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + }, + lowStorageWarning = storageLow, + onManageStorage = { + val intent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + }, + calendarStorageDisabled = calendarStorageDisabled, + contactsStorageDisabled = contactsStorageDisabled, + onManageApps = { + val intent = Intent(Settings.ACTION_APPLICATION_SETTINGS) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + }, + ) + + // account list + AccountList( + accounts = accounts, + onClickAccount = { account -> + onShowAccount(account) + }, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) + } + } + } + } + } + } +} + +@Composable +@Preview +fun AccountsScreen_Preview_Empty() { + AccountsScreen( + accountsDrawerHandler = object: AccountsDrawerHandler() { + @Composable + override fun MenuEntries(snackbarHostState: SnackbarHostState) { + Text("Menu entries") + } + }, + accounts = emptyList(), + showAddAccount = AccountsModel.FABStyle.WithText, + showSyncAll = false + ) +} + +@Composable +@Preview +fun AccountsScreen_Preview_OneAccount() { + AccountsScreen( + accountsDrawerHandler = object: AccountsDrawerHandler() { + @Composable + override fun MenuEntries(snackbarHostState: SnackbarHostState) { + Text("Menu entries") + } + }, + accounts = listOf( + AccountsModel.AccountInfo( + Account("Account Name", "test"), + AccountProgress.Idle + ) + ) + ) +} + +@Composable +fun AccountList( + accounts: List, + modifier: Modifier = Modifier, + onClickAccount: (Account) -> Unit = {} +) { + Column(modifier) { + if (accounts.isEmpty()) + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + Text( + text = stringResource(R.string.account_list_welcome), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 32.dp) + ) + Text( + text = stringResource(R.string.account_list_empty), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + else + for ((account, progress) in accounts) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + elevation = CardDefaults.cardElevation(1.dp), + modifier = Modifier + .clickable { onClickAccount(account) } + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + Column { + val progressAlpha = progress.rememberAlpha() + when (progress) { + AccountProgress.Active -> + ProgressBar( + modifier = Modifier + .alpha(progressAlpha) + .fillMaxWidth() + ) + AccountProgress.Pending, + AccountProgress.Idle -> + ProgressBar( + progress = { 1f }, + modifier = Modifier + .alpha(progressAlpha) + .fillMaxWidth() + ) + } + + Column(Modifier.padding(vertical = 12.dp)) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(48.dp) + ) + + Text( + text = account.name, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } + } + } + } +} + +@Composable +@Preview +fun AccountList_Preview_Idle() { + AppTheme { + AccountList( + listOf( + AccountsModel.AccountInfo( + Account("Account Name", "test"), + AccountProgress.Idle + ) + ) + ) + } +} + +@Composable +@Preview +fun AccountList_Preview_SyncPending() { + AppTheme { + AccountList(listOf( + AccountsModel.AccountInfo( + Account("Account Name", "test"), + AccountProgress.Pending + ) + )) + } +} + +@Composable +@Preview +fun AccountList_Preview_Syncing() { + AppTheme { + AccountList(listOf( + AccountsModel.AccountInfo( + Account("Account Name", "test"), + AccountProgress.Active + ) + )) + } +} + + +@Composable +fun SyncWarnings( + notificationsWarning: Boolean = true, + onManagePermissions: () -> Unit = {}, + internetWarning: Boolean = true, + onManageConnections: () -> Unit = {}, + batterySaverActive: Boolean = true, + onManageBatterySaver: () -> Unit = {}, + dataSaverActive: Boolean = true, + onManageDataSaver: () -> Unit = {}, + lowStorageWarning: Boolean = true, + onManageStorage: () -> Unit = {}, + calendarStorageDisabled: Boolean = false, + contactsStorageDisabled: Boolean = false, + onManageApps: () -> Unit = {} +) { + Column(Modifier.padding(horizontal = 8.dp)) { + if (notificationsWarning) + ActionCard( + icon = Icons.Default.NotificationsOff, + actionText = stringResource(R.string.account_manage_permissions), + onAction = onManagePermissions, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.sync_warning_no_notification_permission)) + } + + if (internetWarning) + ActionCard( + icon = Icons.Default.SignalCellularOff, + actionText = stringResource(R.string.sync_warning_manage_connections), + onAction = onManageConnections, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.sync_warning_no_internet)) + } + + if (batterySaverActive) + ActionCard( + icon = Icons.Default.BatterySaver, + actionText = stringResource(R.string.sync_warning_manage_battery_saver), + onAction = onManageBatterySaver, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.sync_warning_battery_saver_enabled)) + } + + if (dataSaverActive) + ActionCard( + icon = Icons.Default.DataSaverOn, + actionText = stringResource(R.string.sync_warning_manage_datasaver), + onAction = onManageDataSaver, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.sync_warning_datasaver_enabled)) + } + + if (lowStorageWarning) + ActionCard( + icon = Icons.Default.Storage, + actionText = stringResource(R.string.sync_warning_manage_storage), + onAction = onManageStorage, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Text(stringResource(R.string.sync_warning_low_storage)) + } + + if (calendarStorageDisabled) + ActionCard( + icon = ImageVector.vectorResource(R.drawable.ic_database_off), + actionText = stringResource(R.string.sync_warning_manage_apps), + onAction = onManageApps, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Column { + Text( + text = stringResource(R.string.sync_warning_calendar_storage_disabled_title), + modifier = Modifier.padding(vertical = 4.dp) + ) + Text(stringResource(R.string.sync_warning_calendar_storage_disabled_description)) + } + } + + if (contactsStorageDisabled) + ActionCard( + icon = ImageVector.vectorResource(R.drawable.ic_database_off), + actionText = stringResource(R.string.sync_warning_manage_apps), + onAction = onManageApps, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Column { + Text( + text = stringResource(R.string.sync_warning_contacts_storage_disabled_title), + modifier = Modifier.padding(vertical = 4.dp) + ) + Text(stringResource(R.string.sync_warning_contacts_storage_disabled_description)) + } + } + } +} + +@Composable +@Preview +fun SyncWarnings_Preview() { + AppTheme { + SyncWarnings( + notificationsWarning = true, + internetWarning = true, + batterySaverActive = true, + dataSaverActive = true, + lowStorageWarning = true, + calendarStorageDisabled = true, + contactsStorageDisabled = true + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt new file mode 100644 index 0000000..2a07fc1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt @@ -0,0 +1,60 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import at.bitfire.davdroid.BuildConfig +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AppSettingsActivity: AppCompatActivity() { + + @SuppressLint("BatteryLife") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppSettingsScreen( + onNavDebugInfo = { + startActivity(Intent(this, DebugInfoActivity::class.java)) + }, + onExemptFromBatterySaving = { + startActivity( + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + ("package:" + BuildConfig.APPLICATION_ID).toUri() + ) + ) + }, + onBatterySavingSettings = { + startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + }, + onNavTasksScreen = { + startActivity(Intent(this, TasksActivity::class.java)) + }, + onShowNotificationSettings = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) + } + ) + }, + onNavPermissionsScreen = { + startActivity(Intent(this, PermissionsActivity::class.java)) + }, + onNavUp = ::onSupportNavigateUp + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt new file mode 100644 index 0000000..579379b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsModel.kt @@ -0,0 +1,185 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Context +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.PowerManager +import androidx.core.content.getSystemService +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.cert4android.CustomCertStore +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.push.PushRegistrationManager +import at.bitfire.davdroid.repository.PreferenceRepository +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel +import at.bitfire.davdroid.ui.intro.OpenSourcePage +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.davdroid.util.broadcastReceiverFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AppSettingsModel @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val preferences: PreferenceRepository, + private val pushRegistrationManager: PushRegistrationManager, + private val settings: SettingsManager, + tasksAppManager: TasksAppManager +) : ViewModel() { + + + // debugging + + private val powerManager = context.getSystemService()!! + val batterySavingExempted = + broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true) + .map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + fun verboseLogging() = preferences.logToFileFlow() + fun updateVerboseLogging(verbose: Boolean) { + preferences.logToFile(verbose) + } + + + // connection + + fun proxyType() = settings.getIntFlow(Settings.PROXY_TYPE) + fun updateProxyType(type: Int) { + settings.putInt(Settings.PROXY_TYPE, type) + } + + fun proxyHostName() = settings.getStringFlow(Settings.PROXY_HOST) + fun updateProxyHostName(host: String) { + settings.putString(Settings.PROXY_HOST, host) + } + + fun proxyPort() = settings.getIntFlow(Settings.PROXY_PORT) + fun updateProxyPort(port: Int) { + settings.putInt(Settings.PROXY_PORT, port) + } + + + // security + + fun distrustSystemCertificates() = settings.getBooleanFlow(Settings.DISTRUST_SYSTEM_CERTIFICATES) + fun updateDistrustSystemCertificates(distrust: Boolean) { + settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, distrust) + } + + fun resetCertificates() { + CustomCertStore.getInstance(context).clearUserDecisions() + } + + + // user interface + + fun theme() = settings.getIntFlow(Settings.PREFERRED_THEME) + fun updateTheme(theme: Int) { + settings.putInt(Settings.PREFERRED_THEME, theme) + UiUtils.updateTheme(context) + } + + fun resetHints() { + settings.remove(BatteryOptimizationsPageModel.HINT_BATTERY_OPTIMIZATIONS) + settings.remove(BatteryOptimizationsPageModel.HINT_AUTOSTART_PERMISSION) + settings.remove(TasksModel.HINT_OPENTASKS_NOT_INSTALLED) + settings.remove(OpenSourcePage.Model.SETTING_NEXT_DONATION_POPUP) + } + + + // tasks + + private val pm: PackageManager = context.packageManager + private val appInfoFlow = tasksAppManager.currentProviderFlow().map { tasksProvider -> + tasksProvider?.packageName?.let { pkgName -> + pm.getApplicationInfo(pkgName, 0) + } + } + val tasksAppName = appInfoFlow.map { it?.loadLabel(pm)?.toString() } + val tasksAppIcon = appInfoFlow.map { it?.loadIcon(pm) } + + + // push + + private val _pushDistributor = MutableStateFlow(null) + val pushDistributor = _pushDistributor.asStateFlow() + + private val _pushDistributors = MutableStateFlow?>(null) + val pushDistributors = _pushDistributors.asStateFlow() + + /** + * Loads the push distributors configuration: + * + * - Loads the currently selected distributor into [pushDistributor]. + * - Loads all the available distributors into [pushDistributors]. + * - If there's only one push distributor available, and none is selected, it's selected automatically. + * - Makes sure the app is registered with UnifiedPush if there's already a distributor selected. + */ + private fun loadPushDistributors() { + val currentPushDistributor = pushRegistrationManager.getCurrentDistributor() + _pushDistributor.value = currentPushDistributor + + val pushDistributors = pushRegistrationManager.getDistributors() + .map { pushDistributor -> + try { + val applicationInfo = pm.getApplicationInfo(pushDistributor, 0) + val label = pm.getApplicationLabel(applicationInfo).toString() + val icon = pm.getApplicationIcon(applicationInfo) + PushDistributorInfo(pushDistributor, label, icon) + } catch (_: PackageManager.NameNotFoundException) { + // The app is not available for some reason, do not include the app data. + PushDistributorInfo(pushDistributor) + } + } + _pushDistributors.value = pushDistributors + } + + /** + * Updates the current push distributor selection. + * + * Saves the preference in UnifiedPush, (un)registers the app, and writes the selection to [pushDistributor]. + * + * @param pushDistributor The package name of the push distributor, _null_ to disable push. + */ + fun updatePushDistributor(pushDistributor: String?) { + viewModelScope.launch(ioDispatcher) { + pushRegistrationManager.setPushDistributor(pushDistributor) + + _pushDistributor.value = pushDistributor + } + } + + + init { + viewModelScope.launch(ioDispatcher) { + loadPushDistributors() + } + } + + + data class PushDistributorInfo( + val packageName: String, + val appName: String? = null, + val appIcon: Drawable? = null + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt new file mode 100644 index 0000000..373eb16 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt @@ -0,0 +1,759 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.annotation.SuppressLint +import android.graphics.drawable.Drawable +import android.os.Build +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Adb +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.InvertColors +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.ui.AppSettingsModel.PushDistributorInfo +import at.bitfire.davdroid.ui.composable.EditTextInputDialog +import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog +import at.bitfire.davdroid.ui.composable.Setting +import at.bitfire.davdroid.ui.composable.SettingsHeader +import at.bitfire.davdroid.ui.composable.SwitchSetting +import kotlinx.coroutines.launch + +@Composable +fun AppSettingsScreen( + onNavDebugInfo: () -> Unit, + onExemptFromBatterySaving: () -> Unit, + onBatterySavingSettings: () -> Unit, + onNavPermissionsScreen: () -> Unit, + onShowNotificationSettings: () -> Unit, + onNavTasksScreen: () -> Unit, + onNavUp: () -> Unit, + model: AppSettingsModel = viewModel() +) { + AppTheme { + AppSettingsScreen( + onNavDebugInfo = onNavDebugInfo, + verboseLogging = model.verboseLogging().collectAsStateWithLifecycle(false).value, + onUpdateVerboseLogging = model::updateVerboseLogging, + batterySavingExempted = model.batterySavingExempted.collectAsStateWithLifecycle().value, + onExemptFromBatterySaving = onExemptFromBatterySaving, + onBatterySavingSettings = onBatterySavingSettings, + onNavUp = onNavUp, + + // Connection + proxyType = model.proxyType().collectAsStateWithLifecycle(null).value ?: Settings.PROXY_TYPE_NONE, + onProxyTypeUpdated = model::updateProxyType, + proxyHostName = model.proxyHostName().collectAsStateWithLifecycle(null).value, + onProxyHostNameUpdated = model::updateProxyHostName, + proxyPort = model.proxyPort().collectAsStateWithLifecycle(null).value, + onProxyPortUpdated = model::updateProxyPort, + + // Security + distrustSystemCerts = model.distrustSystemCertificates().collectAsStateWithLifecycle(null).value ?: false, + onDistrustSystemCertsUpdated = model::updateDistrustSystemCertificates, + onResetCertificates = model::resetCertificates, + onNavPermissionsScreen = onNavPermissionsScreen, + + // User interface + onShowNotificationSettings = onShowNotificationSettings, + theme = model.theme().collectAsStateWithLifecycle(null).value ?: Settings.PREFERRED_THEME_DEFAULT, + onThemeSelected = model::updateTheme, + onResetHints = model::resetHints, + + // Integration (Tasks and Push) + tasksAppName = model.tasksAppName.collectAsStateWithLifecycle(null).value ?: stringResource(R.string.app_settings_tasks_provider_none), + tasksAppIcon = model.tasksAppIcon.collectAsStateWithLifecycle(null).value, + pushDistributors = model.pushDistributors.collectAsState().value, + pushDistributor = model.pushDistributor.collectAsState().value, + onPushDistributorChange = model::updatePushDistributor, + onNavTasksScreen = onNavTasksScreen + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("BatteryLife") +@Composable +fun AppSettingsScreen( + onNavDebugInfo: () -> Unit, + verboseLogging: Boolean, + onUpdateVerboseLogging: (Boolean) -> Unit, + batterySavingExempted: Boolean, + onExemptFromBatterySaving: () -> Unit, + onBatterySavingSettings: () -> Unit, + + // AppSettings connection + proxyType: Int, + onProxyTypeUpdated: (Int) -> Unit, + proxyHostName: String?, + onProxyHostNameUpdated: (String) -> Unit, + proxyPort: Int?, + onProxyPortUpdated: (Int) -> Unit, + + // AppSettings security + distrustSystemCerts: Boolean, + onDistrustSystemCertsUpdated: (Boolean) -> Unit, + onResetCertificates: () -> Unit, + onNavPermissionsScreen: () -> Unit, + + // AppSettings UserInterface + theme: Int, + onThemeSelected: (Int) -> Unit, + onResetHints: () -> Unit, + + // AppSettings Integration + tasksAppName: String, + tasksAppIcon: Drawable?, + pushDistributors: List?, + pushDistributor: String?, + onPushDistributorChange: (String?) -> Unit, + onNavTasksScreen: () -> Unit, + + onShowNotificationSettings: () -> Unit, + onNavUp: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + }, + title = { Text(stringResource(R.string.app_settings)) }, + actions = { + IconButton(onClick = { + val settingsUri = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_SETTINGS) + .fragment(ExternalUris.Manual.FRAGMENT_APP_SETTINGS) + .build() + uriHandler.openUri(settingsUri.toString()) + }) { + Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help)) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + Column(Modifier.padding(8.dp)) { + AppSettings_Debugging( + onNavDebugInfo = onNavDebugInfo, + verboseLogging = verboseLogging, + onUpdateVerboseLogging = onUpdateVerboseLogging, + batterySavingExempted = batterySavingExempted, + onExemptFromBatterySaving = onExemptFromBatterySaving, + onBatterySavingSettings = onBatterySavingSettings + ) + + AppSettings_Connection( + proxyType = proxyType, + onProxyTypeUpdated = onProxyTypeUpdated, + proxyHostName = proxyHostName, + onProxyHostNameUpdated = onProxyHostNameUpdated, + proxyPort = proxyPort, + onProxyPortUpdated = onProxyPortUpdated, + ) + + val resetCertificatesSuccessMessage = stringResource(R.string.app_settings_reset_certificates_success) + AppSettings_Security( + distrustSystemCerts = distrustSystemCerts, + onDistrustSystemCertsUpdated = onDistrustSystemCertsUpdated, + onResetCertificates = { + onResetCertificates() + coroutineScope.launch { + snackbarHostState.showSnackbar(resetCertificatesSuccessMessage) + } + }, + onNavPermissionsScreen = onNavPermissionsScreen + ) + + val resetHintsSuccessMessage = stringResource(R.string.app_settings_reset_hints_success) + AppSettings_UserInterface( + theme = theme, + onThemeSelected = onThemeSelected, + onResetHints = { + onResetHints() + coroutineScope.launch { + snackbarHostState.showSnackbar(resetHintsSuccessMessage) + } + }, + onShowNotificationSettings = onShowNotificationSettings + ) + + AppSettings_Integration( + tasksAppName = tasksAppName, + tasksAppIcon = tasksAppIcon, + pushDistributors = pushDistributors, + pushDistributor = pushDistributor, + onPushDistributorChange = onPushDistributorChange, + onNavTasksScreen = onNavTasksScreen + ) + } + } + } +} + +@Composable +@Preview +fun AppSettingsScreen_Preview() { + AppTheme { + AppSettingsScreen( + onNavDebugInfo = {}, + verboseLogging = true, + batterySavingExempted = true, + proxyType = 0, + proxyHostName = "true", + proxyPort = 0, + distrustSystemCerts = true, + theme = 0, + onUpdateVerboseLogging = {}, + onProxyHostNameUpdated = {}, + onExemptFromBatterySaving = {}, + onBatterySavingSettings = {}, + onShowNotificationSettings = {}, + onNavUp = {}, + onProxyTypeUpdated = {}, + onProxyPortUpdated = {}, + onDistrustSystemCertsUpdated = {}, + onResetCertificates = {}, + onNavPermissionsScreen = {}, + onThemeSelected = {}, + onResetHints = {}, + tasksAppName = "No tasks app", + tasksAppIcon = null, + pushDistributors = null, + pushDistributor = null, + onPushDistributorChange = {}, + onNavTasksScreen = {} + ) + } +} + +@Composable +fun AppSettings_Debugging( + onNavDebugInfo: () -> Unit, + verboseLogging: Boolean, + onUpdateVerboseLogging: (Boolean) -> Unit, + batterySavingExempted: Boolean, + onExemptFromBatterySaving: () -> Unit, + onBatterySavingSettings: () -> Unit +) { + SettingsHeader { + Text(stringResource(R.string.app_settings_debug)) + } + + Setting( + icon = Icons.Default.BugReport, + name = stringResource(R.string.app_settings_show_debug_info), + summary = stringResource(R.string.app_settings_show_debug_info_details) + ) { + onNavDebugInfo() + } + + SwitchSetting( + icon = Icons.Default.Adb, + checked = verboseLogging, + name = stringResource(R.string.app_settings_logging), + summaryOn = stringResource(R.string.app_settings_logging_on), + summaryOff = stringResource(R.string.app_settings_logging_off) + ) { + onUpdateVerboseLogging(it) + } + + SwitchSetting( + checked = batterySavingExempted, + icon = Icons.Default.SyncProblem.takeUnless { batterySavingExempted }, + name = stringResource(R.string.app_settings_battery_optimization), + summaryOn = stringResource(R.string.app_settings_battery_optimization_exempted), + summaryOff = stringResource(R.string.app_settings_battery_optimization_optimized) + ) { + if (batterySavingExempted) + onBatterySavingSettings() + else + onExemptFromBatterySaving() + } +} + +@Composable +fun AppSettings_Connection( + proxyType: Int, + onProxyTypeUpdated: (Int) -> Unit = {}, + proxyHostName: String? = null, + onProxyHostNameUpdated: (String) -> Unit = {}, + proxyPort: Int? = null, + onProxyPortUpdated: (Int) -> Unit = {} +) { + SettingsHeader(divider = true) { + Text(stringResource(R.string.app_settings_connection)) + } + + val proxyTypeNames = stringArrayResource(R.array.app_settings_proxy_types) + val proxyTypeValues = stringArrayResource(R.array.app_settings_proxy_type_values).map { it.toInt() } + var showProxyTypeInputDialog by remember { mutableStateOf(false) } + Setting( + name = stringResource(R.string.app_settings_proxy), + summary = proxyTypeNames[proxyTypeValues.indexOf(proxyType)] + ) { + showProxyTypeInputDialog = true + } + if (showProxyTypeInputDialog) + MultipleChoiceInputDialog( + title = stringResource(R.string.app_settings_proxy), + namesAndValues = proxyTypeNames.zip(proxyTypeValues.map { it.toString() }), + initialValue = proxyType.toString(), + onValueSelected = { newValue -> + onProxyTypeUpdated(newValue.toInt()) + }, + onDismiss = { showProxyTypeInputDialog = false } + ) + + if (proxyType !in listOf(Settings.PROXY_TYPE_SYSTEM, Settings.PROXY_TYPE_NONE)) { + var showProxyHostNameInputDialog by remember { mutableStateOf(false) } + Setting( + name = stringResource(R.string.app_settings_proxy_host), + summary = proxyHostName + ) { + showProxyHostNameInputDialog = true + } + if (showProxyHostNameInputDialog) + EditTextInputDialog( + title = stringResource(R.string.app_settings_proxy_host), + initialValue = proxyHostName, + keyboardType = KeyboardType.Uri, + onValueEntered = onProxyHostNameUpdated, + onDismiss = { showProxyHostNameInputDialog = false } + ) + + var showProxyPortInputDialog by remember { mutableStateOf(false) } + Setting( + name = stringResource(R.string.app_settings_proxy_port), + summary = proxyPort?.toString() + ) { + showProxyPortInputDialog = true + } + if (showProxyPortInputDialog) + EditTextInputDialog( + title = stringResource(R.string.app_settings_proxy_port), + initialValue = proxyPort?.toString(), + keyboardType = KeyboardType.Number, + onValueEntered = { + try { + val newPort = it.toInt() + if (newPort in 1..65535) + onProxyPortUpdated(newPort) + } catch (_: NumberFormatException) { + // user entered invalid port number + } + }, + onDismiss = { showProxyPortInputDialog = false } + ) + } +} + +@Composable +fun AppSettings_Security( + distrustSystemCerts: Boolean, + onDistrustSystemCertsUpdated: (Boolean) -> Unit, + onResetCertificates: () -> Unit, + onNavPermissionsScreen: () -> Unit +) { + SettingsHeader(divider = true) { + Text(stringResource(R.string.app_settings_security)) + } + + var showingDistrustWarning by remember { mutableStateOf(false) } + if (showingDistrustWarning) { + DistrustSystemCertificatesAlertDialog( + onDistrustSystemCertsRequested = { onDistrustSystemCertsUpdated(true) }, + onDismissRequested = { showingDistrustWarning = false } + ) + } + + SwitchSetting( + checked = distrustSystemCerts, + name = stringResource(R.string.app_settings_distrust_system_certs), + summaryOn = stringResource(R.string.app_settings_distrust_system_certs_on), + summaryOff = stringResource(R.string.app_settings_distrust_system_certs_off) + ) { checked -> + if (checked) { + // Show warning before enabling. + showingDistrustWarning = true + } else { + onDistrustSystemCertsUpdated(false) + } + } + + Setting( + name = stringResource(R.string.app_settings_reset_certificates), + summary = stringResource(R.string.app_settings_reset_certificates_summary), + onClick = onResetCertificates + ) + + Setting( + name = stringResource(R.string.app_settings_security_app_permissions), + summary = stringResource(R.string.app_settings_security_app_permissions_summary), + onClick = onNavPermissionsScreen + ) +} + +@Composable +fun DistrustSystemCertificatesAlertDialog( + onDistrustSystemCertsRequested: () -> Unit, + onDismissRequested: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequested, + icon = { Icon(Icons.Default.Warning, stringResource(R.string.app_settings_distrust_system_certs)) }, + title = { Text(stringResource(R.string.app_settings_distrust_system_certs)) }, + text = { Text(stringResource(R.string.app_settings_distrust_system_certs_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + onDistrustSystemCertsRequested() + onDismissRequested() + } + ) { Text(stringResource(R.string.dialog_enable)) } + }, + dismissButton = { + TextButton( + onClick = onDismissRequested + ) { Text(stringResource(R.string.dialog_deny)) } + }, + ) +} + +@Preview +@Composable +fun DistrustSystemCertificatesAlertDialog_Preview() { + AppTheme { + DistrustSystemCertificatesAlertDialog({}, {}) + } +} + +@Composable +fun AppSettings_UserInterface( + theme: Int, + onThemeSelected: (Int) -> Unit = {}, + onResetHints: () -> Unit = {}, + onShowNotificationSettings: () -> Unit = {} +) { + SettingsHeader(divider = true) { + Text(stringResource(R.string.app_settings_user_interface)) + } + + if (Build.VERSION.SDK_INT >= 26) + Setting( + icon = Icons.Default.Notifications, + name = stringResource(R.string.app_settings_notification_settings), + summary = stringResource(R.string.app_settings_notification_settings_summary), + onClick = onShowNotificationSettings + ) + + val themeNames = stringArrayResource(R.array.app_settings_theme_names) + val themeValues = stringArrayResource(R.array.app_settings_theme_values).map { it.toInt() } + var showThemeDialog by remember { mutableStateOf(false) } + val themeValueIdx = themeValues.indexOf(theme).takeIf { it != -1 } + Setting( + icon = Icons.Default.InvertColors, + name = stringResource(R.string.app_settings_theme_title), + summary = themeValueIdx?.let { themeNames[it] } + ) { + showThemeDialog = true + } + if (showThemeDialog) + MultipleChoiceInputDialog( + title = stringResource(R.string.app_settings_theme_title), + namesAndValues = themeNames.zip(themeValues.map { it.toString() }), + initialValue = theme.toString(), + onValueSelected = { + onThemeSelected(it.toInt()) + }, + onDismiss = { showThemeDialog = false } + ) + + Setting( + name = stringResource(R.string.app_settings_reset_hints), + summary = stringResource(R.string.app_settings_reset_hints_summary), + onClick = onResetHints + ) +} + +@Composable +private fun PushDistributorSelectionDialog( + pushDistributor: String?, + onPushDistributorChange: (String?) -> Unit, + pushDistributors: List?, + onDismissRequested: () -> Unit +) { + var selectedDistributor by remember { mutableStateOf(pushDistributor) } + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismissRequested, + confirmButton = { + TextButton( + onClick = { + onPushDistributorChange(selectedDistributor) + onDismissRequested() + } + ) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton( + onClick = onDismissRequested + ) { Text(stringResource(android.R.string.cancel)) } + }, + title = { + Text(stringResource(R.string.app_settings_unifiedpush_choose_distributor)) + }, + text = { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + if (pushDistributors.isNullOrEmpty()) item { + Text(stringResource(R.string.app_settings_unifiedpush_no_distributor)) + } else item { + ListItem( + leadingContent = { + Icon( + imageVector = if (selectedDistributor == null) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null + ) + }, + headlineContent = { + Text(stringResource(R.string.app_settings_unifiedpush_disable)) + }, + modifier = Modifier.clickable { + selectedDistributor = null + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) + } + + items(pushDistributors.orEmpty()) { (distributor, name, icon) -> + val isSelf = distributor == context.packageName + val headline = if (isSelf) stringResource(R.string.app_settings_unifiedpush_distributor_fcm) else name ?: distributor + ListItem( + leadingContent = { + Icon( + imageVector = if (selectedDistributor == distributor) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null + ) + }, + trailingContent = { + if (isSelf) + Image( + painter = painterResource(R.drawable.product_logomark_cloud_messaging_full_color), + contentDescription = headline, + modifier = Modifier.size(32.dp) + ) + else + icon?.let { + Image( + bitmap = icon.toBitmap().asImageBitmap(), + contentDescription = headline, + modifier = Modifier.size(32.dp) + ) + } + }, + headlineContent = { + Text(headline) + }, + modifier = Modifier.clickable { + selectedDistributor = distributor + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) + } + + item { + Text( + text = buildAnnotatedString { + pushStyle( + SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline) + ) + pushLink( + LinkAnnotation.Url( + ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_WEBDAV_PUSH) + .build().toString() + ) + ) + append(stringResource(R.string.app_settings_unifiedpush_encrypted)) + }, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + ) +} + +@Composable +@Preview("No distributors installed", "PushDistributorSelectionDialog") +fun PushDistributorSelectionDialog_Preview_NoDistributors() { + PushDistributorSelectionDialog(null, {}, null) { } +} + +@Composable +@Preview("Push disabled", "PushDistributorSelectionDialog") +fun PushDistributorSelectionDialog_Preview_PushDisabled() { + val ctx = LocalContext.current + PushDistributorSelectionDialog( + null, + {}, + listOf( + PushDistributorInfo( + "com.example.distributor1", + "Distributor 1", + AppCompatResources.getDrawable(ctx, R.drawable.ic_launcher_foreground) + ) + ) + ) { } +} + +@Composable +@Preview("Distributor Selected", "PushDistributorSelectionDialog") +fun PushDistributorSelectionDialog_Preview_DistributorSelected() { + val ctx = LocalContext.current + PushDistributorSelectionDialog( + "com.example.distributor1", + {}, + listOf( + PushDistributorInfo( + "com.example.distributor1", + "Distributor 1", + AppCompatResources.getDrawable(ctx, R.drawable.ic_launcher_foreground) + ), + PushDistributorInfo("com.example.distributor2") + ) + ) { } +} + +@Composable +fun AppSettings_Integration( + tasksAppName: String, + tasksAppIcon: Drawable? = null, + pushDistributors: List?, + pushDistributor: String?, + onPushDistributorChange: (String?) -> Unit, + onNavTasksScreen: () -> Unit = {} +) { + SettingsHeader(divider = true) { + Text(stringResource(R.string.app_settings_integration)) + } + Setting( + name = { + Text(stringResource(R.string.app_settings_tasks_provider)) + }, + icon = { + tasksAppIcon?.let { + Image(tasksAppIcon.toBitmap().asImageBitmap(), tasksAppName) + } + }, + summary = tasksAppName, + onClick = onNavTasksScreen + ) + + var showingDistributorDialog by remember { mutableStateOf(false) } + if (showingDistributorDialog) { + PushDistributorSelectionDialog( + pushDistributor = pushDistributor, + onPushDistributorChange = onPushDistributorChange, + pushDistributors = pushDistributors + ) { showingDistributorDialog = false } + } + + val context = LocalContext.current + val pushAppName = if (pushDistributor == context.packageName) { + stringResource(R.string.app_settings_unifiedpush_distributor_fcm) + } else { + pushDistributors?.find { it.packageName == pushDistributor }?.appName + } + Setting( + name = stringResource(R.string.app_settings_unifiedpush), + summary = if (pushDistributor != null) + stringResource(R.string.app_settings_unifiedpush_ready, pushAppName ?: pushDistributor) + else + stringResource(R.string.app_settings_unifiedpush_no_endpoint), + onClick = { showingDistributorDialog = true } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppTheme.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppTheme.kt new file mode 100644 index 0000000..dfce151 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppTheme.kt @@ -0,0 +1,70 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalView +import androidx.lifecycle.compose.LifecycleResumeEffect +import at.bitfire.davdroid.ui.composable.SafeAndroidUriHandler + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + windowInsets: WindowInsets = WindowInsets.safeDrawing, + content: @Composable () -> Unit +) { + val activity = LocalActivity.current + SideEffect { + // If applicable, call Activity.enableEdgeToEdge to enable edge-to-edge layout on Android <15, too. + // When we have moved everything into one Activity with Compose navigation, we can call it there instead. + (activity as? AppCompatActivity)?.enableEdgeToEdge( + navigationBarStyle = SystemBarStyle.auto( + lightScrim = M3ColorScheme.lightScheme.scrim.toArgb(), + darkScrim = M3ColorScheme.darkScheme.scrim.toArgb() + ) { darkTheme } + ) + } + + // Apply SafeAndroidUriHandler to the composition + val uriHandler = SafeAndroidUriHandler(LocalContext.current) + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + MaterialTheme( + colorScheme = if (!darkTheme) + M3ColorScheme.lightScheme + else + M3ColorScheme.darkScheme, + ) { + Box(Modifier.windowInsetsPadding(windowInsets).clipToBounds()) { + content() + } + } + } + + // Track if the app is in the foreground + val view = LocalView.current + LifecycleResumeEffect(view) { + ForegroundTracker.onResume() + onPauseOrDispose { + ForegroundTracker.onPaused() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCase.kt new file mode 100644 index 0000000..631ba4f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCase.kt @@ -0,0 +1,87 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.accounts.Account +import at.bitfire.davdroid.di.ApplicationScope +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.push.PushRegistrationManager +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import at.bitfire.davdroid.ui.CollectionSelectedUseCase.Companion.DELAY_MS +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +/** + * Performs actions when a collection was (un)selected for synchronization. + * + * @see handleWithDelay + */ +class CollectionSelectedUseCase @Inject constructor( + private val accountRepository: AccountRepository, + @ApplicationScope private val applicationScope: CoroutineScope, + private val collectionRepository: DavCollectionRepository, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + private val pushRegistrationManager: PushRegistrationManager, + private val serviceRepository: DavServiceRepository, + private val syncWorkerManager: SyncWorkerManager +) { + + /** + * After a delay of [DELAY_MS] ms: + * + * 1. Enqueues a one-time sync for account of the collection. + * 2. Updates push subscriptions for the service of the collection. + * + * Resets delay when called again before delay finishes. + * + * @param collectionId ID of the collection that was (un)selected for synchronization + */ + suspend fun handleWithDelay(collectionId: Long) { + val collection = collectionRepository.getAsync(collectionId) ?: return + val service = serviceRepository.get(collection.serviceId) ?: return + val account = accountRepository.fromName(service.accountName) + + // Atomically cancel, launch and remember delay coroutine of given account + delayJobs.compute(account) { _, previousJob -> + // Stop previous delay, if exists + previousJob?.cancel() + + applicationScope.launch(defaultDispatcher) { + // wait + delay(DELAY_MS) + + // enqueue sync + syncWorkerManager.enqueueOneTimeAllAuthorities(account) + + // update push subscriptions + pushRegistrationManager.update(service.id) + + // remove complete job + delayJobs -= account + } + } + } + + + companion object { + + /** + * Length of delay in milliseconds + */ + const val DELAY_MS = 5000L // 5 seconds + + private val delayJobs: ConcurrentHashMap = ConcurrentHashMap() + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt new file mode 100644 index 0000000..5e913eb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -0,0 +1,272 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.accounts.Account +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import androidx.core.content.IntentCompat +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import com.google.common.base.Ascii +import dagger.Lazy +import dagger.hilt.android.AndroidEntryPoint +import okhttp3.HttpUrl +import java.io.File +import java.time.Instant +import javax.inject.Inject + +/** + * Debug info activity. Provides verbose information for debugging and support. Should enable users + * to debug problems themselves, but also to send it to the support. + * + * Important use cases to test: + * + * - debug info from App settings / Debug info (should provide debug info) + * - login with some broken login URL (should provide debug info + logs; check logs, too) + * - enable App settings / Verbose logs, then open debug info activity (should provide debug info + logs; check logs, too) + */ +@AndroidEntryPoint +class DebugInfoActivity: AppCompatActivity() { + + @Inject + lateinit var tasksAppManager: Lazy + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val extras = intent.extras + val viewResourceIntent = IntentCompat.getParcelableExtra( + intent, + EXTRA_LOCAL_RESOURCE_URI, + Uri::class.java + )?.let { uri -> + buildViewLocalResourceIntent(uri) + } + + val remoteResource = extras?.getString(EXTRA_REMOTE_RESOURCE) + setContent { + DebugInfoScreen( + account = IntentCompat.getParcelableExtra(intent, EXTRA_ACCOUNT, Account::class.java), + syncDataType = extras?.getString(EXTRA_SYNC_DATA_TYPE), + cause = IntentCompat.getSerializableExtra(intent, EXTRA_CAUSE, Throwable::class.java), + canViewResource = viewResourceIntent != null, + localResource = extras?.getString(EXTRA_LOCAL_RESOURCE_SUMMARY), + remoteResource = remoteResource, + logs = extras?.getString(EXTRA_LOGS), + timestamp = extras?.getLong(EXTRA_TIMESTAMP), + onShareZipFile = ::shareZipFile, + onViewFile = ::viewFile, + onCopyRemoteUrl = { + val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText("Remote resource", remoteResource) + clipboard.setPrimaryClip(clipData) + }, + onViewLocalResource = { viewResource(viewResourceIntent) }, + onNavUp = ::onSupportNavigateUp + ) + } + } + + private fun shareZipFile(file: File) { + shareFile( + file, + "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info", + getString(R.string.debug_info_attached), + "*/*", // application/zip won't show all apps that can manage binary files, like ShareViaHttp + ) + } + + /** + * Starts an activity passing sharing intent along + */ + private fun shareFile( + file: File, + subject: String? = null, + text: String? = null, + type: String = "text/plain" + ) { + val uri = FileProvider.getUriForFile( + this, + getString(R.string.authority_debug_provider), + file + ) + ShareCompat.IntentBuilder(this) + .setSubject(subject) + .setText(text) + .setType(type) + .setStream(uri) + .startChooser() + } + + /** + * Starts an activity passing file viewer intent along + */ + private fun viewFile( + file: File, + title: String? = null + ) { + val uri = FileProvider.getUriForFile( + this, + getString(R.string.authority_debug_provider), + file + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "text/plain") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(intent, title)) + } + + /** + * Starts activity to view the affected/problematic resource + */ + private fun viewResource(intent: Intent?) = try { + startActivity(intent) + } catch (_: Exception) { + Toast.makeText( + this, + getString(R.string.debug_info_can_not_view_resource), + Toast.LENGTH_LONG + ).show() + } + + /** + * Builds intent to view the problematic local event, task or contact at given Uri. + * + * Note that only OpenTasks is supported as tasks provider. TasksOrg and jtxBoard + * do not support viewing tasks via intent-filter (yet). See also [at.bitfire.davdroid.sync.SyncNotificationManager.getLocalResourceUri] + */ + private fun buildViewLocalResourceIntent(uri: Uri): Intent? { + val activeTasksAuthority = tasksAppManager.get().currentProvider()?.authority + return when (uri.authority) { + ContactsContract.AUTHORITY -> + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, ContactsContract.Contacts.CONTENT_ITEM_TYPE) + } + + CalendarContract.AUTHORITY, activeTasksAuthority -> + Intent(Intent.ACTION_VIEW, uri) + + else -> null + } + } + + /** + * Builder for [DebugInfoActivity] intents + */ + class IntentBuilder(context: Context) { + + companion object { + const val MAX_ELEMENT_SIZE = 800 * 1024 // 800 kB + } + + val intent = Intent(context, DebugInfoActivity::class.java) + .putExtra(EXTRA_TIMESTAMP, Instant.now().epochSecond) + + fun newTask(): IntentBuilder { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return this + } + + fun withAccount(account: Account?): IntentBuilder { + if (account != null) + intent.putExtra(EXTRA_ACCOUNT, account) + return this + } + + fun withSyncDataType(dataType: SyncDataType?): IntentBuilder { + if (dataType != null) + intent.putExtra(EXTRA_SYNC_DATA_TYPE, dataType.name) + return this + } + + fun withCause(throwable: Throwable?): IntentBuilder { + if (throwable != null) + intent.putExtra(EXTRA_CAUSE, throwable) + return this + } + + fun withLocalResource(dump: String?): IntentBuilder { + if (dump != null) + intent.putExtra( + EXTRA_LOCAL_RESOURCE_SUMMARY, + Ascii.truncate(dump, MAX_ELEMENT_SIZE, "...") + ) + return this + } + + fun withLocalResourceUri(uri: Uri?): IntentBuilder { + if (uri == null) + return this + intent.putExtra(EXTRA_LOCAL_RESOURCE_URI, uri) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return this + } + + fun withLogs(logs: String?): IntentBuilder { + if (logs != null) + intent.putExtra( + EXTRA_LOGS, + Ascii.truncate(logs, MAX_ELEMENT_SIZE, "...") + ) + return this + } + + fun withRemoteResource(remote: HttpUrl?): IntentBuilder { + if (remote != null) + intent.putExtra(EXTRA_REMOTE_RESOURCE, remote.toString()) + return this + } + + + fun build() = intent + + fun share() = intent.apply { + action = Intent.ACTION_SEND + } + + } + + companion object { + /** [android.accounts.Account] (as [android.os.Parcelable]) related to problem */ + private const val EXTRA_ACCOUNT = "account" + + /** sync data type related to problem */ + private const val EXTRA_SYNC_DATA_TYPE = "syncDataType" + + /** serialized [Throwable] that causes the problem */ + private const val EXTRA_CAUSE = "cause" + + /** Summary (dump of [at.bitfire.davdroid.resource.LocalResource] properties) of local resource related to the problem (plain-text [String]) */ + internal const val EXTRA_LOCAL_RESOURCE_SUMMARY = "localResourceSummary" + + /** [Uri] of local resource related to the problem (as [android.os.Parcelable]) */ + internal const val EXTRA_LOCAL_RESOURCE_URI = "localResourceId" + + /** logs related to the problem (plain-text [String]) */ + private const val EXTRA_LOGS = "logs" + + /** URL of remote resource related to the problem (plain-text [String]) */ + private const val EXTRA_REMOTE_RESOURCE = "remoteResource" + + /** A timestamp of the moment at which the error took place. */ + private const val EXTRA_TIMESTAMP = "timestamp" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt new file mode 100644 index 0000000..be8a45f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt @@ -0,0 +1,561 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.usage.UsageStatsManager +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.LocaleList +import android.os.PowerManager +import android.os.StatFs +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.annotation.WorkerThread +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.content.pm.PackageInfoCompat +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.TextTable +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration +import at.bitfire.davdroid.sync.worker.BaseSyncWorker +import at.bitfire.ical4android.TaskProvider +import at.techbee.jtx.JtxContract +import dagger.hilt.android.qualifiers.ApplicationContext +import org.dmfs.tasks.contract.TaskContract +import java.io.PrintWriter +import java.io.Writer +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.TimeZone +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.use +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter as asCalendarSyncAdapter +import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter +import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter + +@WorkerThread +class DebugInfoGenerator @Inject constructor( + private val accountRepository: AccountRepository, + private val accountSettingsFactory: AccountSettings.Factory, + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val logger: Logger, + private val settings: SettingsManager, + private val syncFramework: SyncFrameworkIntegration +) { + + operator fun invoke( + syncAccount: Account?, + syncDataType: String?, + cause: Throwable?, + localResource: String?, + remoteResource: String?, + timestamp: Long?, + writer: PrintWriter + ) { + writer.println("--- BEGIN DEBUG INFO ---") + writer.println() + + // begin with a timestamp to know when the error occurred + if (timestamp != null) { + val instant = Instant.ofEpochSecond(timestamp) + writer.println("NOTIFICATION TIME") + val iso = DateTimeFormatter.ISO_OFFSET_DATE_TIME + writer.println("Local time: ${instant.atZone(ZoneId.systemDefault()).format(iso)}") + writer.println("UTC: ${instant.atZone(ZoneOffset.UTC).format(iso)}") + writer.println() + } + + // continue with most specific information + if (syncAccount != null || syncDataType != null) { + writer.append("SYNCHRONIZATION INFO\n") + if (syncAccount != null) + writer.append("Account: $syncAccount\n") + if (syncDataType != null) + writer.append("SyncDataType: $syncDataType\n") + writer.append("\n") + } + + if (cause != null) { + writer.println("EXCEPTION") + cause.printStackTrace(writer) + writer.println() + } + + // exception details + if (cause is DavException) { + cause.requestExcerpt?.let { request -> + writer.append("HTTP REQUEST\n") + writer.append(request) + writer.append("\n\n") + } + cause.responseExcerpt?.let { response -> + writer.append("HTTP RESPONSE\n") + writer.append(response) + writer.append("\n\n") + } + } + + if (localResource != null) + writer.append("LOCAL RESOURCE\n$localResource\n\n") + + if (remoteResource != null) + writer.append("REMOTE RESOURCE\n$remoteResource\n\n") + + // software info + try { + writer.append("SOFTWARE INFORMATION\n") + val table = TextTable("Package", "Version", "Code", "Installer", "Notes") + val pm = context.packageManager + + val packageNames = mutableSetOf( // we always want info about these packages: + BuildConfig.APPLICATION_ID, // DAVx5 + TaskProvider.ProviderName.JtxBoard.packageName, // jtx Board + TaskProvider.ProviderName.OpenTasks.packageName, // OpenTasks + TaskProvider.ProviderName.TasksOrg.packageName // tasks.org + ) + // ... and info about contact and calendar provider + for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY)) + pm.resolveContentProvider(authority, 0)?.let { packageNames += it.packageName } + // ... and info about contact, calendar, task-editing apps + val dataUris = arrayOf( + ContactsContract.Contacts.CONTENT_URI, + CalendarContract.Events.CONTENT_URI, + TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), + TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.TasksOrg.authority) + ) + for (uri in dataUris) { + val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, /* some random ID */ 1)) + for (info in pm.queryIntentActivities(viewIntent, 0)) + packageNames += info.activityInfo.packageName + } + + for (packageName in packageNames) + try { + val info = pm.getPackageInfo(packageName, 0) + val appInfo = info.applicationInfo + val notes = mutableListOf() + if (appInfo?.enabled == false) + notes += "disabled" + if (appInfo?.flags?.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0) + notes += "on external storage" + table.addLine( + info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info), + pm.getInstallerPackageName(info.packageName) ?: '—', notes.joinToString(", ") + ) + } catch (_: PackageManager.NameNotFoundException) { + } + writer.append(table.toString()) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't get software information", e) + } + + // system info + val locales: Any = LocaleList.getAdjustedDefault() + writer.append( + "\n\nSYSTEM INFORMATION\n\n" + + "Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" + + "Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" + + "Locale(s): $locales\n" + + "Time zone: ${TimeZone.getDefault().id}\n" + ) + val filesPath = Environment.getDataDirectory() + val statFs = StatFs(filesPath.path) + writer.append("Internal memory ($filesPath): ") + .append(Formatter.formatFileSize(context, statFs.availableBytes)) + .append(" free of ") + .append(Formatter.formatFileSize(context, statFs.totalBytes)) + .append("\n\n") + + // power saving + if (Build.VERSION.SDK_INT >= 28) + context.getSystemService()?.let { statsManager -> + val bucket = statsManager.appStandbyBucket + writer + .append("App standby bucket: ") + .append( + when { + bucket <= 5 -> "exempted (very good)" + bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active (good)" + bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working set (bad: job restrictions apply)" + bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent (bad: job restrictions apply)" + bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare (very bad: job and network restrictions apply)" + bucket <= UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> "restricted (very bad: job and network restrictions apply)" + else -> "$bucket" + } + ) + writer.append('\n') + } + context.getSystemService()?.let { powerManager -> + writer.append("App exempted from power saving: ") + .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes (good)" else "no (bad)") + .append('\n') + .append("System in power-save mode: ") + .append(if (powerManager.isPowerSaveMode) "yes (restrictions apply!)" else "no") + .append('\n') + } + // system-wide sync + writer.append("System-wide synchronization: ") + .append(if (syncFramework.getMasterSyncAutomatically()) "automatically" else "manually") + .append("\n\n") + + // connectivity + context.getSystemService()?.let { connectivityManager -> + writer.append("\n\nCONNECTIVITY\n\n") + val activeNetwork = connectivityManager.activeNetwork + connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network -> + val properties = connectivityManager.getLinkProperties(network) + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + writer.append(if (network == activeNetwork) " ☒ " else " ☐ ") + .append(properties?.interfaceName ?: "?") + .append("\n - ") + .append(capabilities.toString().replace('&', ' ')) + .append('\n') + } + if (properties != null) { + writer.append(" - DNS: ") + .append(properties.dnsServers.joinToString(", ") { it.hostAddress }) + if (Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive) + writer.append(" (private mode)") + writer.append('\n') + } + } + writer.append('\n') + + connectivityManager.defaultProxy?.let { proxy -> + writer.append("System default proxy: ${proxy.host}:${proxy.port}\n") + } + writer.append("Data saver: ").append( + when (connectivityManager.restrictBackgroundStatus) { + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled" + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted" + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled" + else -> connectivityManager.restrictBackgroundStatus.toString() + } + ).append('\n') + writer.append('\n') + } + + writer.append("\n\nCONFIGURATION\n") + // notifications + val nm = NotificationManagerCompat.from(context) + writer.append("\nNotifications") + if (!nm.areNotificationsEnabled()) + writer.append(" (blocked!)") + writer.append(":\n") + if (Build.VERSION.SDK_INT >= 26) { + val channelsWithoutGroup = nm.notificationChannels.toMutableSet() + for (group in nm.notificationChannelGroups) { + writer.append(" - ${group.id}") + if (Build.VERSION.SDK_INT >= 28) + writer.append(" isBlocked=${group.isBlocked}") + writer.append('\n') + for (channel in group.channels) { + writer.append(" * ${channel.id}: importance=${channel.importance}\n") + channelsWithoutGroup -= channel + } + } + for (channel in channelsWithoutGroup) + writer.append(" - ${channel.id}: importance=${channel.importance}\n") + } + writer.append('\n') + // permissions + writer.append("Permissions:\n") + val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS) + for (permission in ownPkgInfo.requestedPermissions.orEmpty()) { + val shortPermission = permission.removePrefix("android.permission.") + writer.append(" - $shortPermission: ") + .append( + if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) + "granted" + else + "denied" + ) + .append('\n') + } + writer.append('\n') + + // accounts + writer.append("\nACCOUNTS") + val accountManager = AccountManager.get(context) + val accounts = accountRepository.getAll() + for (account in accounts) + dumpAccount(account, writer) + + val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList() + if (addressBookAccounts.isNotEmpty()) { + writer.append("ADDRESS BOOK ACCOUNTS\n\n") + for (account in addressBookAccounts) + dumpAddressBookAccount(account, accountManager, writer) + } + + // non-sync workers + writer.append("OTHER WORKERS\n") + dumpOtherWorkers(accounts, writer) + + // database dump + writer.append("\n\n\nDATABASE DUMP\n\n") + db.dump(writer, arrayOf("webdav_document")) + + // app settings + writer.append("\nAPP SETTINGS\n\n") + settings.dump(writer) + + writer.append("--- END DEBUG INFO ---\n") + } + + /** + * Appends relevant android account information the given writer. + */ + private fun dumpAccount(account: Account, writer: Writer) { + writer.append("\n\n - Account: ${account.name}\n") + val accountSettings = accountSettingsFactory.create(account) + + writer.append(dumpAndroidAccount(account, AccountDumpInfo.caldavAccount(account))) + try { + val credentials = accountSettings.credentials() + val authStr = mutableListOf() + if (credentials.username != null) + authStr += "user name" + if (credentials.password != null) + authStr += "password" + if (credentials.certificateAlias != null) + authStr += "client certificate" + credentials.authState?.let { authState -> + authStr += "OAuth [${authState.authorizationServiceConfiguration?.authorizationEndpoint}]" + } + if (authStr.isNotEmpty()) + writer .append(" Authentication: ") + .append(authStr.joinToString(", ")) + .append("\n") + + writer.append(" WiFi only: ${accountSettings.getSyncWifiOnly()}") + accountSettings.getSyncWifiOnlySSIDs()?.let { ssids -> + writer.append(", SSIDs: ${ssids.joinToString(", ")}") + } + writer.append( + "\n Contact group method: ${accountSettings.getGroupMethod()}\n" + + " Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" + + " Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" + + " Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" + + " Use event colors: ${accountSettings.getEventColors()}\n" + ) + + writer.append("\nSync workers:\n") + dumpSyncWorkers(account, writer) + writer.append("\n") + } catch (e: InvalidAccountException) { + writer.append("$e\n") + } + writer.append('\n') + } + + /** + * Appends relevant address book type android account information to the given writer. + */ + private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) { + writer.append(" * Address book: ${account.name}\n") + val table = dumpAndroidAccount(account, AccountDumpInfo.addressBookAccount(account)) + writer.append(TextTable.indent(table, 4)) + .append("Collection ID: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)}\n") + .append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n\n") + } + + /** + * Retrieves specified information from an android account. + */ + private fun dumpAndroidAccount(account: Account, infos: Iterable): String { + val table = TextTable("Authority", "isSyncable", "syncsOnContentChange", "Entries") + for (info in infos) { + var nrEntries = "—" + if (info.countUri != null) + try { + context.contentResolver.acquireContentProviderClient(info.authority)?.use { client -> + client.query(info.countUri, null, null, null, null)?.use { cursor -> + nrEntries = "${cursor.count} ${info.countStr}" + } + } + } catch (e: Exception) { + nrEntries = e.toString() + } + table.addLine( + info.authority, + syncFramework.isSyncable(account, info.authority), + syncFramework.syncsOnContentChange(account, info.authority), + nrEntries + ) + } + return table.toString() + } + + /** + * Generates a table to display worker statuses. + * + * By default, the table provides the following columns: + * Tags, State, Next run, Retries, Generation, Periodicity + * + * If more tables are desired, they can be added using [extraColumns]. + * + * The key of the map is the position of the column relative to the default ones, so for example, + * if a column is going to be added between "State" and "Next run", the index should be `2`. + * + * The value the map is a pair whose first element is the column name, and the second one is + * the generator of the value. + * The generator will be called on every worker info found after running the query, and should + * return a String that will be placed in the cell. + * + * @param query The query to use for fetching the workers. + * @param extraColumns Defaults to an empty map, pass extra columns to be added to the table. + * @param filter Allows filtering the results given by the [query]. Will exclude all [WorkInfo] + * whose filter result is `false`. Defaults to all `true` (do not filter). + */ + private fun workersInfoTable( + query: WorkQuery, + extraColumns: Map String>> = emptyMap(), + filter: (WorkInfo) -> Boolean = { true } + ): String { + val columnNames = mutableListOf("Tags", "State", "Next run", "Retries", "Generation", "Periodicity") + for ((index, column) in extraColumns) { + val (columnName) = column + columnNames.add(index, columnName) + } + + val table = TextTable(columnNames) + val wm = WorkManager.getInstance(context) + val workInfos = wm.getWorkInfos(query).get().filter(filter) + for (workInfo in workInfos) { + val line = mutableListOf( + workInfo.tags.map { it.replace("\\bat\\.bitfire\\.davdroid\\.".toRegex(), ".") }, + "${workInfo.state} (${workInfo.stopReason})", + workInfo.nextScheduleTimeMillis.let { nextRun -> + when (nextRun) { + Long.MAX_VALUE -> "—" + else -> DateUtils.getRelativeTimeSpanString(nextRun) + } + }, + workInfo.runAttemptCount, + workInfo.generation, + workInfo.periodicityInfo?.let { periodicity -> + "every ${periodicity.repeatIntervalMillis/60000} min" + } ?: "not periodic" + ) + + for ((index, column) in extraColumns) { + val (_, transformer) = column + val value = transformer(workInfo) + line.add(index, value) + } + + table.addLine(line) + } + return table.toString() + } + + /** + * Gets sync workers info. + * + * Note: WorkManager does not return worker names when queried, so we create them and ask + * whether they exist one by one. + */ + private fun dumpSyncWorkers(account: Account, writer: Writer) { + writer.append(workersInfoTable( + WorkQuery.Builder.fromTags( + SyncDataType.entries.map { BaseSyncWorker.commonTag(account, it) } + ).build(), + mapOf( + 1 to ("Data Type" to { workInfo: WorkInfo -> + // See: BaseSyncWorker.commonTag + // "sync-$dataType ${account.type}/${account.name}" + workInfo.tags + // Search for the first tag that starts with sync- + .first { it.startsWith("sync-") } + // Get everything before the space (get rid of the account) + .substringBefore(' ') + // Remove the "sync-" prefix + .removePrefix("sync-") + }) + ) + )) + } + + /** + * Gets account-independent workers info. This is done by querying all the workers, and + * filtering the ones that depend on an account (the opposite of [dumpSyncWorkers]). + * + * Note: WorkManager does not return worker names when queried, so we create them and ask + * whether they exist one by one. + * + * @param accounts The list of accounts in the system. This is used for filtering account-dependent + * workers. + */ + private fun dumpOtherWorkers(accounts: Array, writer: Writer) { + val syncWorkersTags = accounts.flatMap { account -> + SyncDataType.entries.map { BaseSyncWorker.commonTag(account, it) } + } + + writer.append(workersInfoTable( + // Fetch all workers + WorkQuery.Builder.fromStates(WorkInfo.State.entries).build(), + filter = { it.tags.all { tag -> !syncWorkersTags.contains(tag) } } + )) + } + + + data class AccountDumpInfo( + val account: Account, + val authority: String, + val countUri: Uri?, + val countStr: String?, + ) { + + companion object { + + internal fun caldavAccount(account: Account) = listOf( + AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"), + AccountDumpInfo(account, TaskProvider.ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"), + AccountDumpInfo(account, TaskProvider.ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri( + TaskProvider.ProviderName.OpenTasks.authority).asCalendarSyncAdapter(account), "OpenTasks task(s)"), + AccountDumpInfo(account, TaskProvider.ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri( + TaskProvider.ProviderName.TasksOrg.authority).asCalendarSyncAdapter(account), "tasks.org task(s)"), + AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "wrongly assigned raw contact(s)") + ) + + internal fun addressBookAccount(account: Account) = listOf( + AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "raw contact(s)") + ) + + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt new file mode 100644 index 0000000..a1f7e3f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt @@ -0,0 +1,196 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.accounts.Account +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.log.LogFileHandler +import at.bitfire.davdroid.ui.DebugInfoModel.Companion.FILE_DEBUG_INFO +import at.bitfire.davdroid.ui.DebugInfoModel.Companion.FILE_LOGS +import com.google.common.io.ByteStreams +import com.google.common.io.Files +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@HiltViewModel(assistedFactory = DebugInfoModel.Factory::class) +class DebugInfoModel @AssistedInject constructor( + @Assisted private val details: DebugInfoDetails, + @ApplicationContext val context: Context, + private val debugInfoGenerator: DebugInfoGenerator, + private val logger: Logger +) : ViewModel() { + + data class DebugInfoDetails( + val account: Account?, + val syncDataType: String?, + val cause: Throwable?, + val localResource: String?, + val remoteResource: String?, + val logs: String?, + val timestamp: Long? + ) + + @AssistedFactory + interface Factory { + fun createWithDetails(details: DebugInfoDetails): DebugInfoModel + } + + data class UiState( + val cause: Throwable? = null, + val localResource: String? = null, + val remoteResource: String? = null, + val logFile: File? = null, + val debugInfo: File? = null, + val zipFile: File? = null, + val zipInProgress: Boolean = false, + val error: String? = null + ) + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun resetZipFile() { + uiState = uiState.copy(zipFile = null) + } + + init { + // create debug info directory + val debugDir = LogFileHandler.debugDir(context) ?: throw IOException("Couldn't create debug info directory") + + viewModelScope.launch(Dispatchers.Default) { + // create log file from EXTRA_LOGS or log file + if (details.logs != null) { + val file = File(debugDir, FILE_LOGS) + if (!file.exists() || file.canWrite()) { + file.printWriter().use { writer -> + writer.write(details.logs) + } + uiState = uiState.copy(logFile = file) + } else + logger.warning("Can't write logs to $file") + } else LogFileHandler.getDebugLogFile(context)?.let { debugLogFile -> + if (debugLogFile.isFile && debugLogFile.canRead()) + uiState = uiState.copy(logFile = debugLogFile) + } + + uiState = uiState.copy( + cause = details.cause, + localResource = details.localResource, + remoteResource = details.remoteResource + ) + generateDebugInfo( + syncAccount = details.account, + syncDataType = details.syncDataType, + cause = details.cause, + localResource = details.localResource, + remoteResource = details.remoteResource, + timestamp = details.timestamp + ) + } + } + + /** + * Creates debug info and saves it to [FILE_DEBUG_INFO] in [LogFileHandler.debugDir] + * + * Note: Part of this method and all of it's helpers (listed below) should probably be extracted in the future + */ + private fun generateDebugInfo( + syncAccount: Account?, + syncDataType: String?, + cause: Throwable?, + localResource: String?, + remoteResource: String?, + timestamp: Long? + ) { + val debugInfoFile = File(LogFileHandler.debugDir(context), FILE_DEBUG_INFO) + debugInfoFile.printWriter().use { writer -> + debugInfoGenerator( + syncAccount = syncAccount, + syncDataType = syncDataType, + cause = cause, + localResource = localResource, + remoteResource = remoteResource, + timestamp = timestamp, + writer = writer + ) + } + uiState = uiState.copy(debugInfo = debugInfoFile) + } + + /** + * Creates the ZIP file containing both [FILE_DEBUG_INFO] and [FILE_LOGS]. + * + * Note: Part of this method should probably be extracted to a more suitable location + */ + fun generateZip() { + try { + uiState = uiState.copy(zipInProgress = true) + + val file = File(LogFileHandler.debugDir(context), "davx5-debug.zip") + logger.fine("Writing debug info to ${file.absolutePath}") + ZipOutputStream(file.outputStream().buffered()).use { zip -> + zip.setLevel(9) + uiState.debugInfo?.let { debugInfo -> + zip.putNextEntry(ZipEntry("debug-info.txt")) + Files.copy(debugInfo, zip) + zip.closeEntry() + } + + val logs = uiState.logFile + if (logs != null) { + // verbose logs available + zip.putNextEntry(ZipEntry(logs.name)) + Files.copy(logs, zip) + zip.closeEntry() + } else { + // logcat (short logs) + try { + Runtime.getRuntime().exec("logcat -d").also { logcat -> + zip.putNextEntry(ZipEntry("logcat.txt")) + ByteStreams.copy(logcat.inputStream, zip) + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't attach logcat", e) + } + } + } + + // success, show ZIP file + uiState = uiState.copy(zipFile = file) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't generate debug info ZIP", e) + uiState = uiState.copy(error = e.localizedMessage) + } finally { + uiState = uiState.copy(zipInProgress = false) + } + } + + + companion object { + private const val FILE_DEBUG_INFO = "debug-info.txt" + private const val FILE_LOGS = "logs.txt" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt new file mode 100644 index 0000000..ad6c2cb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt @@ -0,0 +1,361 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.accounts.Account +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.Adb +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.PrivacyTip +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.composable.CardWithImage +import at.bitfire.davdroid.ui.composable.ProgressBar +import java.io.File +import java.io.IOError +import java.io.IOException + +@Composable +fun DebugInfoScreen( + account: Account?, + syncDataType: String?, + cause: Throwable?, + localResource: String?, + canViewResource: Boolean, + remoteResource: String?, + logs: String?, + timestamp: Long?, + onShareZipFile: (File) -> Unit, + onViewFile: (File) -> Unit, + onCopyRemoteUrl: () -> Unit, + onViewLocalResource: () -> Unit, + onNavUp: () -> Unit +) { + val model: DebugInfoModel = hiltViewModel( + creationCallback = { factory: DebugInfoModel.Factory -> + factory.createWithDetails(DebugInfoModel.DebugInfoDetails( + account = account, + syncDataType = syncDataType, + cause = cause, + localResource = localResource, + remoteResource = remoteResource, + logs = logs, + timestamp = timestamp + )) + } + ) + + val uiState = model.uiState + val debugInfo = uiState.debugInfo + val zipInProgress = uiState.zipInProgress + val zipFile = uiState.zipFile + val logFile = uiState.logFile + val error = uiState.error + + // Share zip file card, once successfully generated + LaunchedEffect(zipFile) { + zipFile?.let { file -> + onShareZipFile(file) + model.resetZipFile() + } + } + + DebugInfoScreen( + error = error, + onResetError = model::resetError, + showDebugInfo = debugInfo != null, + zipProgress = zipInProgress, + showModelCause = cause != null, + modelCauseTitle = when (cause) { + is HttpException -> stringResource(if (cause.isServerError) R.string.debug_info_server_error else R.string.debug_info_http_error) + is DavException -> stringResource(R.string.debug_info_webdav_error) + is IOException, is IOError -> stringResource(R.string.debug_info_io_error) + else -> cause?.let { it::class.java.simpleName } + } ?: "", + modelCauseSubtitle = cause?.localizedMessage, + modelCauseMessage = stringResource( + if (cause is HttpException) + when { + cause.statusCode == 403 -> R.string.debug_info_http_403_description + cause.statusCode == 404 -> R.string.debug_info_http_404_description + cause.statusCode == 405 -> R.string.debug_info_http_405_description + cause.isServerError -> R.string.debug_info_http_5xx_description + else -> R.string.debug_info_unexpected_error + } + else + R.string.debug_info_unexpected_error + ), + localResource = localResource, + canViewResource = canViewResource, + remoteResource = remoteResource, + hasLogFile = logFile != null, + onShareZip = { model.generateZip() }, + onViewLogsFile = { logFile?.let { onViewFile(it) } }, + onViewDebugFile = { debugInfo?.let { onViewFile(it) } }, + onCopyRemoteUrl = onCopyRemoteUrl, + onViewLocalResource = onViewLocalResource, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugInfoScreen( + error: String?, + onResetError: () -> Unit = {}, + showDebugInfo: Boolean, + zipProgress: Boolean, + showModelCause: Boolean, + modelCauseTitle: String, + modelCauseSubtitle: String?, + modelCauseMessage: String?, + localResource: String?, + canViewResource: Boolean, + remoteResource: String?, + hasLogFile: Boolean, + onShareZip: () -> Unit = {}, + onViewLogsFile: () -> Unit = {}, + onViewDebugFile: () -> Unit = {}, + onCopyRemoteUrl: () -> Unit = {}, + onViewLocalResource: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar( + message = it, + duration = SnackbarDuration.Long + ) + onResetError() + } + } + + val uriHandler = LocalUriHandler.current + AppTheme { + Scaffold( + floatingActionButton = { + if (showDebugInfo && !zipProgress) { + FloatingActionButton( + onClick = onShareZip, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Rounded.Share, stringResource(R.string.share)) + } + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.debug_info_title)) }, + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(R.string.navigate_up) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + if (!showDebugInfo || zipProgress) + ProgressBar() + + CardWithImage( + title = stringResource(R.string.debug_info_privacy_warning_title), + message = stringResource(R.string.debug_info_privacy_warning_description), + icon = Icons.Rounded.PrivacyTip, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + + if (showModelCause) { + CardWithImage( + title = modelCauseTitle, + subtitle = modelCauseSubtitle, + message = modelCauseMessage, + icon = Icons.Rounded.Info, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + + if (showDebugInfo) + CardWithImage( + image = painterResource(R.drawable.undraw_server_down), + imageAlignment = BiasAlignment(0f, .7f), + title = stringResource(R.string.debug_info_title), + subtitle = stringResource(R.string.debug_info_subtitle), + icon = Icons.Rounded.BugReport, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) { + OutlinedButton( + onClick = onViewDebugFile, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Text( + stringResource(R.string.debug_info_view_details) + ) + } + } + + if (localResource != null || remoteResource != null) + CardWithImage( + title = stringResource(R.string.debug_info_involved_caption), + subtitle = stringResource(R.string.debug_info_involved_subtitle), + icon = Icons.Rounded.Adb, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) { + remoteResource?.let { remoteUrl -> + Text( + text = stringResource(R.string.debug_info_involved_remote), + style = MaterialTheme.typography.bodyLarge + ) + SelectionContainer { + Text( + text = remoteUrl, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + modifier = Modifier.padding(bottom = 8.dp) + ) + } + OutlinedButton( + onClick = onCopyRemoteUrl, + modifier = Modifier.padding(end = 8.dp) + ) { + Text(stringResource(R.string.debug_info_copy_remote_url)) + } + } + localResource?.let { + Text( + text = stringResource(R.string.debug_info_involved_local), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 12.dp) + ) + Text( + text = it, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + modifier = Modifier.padding(bottom = 8.dp) + ) + } + if (canViewResource) + OutlinedButton( + onClick = { onViewLocalResource() }, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Text( + stringResource(R.string.debug_info_view_local_resource) + ) + } + } + + if (hasLogFile) { + CardWithImage( + title = stringResource(R.string.debug_info_logs_caption), + subtitle = stringResource(R.string.debug_info_logs_subtitle), + icon = Icons.Rounded.BugReport, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) { + OutlinedButton( + onClick = onViewLogsFile, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Text( + stringResource(R.string.debug_info_logs_view) + ) + } + } + } + + if (showDebugInfo) { + CardWithImage( + title = stringResource(R.string.debug_info_archive_caption), + subtitle = stringResource(R.string.debug_info_archive_subtitle), + message = stringResource(R.string.debug_info_archive_text), + icon = Icons.Rounded.Share, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) { + OutlinedButton( + onClick = onShareZip, + enabled = !zipProgress, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Text( + stringResource(R.string.debug_info_archive_share) + ) + } + } + } + + // space for the FAB + Spacer(modifier = Modifier.height(64.dp)) + } + } + } +} + +@Composable +@Preview +fun DebugInfoScreen_Preview() { + DebugInfoScreen( + error = "Some error", + showDebugInfo = true, + zipProgress = false, + showModelCause = true, + modelCauseTitle = "ModelCauseTitle", + modelCauseSubtitle = "ModelCauseSubtitle", + modelCauseMessage = "ModelCauseMessage", + localResource = "local-resource-string", + canViewResource = true, + remoteResource = "remote-resource-string", + hasLogFile = true + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/ExternalUris.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/ExternalUris.kt new file mode 100644 index 0000000..9669bca --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/ExternalUris.kt @@ -0,0 +1,95 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.net.Uri +import androidx.core.net.toUri +import at.bitfire.davdroid.BuildConfig + +/** + * Links to to external pages (Web site, manual, social media etc.) + */ +object ExternalUris { + + /** + * URLs of the DAVx5 homepage + */ + @Suppress("unused") // build variants + object Homepage { + + val baseUrl + get() = "https://www.davx5.com".toUri() + + const val PATH_FAQ = "faq" + const val PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected" + const val PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission" + const val PATH_OPEN_SOURCE = "donate" + const val PATH_PRIVACY = "privacy" + const val PATH_TESTED_SERVICES = "tested-with" + + const val PATH_ORGANIZATIONS = "organizations" + const val PATH_ORGANIZATIONS_MANAGED = "managed-davx5" + const val PATH_ORGANIZATIONS_TRY_IT = "try-it-for-free" + } + + + /** + * URLs of the DAVx5 Manual + */ + object Manual { + + val baseUrl + get() = "https://manual.davx5.com".toUri() + + const val PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html" + const val FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work" + + const val PATH_INTRODUCTION = "introduction.html" + const val FRAGMENT_AUTHENTICATION_METHODS = "authentication-methods" + + const val PATH_SETTINGS = "settings.html" + const val FRAGMENT_APP_SETTINGS = "app-wide-settings" + const val FRAGMENT_ACCOUNT_SETTINGS = "account-settings" + + const val PATH_WEBDAV_PUSH = "webdav_push.html" + const val PATH_WEBDAV_MOUNTS = "webdav_mounts.html" + + } + + + /** + * URLs of DAVx5 social sites + */ + object Social { + + val discussionsUrl + get() = "https://github.com/bitfireAT/davx5-ose/discussions".toUri() + + const val fediverseHandle = "@davx5app@fosstodon.org" + val fediverseUrl + get() = "https://fosstodon.org/@davx5app".toUri() + + } + + + // helpers + + /** + * Appends query parameters for anonymized usage statistics (app ID, version). + * Can be used by the called Website to get an idea of which versions etc. are currently used. + * + * @param context optional info about from where the URL was opened (like a specific Activity) + */ + fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder { + appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID) + appendQueryParameter("app-version", BuildConfig.VERSION_NAME) + + if (context != null) + appendQueryParameter("pk_kwd", context) + + return this + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/ForegroundTracker.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/ForegroundTracker.kt new file mode 100644 index 0000000..ad2c770 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/ForegroundTracker.kt @@ -0,0 +1,40 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Used to track whether the app is in foreground (visible to user) or not. + */ +object ForegroundTracker { + + /** + * Whether the app is in the foreground. + * Used by cert4android to known when it's possible to show the certificate trust decision dialog. + */ + private val _inForeground = MutableStateFlow(false) + + /** + * Whether the app is in foreground or not. + */ + val inForeground = _inForeground.asStateFlow() + + /** + * Called when the app is resumed (at [androidx.lifecycle.Lifecycle.Event.ON_RESUME]) + */ + fun onResume() { + _inForeground.value = true + } + + /** + * Called when the app is paused (at [androidx.lifecycle.Lifecycle.Event.ON_PAUSE]) + */ + fun onPaused() { + _inForeground.value = false + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationRegistry.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationRegistry.kt new file mode 100644 index 0000000..97d55b1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationRegistry.kt @@ -0,0 +1,182 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.TaskStackBuilder +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import at.bitfire.davdroid.R +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages notifications and channels. + * + * As soon as the singleton is created, it will create the necessary notification channels on Android 8+. + * + * Don't use the notification IDs for posting notifications directly – always get the instance of this + * class and use [notifyIfPossible]. + */ +@Singleton +class NotificationRegistry @Inject constructor( + @ApplicationContext val context: Context, + private val logger: Logger +) { + + companion object { + + // notification IDs + const val NOTIFY_VERBOSE_LOGGING = 1 + const val NOTIFY_REFRESH_COLLECTIONS = 2 + const val NOTIFY_DATABASE_CORRUPTED = 4 + const val NOTIFY_SYNC_ERROR = 10 + const val NOTIFY_INVALID_RESOURCE = 11 + const val NOTIFY_SYNC_EXPEDITED = 14 + const val NOTIFY_TASKS_PROVIDER_TOO_OLD = 20 + const val NOTIFY_PERMISSIONS = 21 + + @Suppress("unused") // for build variants + const val NOTIFY_LICENSE = 100 + + } + + + // notification channel names, accessible only when instance (and thus the channels) has been created + + /** + * For notifications that don't fit into another channel. + */ + val CHANNEL_GENERAL = "general" + + /** + * For debugging notifications. High priority because a debugging session + * has been activated by the user and they should know all the time. + * + * Currently only used for the "verbose logging active" notification. + */ + val CHANNEL_DEBUG = "debug" + + /** + * Used to show progress, like that a service detection or WebDAV file access is running. + */ + val CHANNEL_STATUS = "status" + + /** + * For sync-related notifications. Use the appropriate sub-channels for different types of sync problems. + */ + val CHANNEL_SYNC = "sync" + + /** + * For sync errors that are not IO errors. Shown as normal priority. + */ + val CHANNEL_SYNC_ERRORS = "syncProblems" + + /** + * For sync warnings. Shown as low priority. + */ + val CHANNEL_SYNC_WARNINGS = "syncWarnings" + + /** + * For sync IO errors. Shown as minimal priority because they might go away automatically, for instance + * when the connection is working again. + */ + val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors" + + + init { + createChannels() + } + + + /** + * Creates notification channels for Android 8+. + */ + private fun createChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = context.getSystemService()!! + + nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))) + + nm.createNotificationChannels(listOf( + NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH), + NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT), + NotificationChannel(CHANNEL_STATUS, context.getString(R.string.notification_channel_status), NotificationManager.IMPORTANCE_LOW), + + NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply { + description = context.getString(R.string.notification_channel_sync_errors_desc) + group = CHANNEL_SYNC + }, + NotificationChannel(CHANNEL_SYNC_WARNINGS, context.getString(R.string.notification_channel_sync_warnings), NotificationManager.IMPORTANCE_LOW).apply { + description = context.getString(R.string.notification_channel_sync_warnings_desc) + group = CHANNEL_SYNC + }, + NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN).apply { + description = context.getString(R.string.notification_channel_sync_io_errors_desc) + group = CHANNEL_SYNC + } + )) + } + } + + /** + * Shows a notification, if possible. + * + * If the notification is not possible because the user didn't give notification permissions, it will be ignored. + * + * The notification should usually be created using [androidx.core.app.NotificationCompat.Builder]. + * + * @param id Notification ID + * @param tag Notification tag + * @param builder Callback that creates the notification; will only be called if we have the notification permission. + */ + fun notifyIfPossible(id: Int, tag: String? = null, builder: () -> Notification) { + if (ContextCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + // we have the permission, show notification + val nm = NotificationManagerCompat.from(context) + nm.notify(tag, id, builder()) + } else + logger.warning("Notifications disabled, not showing notification $id") + } + + // specific common notifications + + /** + * Shows a notification about missing permissions. + * + * @param intent will be set as content Intent; if null, an Intent to launch PermissionsActivity will be used + */ + fun notifyPermissions(intent: Intent? = null) { + notifyIfPossible(NOTIFY_PERMISSIONS) { + val contentIntent = intent ?: Intent(context, PermissionsActivity::class.java) + NotificationCompat.Builder(context, CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_permissions)) + .setContentText(context.getString(R.string.sync_error_permissions_text)) + .setContentIntent( + TaskStackBuilder.create(context) + .addNextIntentWithParentStack(contentIntent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .build() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt new file mode 100644 index 0000000..3a2ad77 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/OseAccountsDrawerHandler.kt @@ -0,0 +1,148 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpCenter +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CorporateFare +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.VolunteerActivism +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris.Homepage +import at.bitfire.davdroid.ui.ExternalUris.Social +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import javax.inject.Inject + +open class OseAccountsDrawerHandler @Inject constructor(): AccountsDrawerHandler() { + + @Composable + override fun MenuEntries( + snackbarHostState: SnackbarHostState + ) { + val uriHandler = LocalUriHandler.current + + // Most important entries + ImportantEntries(snackbarHostState) + + // News + MenuHeading(R.string.navigation_drawer_news_updates) + MenuEntry( + icon = painterResource(R.drawable.mastodon), + title = Social.fediverseHandle, + onClick = { + uriHandler.openUri(Social.fediverseUrl.toString()) + } + ) + + // Tools + Tools() + + // Support the project + MenuHeading(R.string.navigation_drawer_support_project) + Contribute(onContribute = { + uriHandler.openUri( + Homepage.baseUrl.buildUpon() + .appendPath(Homepage.PATH_OPEN_SOURCE) + .withStatParams(javaClass.simpleName) + .build().toString() + ) + }) + MenuEntry( + icon = Icons.Default.Forum, + title = stringResource(R.string.navigation_drawer_community), + onClick = { + uriHandler.openUri(Social.discussionsUrl.toString()) + } + ) + + + // External links + MenuHeading(R.string.navigation_drawer_external_links) + MenuEntry( + icon = Icons.Default.Home, + title = stringResource(R.string.navigation_drawer_website), + onClick = { + uriHandler.openUri( + Homepage.baseUrl + .buildUpon() + .withStatParams(javaClass.simpleName) + .build().toString()) + } + ) + MenuEntry( + icon = Icons.Default.Info, + title = stringResource(R.string.navigation_drawer_manual), + onClick = { + uriHandler.openUri(ExternalUris.Manual.baseUrl.toString()) + } + ) + MenuEntry( + icon = Icons.AutoMirrored.Default.HelpCenter, + title = stringResource(R.string.navigation_drawer_faq), + onClick = { + uriHandler.openUri( + Homepage.baseUrl.buildUpon() + .appendPath(Homepage.PATH_FAQ) + .withStatParams(javaClass.simpleName) + .build().toString() + ) + } + ) + MenuEntry( + icon = Icons.Default.CorporateFare, + title = stringResource(R.string.navigation_drawer_managed), + onClick = { + uriHandler.openUri( + Homepage.baseUrl.buildUpon() + .appendPath(Homepage.PATH_ORGANIZATIONS) + .appendPath(Homepage.PATH_ORGANIZATIONS_MANAGED) + .withStatParams(javaClass.simpleName) + .build().toString() + ) + } + ) + MenuEntry( + icon = Icons.Default.CloudOff, + title = stringResource(R.string.navigation_drawer_privacy_policy), + onClick = { + uriHandler.openUri( + Homepage.baseUrl.buildUpon() + .appendPath(Homepage.PATH_PRIVACY) + .withStatParams(javaClass.simpleName) + .build().toString() + ) + } + ) + } + + @Composable + @Preview + fun MenuEntries_Standard_Preview() { + Column { + MenuEntries(SnackbarHostState()) + } + } + + + @Composable + open fun Contribute(onContribute: () -> Unit) { + MenuEntry( + icon = Icons.Default.VolunteerActivism, + title = stringResource(R.string.navigation_drawer_contribute), + onClick = onContribute + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt new file mode 100644 index 0000000..6f3d5ab --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsActivity.kt @@ -0,0 +1,25 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class PermissionsActivity: AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + PermissionsScreen( + onNavigateUp = ::onSupportNavigateUp + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsModel.kt new file mode 100644 index 0000000..a593af5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Context +import android.os.Build +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.util.packageChangedFlow +import at.bitfire.ical4android.TaskProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PermissionsModel @Inject constructor( + @ApplicationContext val context: Context, +): ViewModel() { + + var needKeepPermissions by mutableStateOf(null) + private set + var openTasksAvailable by mutableStateOf(false) + private set + var tasksOrgAvailable by mutableStateOf(false) + private set + var jtxAvailable by mutableStateOf(false) + private set + + init { + viewModelScope.launch { + // check permissions when a package (e.g. tasks app) is (un)installed + packageChangedFlow(context).collect { + checkPermissions() + } + } + } + + fun checkPermissions() { + val pm = context.packageManager + + // auto-reset permissions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + needKeepPermissions = pm.isAutoRevokeWhitelisted + } + + openTasksAvailable = pm.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null + tasksOrgAvailable = pm.resolveContentProvider(TaskProvider.ProviderName.TasksOrg.authority, 0) != null + jtxAvailable = pm.resolveContentProvider(TaskProvider.ProviderName.JtxBoard.authority, 0) != null + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt new file mode 100644 index 0000000..7d1592b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/PermissionsScreen.kt @@ -0,0 +1,257 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.composable.CardWithImage +import at.bitfire.davdroid.ui.composable.PermissionSwitchRow +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.ical4android.TaskProvider +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Used when "Manage permissions" is selected in the settings. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionsScreen( + onNavigateUp: () -> Unit +) { + AppTheme { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_settings_security_app_permissions)) }, + navigationIcon = { + IconButton( + onClick = onNavigateUp + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + } + ) + } + ) { paddingValues -> + PermissionsScreen(modifier = Modifier.padding(paddingValues)) + } + } +} + +/** + * Used by [PermissionsScreen] and directly embedded in [at.bitfire.davdroid.ui.intro.PermissionsIntroPage]. + */ +@Composable +fun PermissionsScreen( + modifier: Modifier = Modifier, + model: PermissionsModel = viewModel() +) { + // check permissions when the lifecycle owner (for instance Activity) is resumed + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = object: DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + model.checkPermissions() + } + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } + + val context = LocalContext.current + PermissionsScreen( + keepPermissions = model.needKeepPermissions, + onKeepPermissionsRequested = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent( + Intent.ACTION_AUTO_REVOKE_PERMISSIONS, + Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) + ) + try { + context.startActivity(intent) + Toast.makeText(context, R.string.permissions_autoreset_instruction, Toast.LENGTH_LONG).show() + } catch (e: Exception) { + Logger.getGlobal().log(Level.WARNING, "Couldn't start Keep Permissions activity", e) + } + } + }, + openTasksAvailable = model.openTasksAvailable, + tasksOrgAvailable = model.tasksOrgAvailable, + jtxAvailable = model.jtxAvailable, + modifier = modifier + ) +} + + +@Composable +fun PermissionsScreen( + keepPermissions: Boolean?, + onKeepPermissionsRequested: () -> Unit, + openTasksAvailable: Boolean?, + tasksOrgAvailable: Boolean?, + jtxAvailable: Boolean?, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + CardWithImage( + title = stringResource(R.string.permissions_title), + message = stringResource( + R.string.permissions_text, + stringResource(R.string.app_name) + ), + image = painterResource(R.drawable.intro_permissions), + modifier = Modifier.padding(8.dp) + ) { + if (keepPermissions != null) { + PermissionSwitchRow( + text = stringResource(R.string.permissions_autoreset_title), + summaryWhenGranted = stringResource(R.string.permissions_autoreset_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_autoreset_status_off), + allPermissionsGranted = keepPermissions, + onLaunchRequest = onKeepPermissionsRequested, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + val allPermissions = mutableListOf() + allPermissions.addAll(PermissionUtils.CONTACT_PERMISSIONS) + allPermissions.addAll(PermissionUtils.CALENDAR_PERMISSIONS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + allPermissions += Manifest.permission.POST_NOTIFICATIONS + if (openTasksAvailable == true) + allPermissions.addAll(TaskProvider.PERMISSIONS_OPENTASKS) + if (tasksOrgAvailable == true) + allPermissions.addAll(TaskProvider.PERMISSIONS_TASKS_ORG) + if (jtxAvailable == true) + allPermissions.addAll(TaskProvider.PERMISSIONS_JTX) + PermissionSwitchRow( + text = stringResource(R.string.permissions_all_title), + permissions = allPermissions, + summaryWhenGranted = stringResource(R.string.permissions_all_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_all_status_off), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 4.dp) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + PermissionSwitchRow( + text = stringResource(R.string.permissions_notification_title), + summaryWhenGranted = stringResource(R.string.permissions_notification_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_notification_status_off), + permissions = listOf(Manifest.permission.POST_NOTIFICATIONS), + modifier = Modifier.padding(vertical = 4.dp) + ) + + PermissionSwitchRow( + text = stringResource(R.string.permissions_calendar_title), + summaryWhenGranted = stringResource(R.string.permissions_calendar_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_calendar_status_off), + permissions = PermissionUtils.CALENDAR_PERMISSIONS.toList(), + modifier = Modifier.padding(vertical = 4.dp) + ) + PermissionSwitchRow( + text = stringResource(R.string.permissions_contacts_title), + summaryWhenGranted = stringResource(R.string.permissions_contacts_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_contacts_status_off), + permissions = PermissionUtils.CONTACT_PERMISSIONS.toList(), + modifier = Modifier.padding(vertical = 4.dp) + ) + + if (jtxAvailable == true) + PermissionSwitchRow( + text = stringResource(R.string.permissions_jtx_title), + summaryWhenGranted = stringResource(R.string.permissions_tasks_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_tasks_status_off), + permissions = TaskProvider.PERMISSIONS_JTX.toList(), + modifier = Modifier.padding(vertical = 4.dp) + ) + if (openTasksAvailable == true) + PermissionSwitchRow( + text = stringResource(R.string.permissions_opentasks_title), + summaryWhenGranted = stringResource(R.string.permissions_tasks_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_tasks_status_off), + permissions = TaskProvider.PERMISSIONS_OPENTASKS.toList(), + modifier = Modifier.padding(vertical = 4.dp) + ) + if (tasksOrgAvailable == true) + PermissionSwitchRow( + text = stringResource(R.string.permissions_tasksorg_title), + summaryWhenGranted = stringResource(R.string.permissions_tasks_status_on), + summaryWhenNotGranted = stringResource(R.string.permissions_tasks_status_off), + permissions = TaskProvider.PERMISSIONS_TASKS_ORG.toList(), + modifier = Modifier.padding(vertical = 4.dp) + ) + + Text( + text = stringResource(R.string.permissions_app_settings_hint), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 24.dp) + ) + + val context = LocalContext.current + OutlinedButton( + modifier = Modifier.padding(vertical = 8.dp), + onClick = { PermissionUtils.showAppSettings(context) } + ) { + Text(stringResource(R.string.permissions_app_settings)) + } + } + } +} + +@Composable +@Preview +fun PermissionsCard_Preview() { + AppTheme { + PermissionsScreen( + keepPermissions = true, + onKeepPermissionsRequested = {}, + openTasksAvailable = true, + tasksOrgAvailable = true, + jtxAvailable = true + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksActivity.kt new file mode 100644 index 0000000..62685a1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksActivity.kt @@ -0,0 +1,24 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TasksActivity: AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + TasksScreen( + onNavUp = ::onSupportNavigateUp + ) + } + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksModel.kt new file mode 100644 index 0000000..b67d08e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksModel.kt @@ -0,0 +1,84 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.util.packageChangedFlow +import at.bitfire.ical4android.TaskProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TasksModel @Inject constructor( + @ApplicationContext val context: Context, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + private val settings: SettingsManager, + private val tasksAppManager: TasksAppManager +) : ViewModel() { + + companion object { + + /** + * Whether this fragment (which asks for OpenTasks installation) shall be shown. + * If this setting is true or null/not set, the notice shall be shown. Only if this + * setting is false, the notice shall not be shown. + */ + const val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled" + + } + + val showAgain = settings.getBooleanFlow(HINT_OPENTASKS_NOT_INSTALLED, true) + fun setShowAgain(showAgain: Boolean) { + if (showAgain) + settings.remove(HINT_OPENTASKS_NOT_INSTALLED) + else + settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false) + } + + val currentProvider = tasksAppManager.currentProviderFlow() + val jtxSelected = currentProvider.map { it == TaskProvider.ProviderName.JtxBoard } + val tasksOrgSelected = currentProvider.map { it == TaskProvider.ProviderName.TasksOrg } + val openTasksSelected = currentProvider.map { it == TaskProvider.ProviderName.OpenTasks } + + var jtxInstalled by mutableStateOf(false) + var tasksOrgInstalled by mutableStateOf(false) + var openTasksInstalled by mutableStateOf(false) + + init { + viewModelScope.launch { + packageChangedFlow(context).collect { + jtxInstalled = isInstalled(TaskProvider.ProviderName.JtxBoard.packageName) + tasksOrgInstalled = isInstalled(TaskProvider.ProviderName.TasksOrg.packageName) + openTasksInstalled = isInstalled(TaskProvider.ProviderName.OpenTasks.packageName) + } + } + } + + private fun isInstalled(packageName: String): Boolean = + try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + + fun selectProvider(provider: TaskProvider.ProviderName) = viewModelScope.launch(defaultDispatcher) { + tasksAppManager.selectProvider(provider) + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt new file mode 100644 index 0000000..e854f7b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksScreen.kt @@ -0,0 +1,257 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.core.text.HtmlCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.CardWithImage +import at.bitfire.davdroid.ui.composable.RadioWithSwitch +import at.bitfire.ical4android.TaskProvider +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TasksScreen(onNavUp: () -> Unit) { + AppTheme { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.intro_tasks_title)) }, + navigationIcon = { + IconButton( + onClick = onNavUp + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + } + ) + } + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + TasksCard() + } + } + } +} + +@Composable +fun TasksCard( + model: TasksModel = viewModel() +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val currentProvider by model.currentProvider.collectAsStateWithLifecycle(null) + + val jtxInstalled = model.jtxInstalled + val jtxSelected by model.jtxSelected.collectAsStateWithLifecycle(false) + + val tasksOrgInstalled = model.tasksOrgInstalled + val tasksOrgSelected by model.tasksOrgSelected.collectAsStateWithLifecycle(false) + + val openTasksInstalled = model.openTasksInstalled + val openTasksSelected by model.openTasksSelected.collectAsStateWithLifecycle(false) + + val showAgain by model.showAgain.collectAsStateWithLifecycle(true) + + TasksCard( + jtxSelected = jtxSelected, + jtxInstalled = jtxInstalled, + tasksOrgSelected = tasksOrgSelected, + tasksOrgInstalled = tasksOrgInstalled, + openTasksSelected = openTasksSelected, + openTasksInstalled = openTasksInstalled, + showAgain = showAgain, + onSetShowAgain = model::setShowAgain, + onProviderSelected = { provider -> + if (currentProvider != provider) + model.selectProvider(provider) + }, + installApp = { packageName -> + val uri = ("market://details?id=$packageName&referrer=" + + Uri.encode("utm_source=" + BuildConfig.APPLICATION_ID)).toUri() + val intent = Intent(Intent.ACTION_VIEW, uri) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + else + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.intro_tasks_no_app_store), + duration = SnackbarDuration.Long + ) + } + } + ) +} + +@Composable +fun TasksCard( + jtxSelected: Boolean, + jtxInstalled: Boolean, + tasksOrgSelected: Boolean, + tasksOrgInstalled: Boolean, + openTasksSelected: Boolean, + openTasksInstalled: Boolean, + onProviderSelected: (TaskProvider.ProviderName) -> Unit = {}, + installApp: (String) -> Unit = {}, + showAgain: Boolean, + onSetShowAgain: (Boolean) -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + ) { + CardWithImage( + image = painterResource(R.drawable.intro_tasks), + imageAlignment = BiasAlignment(0f, .1f), + title = stringResource(R.string.intro_tasks_title), + message = stringResource(R.string.intro_tasks_text1), + modifier = Modifier.padding(8.dp) + ) { + RadioWithSwitch( + title = stringResource(R.string.intro_tasks_jtx), + summary = { + Text(stringResource(R.string.intro_tasks_jtx_info)) + }, + isSelected = jtxSelected, + isToggled = jtxInstalled, + enabled = jtxInstalled, + onSelected = { onProviderSelected(TaskProvider.ProviderName.JtxBoard) }, + onToggled = { toggled -> + if (toggled) installApp(TaskProvider.ProviderName.JtxBoard.packageName) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + RadioWithSwitch( + title = stringResource(R.string.intro_tasks_tasks_org), + summary = { + val summary = HtmlCompat.fromHtml( + stringResource(R.string.intro_tasks_tasks_org_info), + HtmlCompat.FROM_HTML_MODE_COMPACT + ).toAnnotatedString() + Text(summary) + }, + isSelected = tasksOrgSelected, + isToggled = tasksOrgInstalled, + enabled = tasksOrgInstalled, + onSelected = { onProviderSelected(TaskProvider.ProviderName.TasksOrg) }, + onToggled = { toggled -> + if (toggled) installApp(TaskProvider.ProviderName.TasksOrg.packageName) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + RadioWithSwitch( + title = stringResource(R.string.intro_tasks_opentasks), + summary = { + Text(stringResource(R.string.intro_tasks_opentasks_info)) + }, + isSelected = openTasksSelected, + isToggled = openTasksInstalled, + enabled = openTasksInstalled, + onSelected = { onProviderSelected(TaskProvider.ProviderName.OpenTasks) }, + onToggled = { toggled -> + if (toggled) installApp(TaskProvider.ProviderName.OpenTasks.packageName) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + Checkbox( + checked = !showAgain, + onCheckedChange = { onSetShowAgain(!it) } + ) + Text( + text = stringResource(R.string.intro_tasks_dont_show), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .clickable { onSetShowAgain(!showAgain) } + ) + } + } + + Text( + text = stringResource( + R.string.intro_leave_unchecked, + stringResource(R.string.app_settings_reset_hints) + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun TasksCard_Preview() { + AppTheme { + TasksCard( + jtxSelected = true, + jtxInstalled = true, + tasksOrgSelected = false, + tasksOrgInstalled = false, + openTasksSelected = false, + openTasksInstalled = false, + showAgain = true + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt new file mode 100644 index 0000000..4b1038a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/UiUtils.kt @@ -0,0 +1,141 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.Typeface +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.Icon +import android.os.Build +import android.text.Spanned +import android.text.style.StyleSpan +import android.text.style.URLSpan +import androidx.annotation.DrawableRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.browser.customtabs.CustomTabsClient +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.core.content.getSystemService +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.text.getSpans +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.util.logging.Level +import java.util.logging.Logger + +object UiUtils { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface UiUtilsEntryPoint { + fun logger(): Logger + fun settingsManager(): SettingsManager + } + + const val SHORTCUT_SYNC_ALL = "syncAllAccounts" + + + @Composable + fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter { + val context = LocalContext.current + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val adaptiveIcon = ResourcesCompat.getDrawable(context.resources, id, null) as? AdaptiveIconDrawable + if (adaptiveIcon != null) + BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap()) + else + painterResource(id) + } else + painterResource(id) + } + + fun haveCustomTabs(context: Context) = CustomTabsClient.getPackageName(context, null, false) != null + + fun updateTheme(context: Context) { + val settings = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).settingsManager() + val mode = settings.getIntOrNull(Settings.PREFERRED_THEME) ?: Settings.PREFERRED_THEME_DEFAULT + AppCompatDelegate.setDefaultNightMode(mode) + } + + fun updateShortcuts(context: Context) { + if (Build.VERSION.SDK_INT >= 25) + context.getSystemService()?.let { shortcutManager -> + try { + shortcutManager.dynamicShortcuts = listOf( + ShortcutInfo.Builder(context, SHORTCUT_SYNC_ALL) + .setIcon(Icon.createWithResource(context, R.drawable.ic_sync_shortcut)) + .setShortLabel(context.getString(R.string.accounts_sync_all)) + .setIntent(Intent(Intent.ACTION_SYNC, null, context, AccountsActivity::class.java)) + .build() + ) + } catch(e: Exception) { + val logger = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).logger() + logger.log(Level.WARNING, "Couldn't update dynamic shortcut(s)", e) + } + } + } + + @Composable + fun Spanned.toAnnotatedString() = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + + for (span in getSpans(0, spanned.length)) { + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> + when (span.style) { + Typeface.BOLD -> addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, end = end + ) + Typeface.ITALIC -> addStyle( + SpanStyle(fontStyle = FontStyle.Italic), + start = start, end = end + ) + } + is URLSpan -> { + addLink( + LinkAnnotation.Url(span.url), + start = start, end = end + ) + addStyle( + SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ), + start = start, end = end + ) + } + else -> { + val context = LocalContext.current + val logger = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).logger() + logger.warning("Ignoring unknown span type ${span.javaClass.name}") + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt new file mode 100644 index 0000000..2ba4659 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt @@ -0,0 +1,81 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import AccountScreen +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.IntentCompat +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AccountsActivity +import dagger.hilt.android.AndroidEntryPoint +import java.util.logging.Logger +import javax.inject.Inject + +@AndroidEntryPoint +class AccountActivity : AppCompatActivity() { + + @Inject + lateinit var logger: Logger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val account = + IntentCompat.getParcelableExtra(intent, EXTRA_ACCOUNT, Account::class.java) ?: + intent.getStringExtra(EXTRA_ACCOUNT)?.let { Account(it, getString(R.string.account_type)) } + + // If account is not passed, log warning and redirect to accounts overview + if (account == null) { + logger.warning("AccountActivity requires EXTRA_ACCOUNT") + + // Redirect to accounts overview activity + val intent = Intent(this, AccountsActivity::class.java).apply { + // Create a new root activity, do not allow going back. + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + finish() + return + } + + setContent { + AccountScreen( + account = account, + onAccountSettings = { + val intent = Intent(this, AccountSettingsActivity::class.java) + intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + startActivity(intent, null) + }, + onCreateAddressBook = { + val intent = Intent(this, CreateAddressBookActivity::class.java) + intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account) + startActivity(intent) + }, + onCreateCalendar = { + val intent = Intent(this, CreateCalendarActivity::class.java) + intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account) + startActivity(intent) + }, + onCollectionDetails = { collection -> + val intent = Intent(this, CollectionActivity::class.java) + intent.putExtra(CollectionActivity.EXTRA_ACCOUNT, account) + intent.putExtra(CollectionActivity.EXTRA_COLLECTION_ID, collection.id) + startActivity(intent, null) + }, + onNavUp = ::onSupportNavigateUp, + onFinish = ::finish + ) + } + } + + companion object { + const val EXTRA_ACCOUNT = "account" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt new file mode 100644 index 0000000..504383e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt @@ -0,0 +1,32 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue + +/** Tri-state enum to represent active / pending / idle status */ +enum class AccountProgress { + Active, // syncing or refreshing + Pending, // sync pending + Idle; // idle + + @Composable + fun rememberAlpha(): Float { + val progressAlpha by animateFloatAsState( + when (this@AccountProgress) { + Active -> 1f + Pending -> 0.5f + Idle -> 0f + }, + label = "progressAlpha", + animationSpec = tween(500) + ) + return progressAlpha + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt new file mode 100644 index 0000000..240ce36 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt @@ -0,0 +1,86 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Context +import androidx.work.WorkInfo +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration +import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class AccountProgressUseCase @Inject constructor( + @ApplicationContext val context: Context, + private val syncFramework: SyncFrameworkIntegration, + private val syncWorkerManager: SyncWorkerManager +) { + + /** + * Returns the current sync state of the account. + */ + operator fun invoke( + account: Account, + serviceFlow: Flow, + dataTypes: Iterable + ): Flow { + val serviceRefreshing = isServiceRefreshing(serviceFlow) + val syncEnqueued = isSyncEnqueued(account, dataTypes) + val syncPending = syncFramework.isSyncPending(account, dataTypes) + val syncRunning = isSyncRunning(account, dataTypes) + + return combine( + serviceRefreshing, + syncEnqueued, + syncPending, + syncRunning + ) { refreshing, enqueued, pending, syncing -> + when { + refreshing || syncing -> AccountProgress.Active + enqueued || pending -> AccountProgress.Pending + else -> AccountProgress.Idle + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun isServiceRefreshing(serviceFlow: Flow): Flow = + serviceFlow.flatMapLatest { service -> + if (service == null) + flowOf(false) + else + RefreshCollectionsWorker.existsFlow(context, RefreshCollectionsWorker.workerName(service.id)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun isSyncEnqueued(account: Account, dataTypes: Iterable): Flow = + syncWorkerManager.hasAnyFlow( + workStates = listOf(WorkInfo.State.ENQUEUED), + account = account, + dataTypes = dataTypes, + whichTag = { _, authority -> + // we are only interested in enqueued OneTimeSyncWorkers because there's always an enqueued PeriodicSyncWorker + OneTimeSyncWorker.workerName(account, authority) + } + ) + + @OptIn(ExperimentalCoroutinesApi::class) + fun isSyncRunning(account: Account, dataTypes: Iterable): Flow = + syncWorkerManager.hasAnyFlow( + workStates = listOf(WorkInfo.State.RUNNING), + account = account, + dataTypes = dataTypes + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt new file mode 100644 index 0000000..d3f795d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt @@ -0,0 +1,774 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +import android.Manifest +import android.accounts.Account +import android.content.Intent +import android.widget.Toast +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.outlined.RuleFolder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.PermissionsActivity +import at.bitfire.davdroid.ui.account.AccountProgress +import at.bitfire.davdroid.ui.account.AccountScreenModel +import at.bitfire.davdroid.ui.account.CollectionsList +import at.bitfire.davdroid.ui.account.RenameAccountDialog +import at.bitfire.davdroid.ui.composable.ActionCard +import at.bitfire.davdroid.ui.composable.ProgressBar +import at.bitfire.ical4android.TaskProvider +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun AccountScreen( + account: Account, + onAccountSettings: () -> Unit, + onCreateAddressBook: () -> Unit, + onCreateCalendar: () -> Unit, + onCollectionDetails: (Collection) -> Unit, + onNavUp: () -> Unit, + onFinish: () -> Unit +) { + val model: AccountScreenModel = hiltViewModel( + creationCallback = { factory: AccountScreenModel.Factory -> + factory.create(account) + } + ) + + val cardDavService by model.cardDavSvc.collectAsStateWithLifecycle() + val addressBooks = model.addressBooks.collectAsLazyPagingItems() + + val calDavService by model.calDavSvc.collectAsStateWithLifecycle() + val calendars = model.calendars.collectAsLazyPagingItems() + val currentTasksApp by model.tasksProvider.collectAsStateWithLifecycle(null) + val subscriptions = model.subscriptions.collectAsLazyPagingItems() + + val context = LocalContext.current + AccountScreen( + accountName = account.name, + error = model.error, + onResetError = model::resetError, + invalidAccount = model.invalidAccount.collectAsStateWithLifecycle(false).value, + showOnlyPersonal = model.showOnlyPersonal.collectAsStateWithLifecycle().value, + showOnlyPersonalLocked = model.showOnlyPersonalLocked.collectAsStateWithLifecycle().value, + onSetShowOnlyPersonal = model::setShowOnlyPersonal, + hasCardDav = cardDavService != null, + canCreateAddressBook = model.canCreateAddressBook.collectAsStateWithLifecycle(false).value, + cardDavProgress = model.cardDavProgress.collectAsStateWithLifecycle(AccountProgress.Idle).value, + addressBooks = addressBooks, + hasCalDav = calDavService != null, + canCreateCalendar = model.canCreateCalendar.collectAsStateWithLifecycle(false).value, + calDavProgress = model.calDavProgress.collectAsStateWithLifecycle(AccountProgress.Idle).value, + calendars = calendars, + currentTasksProvider = currentTasksApp, + hasWebcal = subscriptions.itemCount != 0, + subscriptions = subscriptions, + onUpdateCollectionSync = model::setCollectionSync, + onSubscribe = { collection -> + // subscribe + var uri = collection.source.toString().toUri() + when { + uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build() + uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build() + } + + val intent = Intent(Intent.ACTION_VIEW, uri) + collection.displayName?.let { intent.putExtra("title", it) } + collection.color?.let { intent.putExtra("color", it) } + + if (context.packageManager.resolveActivity(intent, 0) != null) + context.startActivity(intent) + else + model.noWebcalApp() + }, + onCollectionDetails = onCollectionDetails, + showNoWebcalApp = model.showNoWebcalApp, + resetShowNoWebcalApp = model::resetShowNoWebcalApp, + onRefreshCollections = model::refreshCollections, + onSync = model::sync, + onAccountSettings = onAccountSettings, + onCreateAddressBook = onCreateAddressBook, + onCreateCalendar = onCreateCalendar, + onRenameAccount = model::renameAccount, + onDeleteAccount = model::deleteAccount, + onNavUp = onNavUp, + onFinish = onFinish + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun AccountScreen( + accountName: String, + error: String? = null, + onResetError: () -> Unit = {}, + invalidAccount: Boolean = false, + showOnlyPersonal: Boolean = false, + showOnlyPersonalLocked: Boolean = false, + onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit = {}, + hasCardDav: Boolean, + canCreateAddressBook: Boolean, + cardDavProgress: AccountProgress, + addressBooks: LazyPagingItems?, + hasCalDav: Boolean, + canCreateCalendar: Boolean, + calDavProgress: AccountProgress, + calendars: LazyPagingItems?, + currentTasksProvider: TaskProvider.ProviderName?, + hasWebcal: Boolean, + subscriptions: LazyPagingItems?, + onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> }, + onSubscribe: (Collection) -> Unit = {}, + onCollectionDetails: (Collection) -> Unit = {}, + showNoWebcalApp: Boolean = false, + resetShowNoWebcalApp: () -> Unit = {}, + onRefreshCollections: () -> Unit = {}, + onSync: () -> Unit = {}, + onAccountSettings: () -> Unit = {}, + onCreateAddressBook: () -> Unit = {}, + onCreateCalendar: () -> Unit = {}, + onRenameAccount: (newName: String) -> Unit = {}, + onDeleteAccount: () -> Unit = {}, + onNavUp: () -> Unit = {}, + onFinish: () -> Unit = {} +) { + AppTheme { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(invalidAccount) { + if (invalidAccount) { + Toast.makeText(context, R.string.account_invalid_account, Toast.LENGTH_LONG).show() + onFinish() + } + } + + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(error) { + if (error != null) + scope.launch { + snackbarHostState.showSnackbar(error) + onResetError() + } + } + + var isRefreshing by remember { mutableStateOf(false) } + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(300) + isRefreshing = false + } + } + + // tabs calculation + var nextIdx = -1 + + @Suppress("KotlinConstantConditions") + val idxCalDav: Int? = if (hasCalDav) ++nextIdx else null + val idxCardDav: Int? = if (hasCardDav) ++nextIdx else null + val idxWebcal: Int? = if (hasWebcal) ++nextIdx else null + val nrPages = + (if (idxCalDav != null) 1 else 0) + + (if (idxCardDav != null) 1 else 0) + + (if (idxWebcal != null) 1 else 0) + val pagerState = rememberPagerState(pageCount = { nrPages }) + + val calDavScrollState = rememberLazyListState() + val cardDavScrollState = rememberLazyListState() + val webcalScrollState = rememberLazyListState() + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + }, + title = { + Text( + text = accountName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + AccountScreen_Actions( + accountName = accountName, + canCreateAddressBook = canCreateAddressBook, + onCreateAddressBook = onCreateAddressBook, + canCreateCalendar = canCreateCalendar, + onCreateCalendar = onCreateCalendar, + showOnlyPersonal = showOnlyPersonal, + showOnlyPersonalLocked = showOnlyPersonalLocked, + onSetShowOnlyPersonal = onSetShowOnlyPersonal, + currentPage = pagerState.currentPage, + idxCardDav = idxCardDav, + idxCalDav = idxCalDav, + onRenameAccount = onRenameAccount, + onDeleteAccount = onDeleteAccount, + onAccountSettings = onAccountSettings + ) + } + ) + }, + floatingActionButton = { + Column(horizontalAlignment = Alignment.End) { + ExtendedFloatingActionButton( + text = { + Text(stringResource(R.string.account_refresh_collections)) + }, + icon = { + Icon(Icons.Outlined.RuleFolder, stringResource(R.string.account_refresh_collections)) + }, + onClick = onRefreshCollections, + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (pagerState.currentPage == idxCardDav || pagerState.currentPage == idxCalDav) + ExtendedFloatingActionButton( + text = { + Text(stringResource(R.string.account_synchronize_now)) + }, + icon = { + Icon(Icons.Default.Sync, stringResource(R.string.account_synchronize_now)) + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onSync + ) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { padding -> + Column( + modifier = Modifier.padding(padding) + ) { + if (nrPages > 0) { + SharedTransitionLayout { + val idxCurrentPage = pagerState.currentPage + + // The icon shall be shown when the scroll state is at the top (= we can't scroll backward) + val currentPageScrollState = when (idxCurrentPage) { + idxCalDav -> calDavScrollState + idxCardDav -> cardDavScrollState + idxWebcal -> webcalScrollState + else -> null + } + AnimatedContent( + targetState = currentPageScrollState?.canScrollBackward != true + ) { showIcon -> + PrimaryTabRow(selectedTabIndex = idxCurrentPage) { + if (idxCalDav != null) + AccountScreen_Tab( + selected = idxCurrentPage == idxCalDav, + showIcon = showIcon, + icon = Icons.Default.CalendarToday, + text = stringResource(R.string.account_caldav), + animatedVisibilityScope = this@AnimatedContent, + sharedTransitionScope = this@SharedTransitionLayout, + ) { + scope.launch { + pagerState.scrollToPage(idxCalDav) + } + } + + if (idxCardDav != null) + AccountScreen_Tab( + selected = idxCurrentPage == idxCardDav, + showIcon = showIcon, + icon = Icons.Default.Group, + text = stringResource(R.string.account_carddav), + animatedVisibilityScope = this@AnimatedContent, + sharedTransitionScope = this@SharedTransitionLayout, + ) { + scope.launch { + pagerState.scrollToPage(idxCardDav) + } + } + + if (idxWebcal != null) + AccountScreen_Tab( + selected = idxCurrentPage == idxWebcal, + showIcon = showIcon, + icon = Icons.Default.Link, + text = stringResource(R.string.account_webcal), + animatedVisibilityScope = this@AnimatedContent, + sharedTransitionScope = this@SharedTransitionLayout, + ) { + scope.launch { + pagerState.scrollToPage(idxWebcal) + } + } + } + } + } + + HorizontalPager( + pagerState, + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { index -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { isRefreshing = true; onSync() } + ) { + when (index) { + idxCardDav -> + AccountScreen_ServiceTab( + requiredPermissions = listOf(Manifest.permission.WRITE_CONTACTS), + progress = cardDavProgress, + collections = addressBooks, + onUpdateCollectionSync = onUpdateCollectionSync, + onCollectionDetails = onCollectionDetails, + state = cardDavScrollState + ) + + idxCalDav -> { + val permissions = mutableListOf(Manifest.permission.WRITE_CALENDAR) + if (currentTasksProvider != null) + permissions += currentTasksProvider.permissions + AccountScreen_ServiceTab( + requiredPermissions = permissions, + progress = calDavProgress, + collections = calendars, + onUpdateCollectionSync = onUpdateCollectionSync, + onCollectionDetails = onCollectionDetails, + state = calDavScrollState + ) + } + + idxWebcal -> { + LaunchedEffect(showNoWebcalApp) { + if (showNoWebcalApp) { + if (snackbarHostState.showSnackbar( + message = context.getString(R.string.account_no_webcal_handler_found), + actionLabel = context.getString(R.string.account_install_icsx5), + duration = SnackbarDuration.Long + ) == SnackbarResult.ActionPerformed + ) { + val installIntent = Intent( + Intent.ACTION_VIEW, + "market://details?id=at.bitfire.icsdroid".toUri() + ) + if (context.packageManager.resolveActivity(installIntent, 0) != null) + context.startActivity(installIntent) + } + resetShowNoWebcalApp() + } + } + + Column { + Text( + stringResource(R.string.account_webcal_external_app), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) + ) + + AccountScreen_ServiceTab( + requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR), + progress = calDavProgress, + collections = subscriptions, + onSubscribe = onSubscribe, + state = webcalScrollState + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +@OptIn(ExperimentalSharedTransitionApi::class) +fun AccountScreen_Tab( + selected: Boolean, + showIcon: Boolean, + icon: ImageVector, + text: String, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + onClick: () -> Unit, +) { + with(sharedTransitionScope) { + if (showIcon) { + Tab( + selected = selected, + onClick = onClick, + icon = { Icon(imageVector = icon, contentDescription = text) }, + text = { + Text( + text, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(key = text), + animatedVisibilityScope = animatedVisibilityScope, + ) + .padding(8.dp) + ) + } + ) + } else { + Tab( + selected = selected, + onClick = onClick, + content = { + Text( + text, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(key = text), + animatedVisibilityScope = animatedVisibilityScope, + ) + .padding(8.dp) + ) + } + ) + } + } +} + +@Composable +fun AccountScreen_Actions( + accountName: String, + canCreateAddressBook: Boolean, + onCreateAddressBook: () -> Unit, + canCreateCalendar: Boolean, + onCreateCalendar: () -> Unit, + showOnlyPersonal: Boolean, + showOnlyPersonalLocked: Boolean, + onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit, + currentPage: Int, + idxCardDav: Int?, + idxCalDav: Int?, + onRenameAccount: (newName: String) -> Unit, + onDeleteAccount: () -> Unit, + onAccountSettings: () -> Unit +) { + var showDeleteAccountDialog by remember { mutableStateOf(false) } + var showRenameAccountDialog by remember { mutableStateOf(false) } + + var overflowOpen by remember { mutableStateOf(false) } + IconButton(onClick = onAccountSettings) { + Icon(Icons.Default.Settings, stringResource(R.string.account_settings)) + } + IconButton(onClick = { overflowOpen = !overflowOpen }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu)) + } + DropdownMenu( + expanded = overflowOpen, + onDismissRequest = { overflowOpen = false } + ) { + // TAB-SPECIFIC ACTIONS + + // create collection + if (currentPage == idxCardDav && canCreateAddressBook) { + // create address book + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.CreateNewFolder, + contentDescription = stringResource(R.string.create_addressbook), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.create_addressbook)) + }, + onClick = { + onCreateAddressBook() + overflowOpen = false + } + ) + } else if (currentPage == idxCalDav && canCreateCalendar) { + // create calendar + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.CreateNewFolder, + contentDescription = stringResource(R.string.create_calendar), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.create_calendar)) + }, + onClick = { + onCreateCalendar() + overflowOpen = false + } + ) + } + + // GENERAL ACTIONS + + // show only personal + DropdownMenuItem( + leadingIcon = { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides Dp.Unspecified + ) { + Checkbox( + checked = showOnlyPersonal, + enabled = !showOnlyPersonalLocked, + onCheckedChange = { + onSetShowOnlyPersonal(it) + overflowOpen = false + }, + modifier = Modifier.padding(end = 8.dp) + ) + } + }, + text = { + Text(stringResource(R.string.account_only_personal)) + }, + onClick = { + onSetShowOnlyPersonal(!showOnlyPersonal) + overflowOpen = false + }, + enabled = !showOnlyPersonalLocked + ) + + // rename account + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = stringResource(R.string.account_rename), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.account_rename)) + }, + onClick = { + showRenameAccountDialog = true + overflowOpen = false + } + ) + + // delete account + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.account_delete), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.account_delete)) + }, + onClick = { + showDeleteAccountDialog = true + overflowOpen = false + } + ) + } + + // modal dialogs + if (showRenameAccountDialog) + RenameAccountDialog( + oldName = accountName, + onRenameAccount = { newName -> + onRenameAccount(newName) + showRenameAccountDialog = false + }, + onDismiss = { showRenameAccountDialog = false } + ) + if (showDeleteAccountDialog) + DeleteAccountDialog( + onConfirm = onDeleteAccount, + onDismiss = { showDeleteAccountDialog = false } + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun AccountScreen_ServiceTab( + requiredPermissions: List, + progress: AccountProgress, + collections: LazyPagingItems?, + onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> }, + onSubscribe: (Collection) -> Unit = {}, + onCollectionDetails: ((Collection) -> Unit)? = null, + state: LazyListState = rememberLazyListState() +) { + val context = LocalContext.current + + Column { + // progress indicator + val progressAlpha = progress.rememberAlpha() + when (progress) { + AccountProgress.Active -> ProgressBar( + modifier = Modifier + .alpha(progressAlpha) + .fillMaxWidth() + ) + AccountProgress.Pending, + AccountProgress.Idle -> ProgressBar( + progress = { 1f }, + modifier = Modifier + .alpha(progressAlpha) + .fillMaxWidth() + ) + } + + // permissions warning + if (!LocalInspectionMode.current) { + val permissionsState = rememberMultiplePermissionsState(requiredPermissions) + if (!permissionsState.allPermissionsGranted) + ActionCard( + icon = Icons.Default.SyncProblem, + actionText = stringResource(R.string.account_manage_permissions), + onAction = { + val intent = Intent(context, PermissionsActivity::class.java) + context.startActivity(intent) + }, + modifier = Modifier.padding(8.dp) + ) { + Text(stringResource(R.string.account_missing_permissions)) + } + + // collection list + if (collections != null) + CollectionsList( + collections, + onChangeSync = onUpdateCollectionSync, + onSubscribe = onSubscribe, + onCollectionDetails = onCollectionDetails, + modifier = Modifier.weight(1f), + state = state + ) + } + } +} + +@Preview +@Composable +fun AccountScreen_Preview() { + AccountScreen( + accountName = "test@example.com", + showOnlyPersonal = false, + showOnlyPersonalLocked = true, + hasCardDav = true, + canCreateAddressBook = false, + cardDavProgress = AccountProgress.Active, + addressBooks = null, + hasCalDav = true, + canCreateCalendar = true, + calDavProgress = AccountProgress.Pending, + calendars = null, + currentTasksProvider = TaskProvider.ProviderName.JtxBoard, + hasWebcal = true, + subscriptions = null + ) +} + +@Composable +@Preview +fun DeleteAccountDialog( + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.account_delete_confirmation_title)) }, + text = { Text(stringResource(R.string.account_delete_confirmation_text)) }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt new file mode 100644 index 0000000..9e1607a --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt @@ -0,0 +1,209 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.account.InvalidAccountException +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.logging.Level +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = AccountScreenModel.Factory::class) +class AccountScreenModel @AssistedInject constructor( + @Assisted val account: Account, + private val accountRepository: AccountRepository, + accountProgressUseCase: AccountProgressUseCase, + private val accountSettingsFactory: AccountSettings.Factory, + private val collectionRepository: DavCollectionRepository, + @ApplicationContext val context: Context, + private val collectionSelectedUseCase: Lazy, + getBindableHomesetsFromService: GetBindableHomeSetsFromServiceUseCase, + getServiceCollectionPager: GetServiceCollectionPagerUseCase, + private val logger: Logger, + serviceRepository: DavServiceRepository, + private val syncWorkerManager: SyncWorkerManager, + tasksAppManager: TasksAppManager +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): AccountScreenModel + } + + /** + * Only acquire account settings on a worker thread! + */ + private val accountSettings: AccountSettings? by lazy { + try { + accountSettingsFactory.create(account) + } catch (_: InvalidAccountException) { + null + } + } + + /** whether the account is invalid and the screen shall be closed */ + val invalidAccount = accountRepository.getAllFlow().map { accounts -> + !accounts.contains(account) + } + + /** + * Whether to show only personal collections. + */ + private val _showOnlyPersonal = MutableStateFlow(false) + val showOnlyPersonal = _showOnlyPersonal.asStateFlow() + private suspend fun reloadShowOnlyPersonal() = withContext(Dispatchers.Default) { + accountSettings?.let { + _showOnlyPersonal.value = it.getShowOnlyPersonal() + } + } + fun setShowOnlyPersonal(showOnlyPersonal: Boolean) { + viewModelScope.launch { + accountSettings?.setShowOnlyPersonal(showOnlyPersonal) + reloadShowOnlyPersonal() + } + } + + /** + * Whether the user setting to show only personal collections is locked. + */ + private var _showOnlyPersonalLocked = MutableStateFlow(false) + val showOnlyPersonalLocked = _showOnlyPersonalLocked.asStateFlow() + private suspend fun reloadShowOnlyPersonalLocked() = withContext(Dispatchers.Default) { + accountSettings?.let { + _showOnlyPersonalLocked.value = it.getShowOnlyPersonalLocked() + } + } + + init { + viewModelScope.launch { + reloadShowOnlyPersonal() + reloadShowOnlyPersonalLocked() + } + } + + val cardDavSvc = serviceRepository + .getCardDavServiceFlow(account.name) + .stateIn(viewModelScope, initialValue = null, started = SharingStarted.Eagerly) + private val bindableAddressBookHomesets = getBindableHomesetsFromService(cardDavSvc) + val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets -> + homeSets.isNotEmpty() + } + val cardDavProgress: Flow = accountProgressUseCase( + account = account, + serviceFlow = cardDavSvc, + dataTypes = listOf(SyncDataType.CONTACTS) + ) + val addressBooks = getServiceCollectionPager(cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal) + + val calDavSvc = serviceRepository + .getCalDavServiceFlow(account.name) + .stateIn(viewModelScope, initialValue = null, started = SharingStarted.Eagerly) + private val bindableCalendarHomesets = getBindableHomesetsFromService(calDavSvc) + val canCreateCalendar = bindableCalendarHomesets.map { homeSets -> + homeSets.isNotEmpty() + } + val tasksProvider = tasksAppManager.currentProviderFlow() + val calDavProgress = accountProgressUseCase( + account = account, + serviceFlow = calDavSvc, + dataTypes = listOf(SyncDataType.EVENTS, SyncDataType.TASKS) + ) + val calendars = getServiceCollectionPager(calDavSvc, Collection.TYPE_CALENDAR, showOnlyPersonal) + val subscriptions = getServiceCollectionPager(calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal) + + + var error by mutableStateOf(null) + private set + + fun resetError() { error = null } + + + var showNoWebcalApp by mutableStateOf(false) + private set + + fun noWebcalApp() { showNoWebcalApp = true } + fun resetShowNoWebcalApp() { showNoWebcalApp = false } + + + // actions + + /** Deletes the account from the system (won't touch collections on the server). */ + fun deleteAccount() { + viewModelScope.launch { + accountRepository.delete(account.name) + } + } + + fun refreshCollections() { + cardDavSvc.value?.let { svc -> + RefreshCollectionsWorker.enqueue(context, svc.id) + } + calDavSvc.value?.let { svc -> + RefreshCollectionsWorker.enqueue(context, svc.id) + } + } + + /** + * Renames the [account] to given name. + * + * @param newName new account name + */ + fun renameAccount(newName: String) { + viewModelScope.launch { + try { + accountRepository.rename(account.name, newName) + + // synchronize again + val newAccount = Account(newName, context.getString(R.string.account_type)) + syncWorkerManager.enqueueOneTimeAllAuthorities(newAccount, manual = true) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Couldn't rename account", e) + error = e.localizedMessage + } + } + } + + fun setCollectionSync(id: Long, sync: Boolean) { + viewModelScope.launch { + collectionRepository.setSync(id, sync) + collectionSelectedUseCase.get().handleWithDelay(id) + } + } + + fun sync() { + syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt new file mode 100644 index 0000000..7e09a35 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt @@ -0,0 +1,51 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import androidx.core.content.IntentCompat +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AccountSettingsActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + private val account by lazy { + IntentCompat.getParcelableExtra(intent, EXTRA_ACCOUNT, Account::class.java) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + title = account.name + + setContent { + AccountSettingsScreen( + account = account, + onNavWifiPermissionsScreen = { + val intent = Intent(this, WifiPermissionsActivity::class.java) + intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, account) + startActivity(intent) + }, + onNavUp = ::onSupportNavigateUp, + ) + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt new file mode 100644 index 0000000..7d2344e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt @@ -0,0 +1,290 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.network.OAuthIntegration +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.ResyncType +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import at.bitfire.vcard4android.GroupMethod +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import java.util.logging.Level +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = AccountSettingsModel.Factory::class) +class AccountSettingsModel @AssistedInject constructor( + @Assisted val account: Account, + private val accountSettingsFactory: AccountSettings.Factory, + private val authService: AuthorizationService, + @ApplicationContext val context: Context, + db: AppDatabase, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + private val logger: Logger, + private val settings: SettingsManager, + private val syncWorkerManager: SyncWorkerManager, + private val tasksAppManager: TasksAppManager +): ViewModel(), SettingsManager.OnChangeListener { + + @AssistedFactory + interface Factory { + fun create(account: Account): AccountSettingsModel + } + + // settings + data class UiState( + val status: String? = null, + + val hasContactsSync: Boolean = false, + val syncIntervalContacts: Long? = null, + val hasCalendarsSync: Boolean = false, + val syncIntervalCalendars: Long? = null, + val hasTasksSync: Boolean = false, + val syncIntervalTasks: Long? = null, + + val syncWifiOnly: Boolean = false, + val syncWifiOnlySSIDs: List? = null, + val ignoreVpns: Boolean = false, + + val credentials: Credentials = Credentials(), + val allowCredentialsChange: Boolean = true, + + val timeRangePastDays: Int? = null, + val defaultAlarmMinBefore: Int? = null, + val manageCalendarColors: Boolean = false, + val eventColors: Boolean = false, + + val contactGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val serviceDao = db.serviceDao() + private val tasksProvider + get() = tasksAppManager.currentProvider() + + /** + * Only acquire account settings on a worker thread! + */ + private val accountSettings by lazy { accountSettingsFactory.create(account) } + + + init { + settings.addOnChangeListener(this) + viewModelScope.launch { + reload() + } + } + + override fun onCleared() { + authService.dispose() + settings.removeOnChangeListener(this) + } + + override fun onSettingsChanged() { + viewModelScope.launch { + reload() + } + } + + private suspend fun reload() = withContext(defaultDispatcher) { + val hasContactsSync = serviceDao.getByAccountAndType(account.name, Service.TYPE_CARDDAV) != null + val hasCalendarSync = serviceDao.getByAccountAndType(account.name, Service.TYPE_CALDAV) != null + val hasTasksSync = hasCalendarSync && tasksProvider != null + + _uiState.value = UiState( + hasContactsSync = hasContactsSync, + syncIntervalContacts = accountSettings.getSyncInterval(SyncDataType.CONTACTS), + hasCalendarsSync = hasCalendarSync, + syncIntervalCalendars = accountSettings.getSyncInterval(SyncDataType.EVENTS), + hasTasksSync = hasTasksSync, + syncIntervalTasks = accountSettings.getSyncInterval(SyncDataType.TASKS), + + syncWifiOnly = accountSettings.getSyncWifiOnly(), + syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs(), + ignoreVpns = accountSettings.getIgnoreVpns(), + + credentials = accountSettings.credentials(), + allowCredentialsChange = accountSettings.changingCredentialsAllowed(), + + timeRangePastDays = accountSettings.getTimeRangePastDays(), + defaultAlarmMinBefore = accountSettings.getDefaultAlarm(), + manageCalendarColors = accountSettings.getManageCalendarColors(), + eventColors = accountSettings.getEventColors(), + + contactGroupMethod = accountSettings.getGroupMethod(), + ) + } + + + fun updateContactsSyncInterval(syncInterval: Long) { + CoroutineScope(defaultDispatcher).launch { + accountSettings.setSyncInterval(SyncDataType.CONTACTS, syncInterval.takeUnless { it == -1L }) + reload() + } + } + + fun updateCalendarSyncInterval(syncInterval: Long) { + CoroutineScope(defaultDispatcher).launch { + accountSettings.setSyncInterval(SyncDataType.EVENTS, syncInterval.takeUnless { it == -1L }) + reload() + } + } + + fun updateTasksSyncInterval(syncInterval: Long) { + CoroutineScope(defaultDispatcher).launch { + accountSettings.setSyncInterval(SyncDataType.TASKS, syncInterval.takeUnless { it == -1L }) + reload() + } + } + + fun updateSyncWifiOnly(wifiOnly: Boolean) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setSyncWiFiOnly(wifiOnly) + reload() + } + + fun updateSyncWifiOnlySSIDs(ssids: List?) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setSyncWifiOnlySSIDs(ssids) + reload() + } + + fun updateIgnoreVpns(ignoreVpns: Boolean) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setIgnoreVpns(ignoreVpns) + reload() + } + + + fun authorizationContract() = OAuthIntegration.AuthorizationContract(authService) + + fun newAuthorizationRequest(): AuthorizationRequest? = + accountSettings.credentials().authState?.lastAuthorizationResponse?.request + + fun authenticate(authResponse: AuthorizationResponse) { + CoroutineScope(defaultDispatcher).launch { + try { + // save new credentials + val authState = OAuthIntegration.authenticate(authService, authResponse) + accountSettings.updateAuthState(authState) + + _uiState.update { + it.copy(status = context.getString(R.string.settings_reauthorize_oauth_success)) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Authentication failed", e) + _uiState.update { + it.copy(status = e.localizedMessage) + } + } + } + } + + fun authCodeFailed() { + _uiState.update { + it.copy(status = context.getString(R.string.login_oauth_couldnt_obtain_auth_code)) + } + } + + fun updateCredentials(credentials: Credentials) = CoroutineScope(defaultDispatcher).launch { + accountSettings.credentials(credentials) + reload() + } + + + fun updateTimeRangePastDays(days: Int?) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setTimeRangePastDays(days) + reload() + + /* If the new setting is a certain number of days, no full resync is required, + because every sync will cause a REPORT calendar-query with the given number of days. + However, if the new setting is "all events", collection sync may/should be used, so + the last sync-token has to be reset, which is done by setting fullResync=true. + */ + resyncCalendars( + resync = if (days == null) ResyncType.RESYNC_ENTRIES else ResyncType.RESYNC_LIST, + tasks = false + ) + } + + fun updateDefaultAlarm(minBefore: Int?) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setDefaultAlarm(minBefore) + reload() + + resyncCalendars(resync = ResyncType.RESYNC_ENTRIES, tasks = false) + } + + fun updateManageCalendarColors(manage: Boolean) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setManageCalendarColors(manage) + reload() + + resyncCalendars(resync = ResyncType.RESYNC_LIST, tasks = true) + } + + fun updateEventColors(manageColors: Boolean) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setEventColors(manageColors) + reload() + + resyncCalendars(resync = ResyncType.RESYNC_ENTRIES, tasks = false) + } + + + fun updateContactGroupMethod(groupMethod: GroupMethod) = CoroutineScope(defaultDispatcher).launch { + accountSettings.setGroupMethod(groupMethod) + reload() + + resync(SyncDataType.CONTACTS, ResyncType.RESYNC_ENTRIES) + } + + /** + * Initiates calendar re-synchronization. + * + * @param resync whether only the list of entries (resync) or also all entries + * themselves (full resync) shall be downloaded again + * @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks) + */ + private fun resyncCalendars(resync: ResyncType, tasks: Boolean) { + resync(SyncDataType.EVENTS, resync) + if (tasks) + resync(SyncDataType.TASKS, resync) + } + + /** + * Initiates re-synchronization for given authority. + * + * @param dataType type of data to synchronize + * @param resync whether only the list of entries (resync) or also all entries + * themselves (full resync) shall be downloaded again + */ + private fun resync(dataType: SyncDataType, resync: ResyncType) { + syncWorkerManager.enqueueOneTime(account, dataType = dataType, resync = resync) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt new file mode 100644 index 0000000..d25be3c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt @@ -0,0 +1,804 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.app.Activity +import android.security.KeyChain +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Contacts +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Task +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.AccountSettings.Companion.SYNC_INTERVAL_MANUALLY +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.composable.ActionCard +import at.bitfire.davdroid.ui.composable.EditTextInputDialog +import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog +import at.bitfire.davdroid.ui.composable.Setting +import at.bitfire.davdroid.ui.composable.SettingsHeader +import at.bitfire.davdroid.ui.composable.SwitchSetting +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import at.bitfire.vcard4android.GroupMethod +import kotlinx.coroutines.launch + +@Composable +fun AccountSettingsScreen( + onNavUp: () -> Unit, + account: Account, + onNavWifiPermissionsScreen: () -> Unit +) { + val model = hiltViewModel { factory: AccountSettingsModel.Factory -> + factory.create(account) + } + val uiState by model.uiState.collectAsState() + val canAccessWifiSsid by PermissionUtils.rememberCanAccessWifiSsid() + + // contract to open the browser for re-authentication + val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> + if (authResponse != null) + model.authenticate(authResponse) + else + model.authCodeFailed() + } + + AppTheme { + AccountSettingsScreen( + accountName = account.name, + onNavUp = onNavUp, + status = uiState.status, + + // Sync settings + canAccessWifiSsid = canAccessWifiSsid, + onSyncWifiOnlyPermissionsAction = onNavWifiPermissionsScreen, + hasContactsSync = uiState.hasContactsSync, + contactsSyncInterval = uiState.syncIntervalContacts, + onUpdateContactsSyncInterval = model::updateContactsSyncInterval, + hasCalendarsSync = uiState.hasCalendarsSync, + calendarSyncInterval = uiState.syncIntervalCalendars, + onUpdateCalendarSyncInterval = model::updateCalendarSyncInterval, + hasTasksSync = uiState.hasTasksSync, + tasksSyncInterval = uiState.syncIntervalTasks, + onUpdateTasksSyncInterval = model::updateTasksSyncInterval, + syncOnlyOnWifi = uiState.syncWifiOnly, + onUpdateSyncOnlyOnWifi = model::updateSyncWifiOnly, + onlyOnSsids = uiState.syncWifiOnlySSIDs, + onUpdateOnlyOnSsids = model::updateSyncWifiOnlySSIDs, + ignoreVpns = uiState.ignoreVpns, + onUpdateIgnoreVpns = model::updateIgnoreVpns, + + // Authentication Settings + credentials = uiState.credentials, + onUpdateCredentials = model::updateCredentials, + onAuthenticateOAuth = { + val request = model.newAuthorizationRequest() + if (request != null) + authRequestContract.launch(request) + }, + isCredentialsUpdateAllowed = uiState.allowCredentialsChange, + + // CalDav Settings + timeRangePastDays = uiState.timeRangePastDays, + onUpdateTimeRangePastDays = model::updateTimeRangePastDays, + defaultAlarmMinBefore = uiState.defaultAlarmMinBefore, + onUpdateDefaultAlarmMinBefore = model::updateDefaultAlarm, + manageCalendarColors = uiState.manageCalendarColors, + onUpdateManageCalendarColors = model::updateManageCalendarColors, + eventColors = uiState.eventColors, + onUpdateEventColors = model::updateEventColors, + + // CardDav Settings + contactGroupMethod = uiState.contactGroupMethod, + onUpdateContactGroupMethod = model::updateContactGroupMethod, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSettingsScreen( + onNavUp: () -> Unit, + accountName: String, + status: String? = null, + + // Sync settings + canAccessWifiSsid: Boolean, + onSyncWifiOnlyPermissionsAction: () -> Unit, + hasContactsSync: Boolean, + contactsSyncInterval: Long?, + onUpdateContactsSyncInterval: ((Long) -> Unit) = {}, + hasCalendarsSync: Boolean, + calendarSyncInterval: Long?, + onUpdateCalendarSyncInterval: ((Long) -> Unit) = {}, + hasTasksSync: Boolean, + tasksSyncInterval: Long?, + onUpdateTasksSyncInterval: ((Long) -> Unit) = {}, + syncOnlyOnWifi: Boolean, + onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {}, + onlyOnSsids: List?, + onUpdateOnlyOnSsids: (List) -> Unit = {}, + ignoreVpns: Boolean, + onUpdateIgnoreVpns: (Boolean) -> Unit = {}, + + // Authentication Settings + credentials: Credentials?, + onUpdateCredentials: (Credentials) -> Unit = {}, + onAuthenticateOAuth: () -> Unit = {}, + isCredentialsUpdateAllowed: Boolean, + + // CalDav Settings + timeRangePastDays: Int?, + onUpdateTimeRangePastDays: (Int?) -> Unit = {}, + defaultAlarmMinBefore: Int?, + onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {}, + manageCalendarColors: Boolean, + onUpdateManageCalendarColors: (Boolean) -> Unit = {}, + eventColors: Boolean, + onUpdateEventColors: (Boolean) -> Unit = {}, + + // CardDav Settings + contactGroupMethod: GroupMethod, + onUpdateContactGroupMethod: (GroupMethod) -> Unit = {}, +) { + val uriHandler = LocalUriHandler.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(status) { + if (status != null) + snackbarHostState.showSnackbar(status) + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.navigate_up) + ) + } + }, + title = { Text(accountName) }, + actions = { + IconButton(onClick = { + val settingsUri = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_SETTINGS) + .fragment(ExternalUris.Manual.FRAGMENT_ACCOUNT_SETTINGS) + .build() + uriHandler.openUri(settingsUri.toString()) + }) { + Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help)) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + AccountSettings_FromModel( + snackbarHostState = snackbarHostState, + + // Sync settings + canAccessWifiSsid = canAccessWifiSsid, + onSyncWifiOnlyPermissionsAction = onSyncWifiOnlyPermissionsAction, + hasContactsSync = hasContactsSync, + contactsSyncInterval = contactsSyncInterval, + onUpdateContactsSyncInterval = onUpdateContactsSyncInterval, + hasCalendarsSync = hasCalendarsSync, + calendarSyncInterval = calendarSyncInterval, + onUpdateCalendarSyncInterval = onUpdateCalendarSyncInterval, + hasTasksSync = hasTasksSync, + taskSyncInterval = tasksSyncInterval, + onUpdateTaskSyncInterval = onUpdateTasksSyncInterval, + syncOnlyOnWifi = syncOnlyOnWifi, + onUpdateSyncOnlyOnWifi = onUpdateSyncOnlyOnWifi, + onlyOnSsids = onlyOnSsids, + onUpdateOnlyOnSsids = onUpdateOnlyOnSsids, + ignoreVpns = ignoreVpns, + onUpdateIgnoreVpns = onUpdateIgnoreVpns, + + // Authentication Settings + credentials = credentials, + onUpdateCredentials = onUpdateCredentials, + onAuthenticateOAuth = onAuthenticateOAuth, + isCredentialsUpdateAllowed = isCredentialsUpdateAllowed, + + // CalDav Settings + timeRangePastDays = timeRangePastDays, + onUpdateTimeRangePastDays = onUpdateTimeRangePastDays, + defaultAlarmMinBefore = defaultAlarmMinBefore, + onUpdateDefaultAlarmMinBefore = onUpdateDefaultAlarmMinBefore, + manageCalendarColors = manageCalendarColors, + onUpdateManageCalendarColors = onUpdateManageCalendarColors, + eventColors = eventColors, + onUpdateEventColors = onUpdateEventColors, + + // CardDav Settings + contactGroupMethod = contactGroupMethod, + onUpdateContactGroupMethod = onUpdateContactGroupMethod + ) + } + } +} + +@Composable +fun AccountSettings_FromModel( + snackbarHostState: SnackbarHostState, + + // Sync settings + canAccessWifiSsid: Boolean, + onSyncWifiOnlyPermissionsAction: () -> Unit, + hasContactsSync: Boolean, + contactsSyncInterval: Long?, + onUpdateContactsSyncInterval: ((Long) -> Unit) = {}, + hasCalendarsSync: Boolean, + calendarSyncInterval: Long?, + onUpdateCalendarSyncInterval: ((Long) -> Unit) = {}, + hasTasksSync: Boolean, + taskSyncInterval: Long?, + onUpdateTaskSyncInterval: ((Long) -> Unit) = {}, + syncOnlyOnWifi: Boolean, + onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {}, + onlyOnSsids: List?, + onUpdateOnlyOnSsids: (List) -> Unit = {}, + ignoreVpns: Boolean, + onUpdateIgnoreVpns: (Boolean) -> Unit = {}, + + // Authentication Settings + credentials: Credentials?, + onUpdateCredentials: (Credentials) -> Unit = {}, + onAuthenticateOAuth: () -> Unit = {}, + isCredentialsUpdateAllowed: Boolean, + + // CalDav Settings + timeRangePastDays: Int?, + onUpdateTimeRangePastDays: (Int?) -> Unit = {}, + defaultAlarmMinBefore: Int?, + onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {}, + manageCalendarColors: Boolean, + onUpdateManageCalendarColors: (Boolean) -> Unit = {}, + eventColors: Boolean, + onUpdateEventColors: (Boolean) -> Unit = {}, + + // CardDav Settings + contactGroupMethod: GroupMethod, + onUpdateContactGroupMethod: (GroupMethod) -> Unit = {}, +) { + Column(Modifier.padding(8.dp)) { + SyncSettings( + canAccessWifiSsid = canAccessWifiSsid, + onSyncWifiOnlyPermissionsAction = onSyncWifiOnlyPermissionsAction, + hasContactsSync = hasContactsSync, + contactsSyncInterval = contactsSyncInterval, + onUpdateContactsSyncInterval = onUpdateContactsSyncInterval, + hasCalendarsSync = hasCalendarsSync, + calendarSyncInterval = calendarSyncInterval, + onUpdateCalendarSyncInterval = onUpdateCalendarSyncInterval, + hasTasksSync = hasTasksSync, + taskSyncInterval = taskSyncInterval, + onUpdateTaskSyncInterval = onUpdateTaskSyncInterval, + syncOnlyOnWifi = syncOnlyOnWifi, + onUpdateSyncOnlyOnWifi = onUpdateSyncOnlyOnWifi, + onlyOnSsids = onlyOnSsids, + onUpdateOnlyOnSsids = onUpdateOnlyOnSsids, + ignoreVpns = ignoreVpns, + onUpdateIgnoreVpns = onUpdateIgnoreVpns + ) + + credentials?.let { + AuthenticationSettings( + snackbarHostState = snackbarHostState, + credentials = credentials, + isEnabled = isCredentialsUpdateAllowed, + onUpdateCredentials = onUpdateCredentials, + onAuthenticateOAuth = onAuthenticateOAuth + ) + } + + CalDavSettings( + timeRangePastDays = timeRangePastDays, + onUpdateTimeRangePastDays = onUpdateTimeRangePastDays, + defaultAlarmMinBefore = defaultAlarmMinBefore, + onUpdateDefaultAlarmMinBefore = onUpdateDefaultAlarmMinBefore, + manageCalendarColors = manageCalendarColors, + onUpdateManageCalendarColors = onUpdateManageCalendarColors, + eventColors = eventColors, + onUpdateEventColors = onUpdateEventColors, + ) + + CardDavSettings( + contactGroupMethod = contactGroupMethod, + onUpdateContactGroupMethod = onUpdateContactGroupMethod + ) + } +} + +@Composable +fun SyncSettings( + canAccessWifiSsid: Boolean, + onSyncWifiOnlyPermissionsAction: () -> Unit, + hasContactsSync: Boolean, + contactsSyncInterval: Long?, + onUpdateContactsSyncInterval: ((Long) -> Unit) = {}, + hasCalendarsSync: Boolean, + calendarSyncInterval: Long?, + onUpdateCalendarSyncInterval: ((Long) -> Unit) = {}, + hasTasksSync: Boolean, + taskSyncInterval: Long?, + onUpdateTaskSyncInterval: ((Long) -> Unit) = {}, + syncOnlyOnWifi: Boolean, + onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {}, + onlyOnSsids: List?, + onUpdateOnlyOnSsids: (List) -> Unit = {}, + ignoreVpns: Boolean, + onUpdateIgnoreVpns: (Boolean) -> Unit = {} +) { + Column { + SettingsHeader(false) { + Text(stringResource(R.string.settings_sync)) + } + + if (hasContactsSync) + SyncIntervalSetting( + icon = Icons.Default.Contacts, + name = R.string.settings_sync_interval_contacts, + syncInterval = contactsSyncInterval, + onUpdateSyncInterval = onUpdateContactsSyncInterval + ) + if (hasCalendarsSync) + SyncIntervalSetting( + icon = Icons.Default.Event, + name = R.string.settings_sync_interval_calendars, + syncInterval = calendarSyncInterval, + onUpdateSyncInterval = onUpdateCalendarSyncInterval + ) + if (hasTasksSync) + SyncIntervalSetting( + icon = Icons.Outlined.Task, + name = R.string.settings_sync_interval_tasks, + syncInterval = taskSyncInterval, + onUpdateSyncInterval = onUpdateTaskSyncInterval + ) + + SwitchSetting( + icon = Icons.Default.Wifi, + name = stringResource(R.string.settings_sync_wifi_only), + summaryOn = stringResource(R.string.settings_sync_wifi_only_on), + summaryOff = stringResource(R.string.settings_sync_wifi_only_off), + checked = syncOnlyOnWifi, + onCheckedChange = onUpdateSyncOnlyOnWifi + ) + + var showWifiOnlySsidsDialog by remember { mutableStateOf(false) } + Setting( + icon = null, + name = stringResource(R.string.settings_sync_wifi_only_ssids), + enabled = syncOnlyOnWifi, + summary = + if (onlyOnSsids != null) + stringResource(R.string.settings_sync_wifi_only_ssids_on, onlyOnSsids.joinToString(", ")) + else + stringResource(R.string.settings_sync_wifi_only_ssids_off), + onClick = { + showWifiOnlySsidsDialog = true + } + ) + if (showWifiOnlySsidsDialog) + EditTextInputDialog( + title = stringResource(R.string.settings_sync_wifi_only_ssids_message), + initialValue = onlyOnSsids?.joinToString(", ") ?: "", + onValueEntered = { newValue -> + val newSsids = newValue.split(',') + .map { it.trim() } + .distinct() + onUpdateOnlyOnSsids(newSsids) + showWifiOnlySsidsDialog = false + }, + onDismiss = { showWifiOnlySsidsDialog = false } + ) + + if (LocalInspectionMode.current || onlyOnSsids != null) + ActionCard( + icon = if (!canAccessWifiSsid) Icons.Default.SyncProblem else Icons.Default.Info, + actionText = stringResource(R.string.settings_sync_wifi_only_ssids_permissions_action), + onAction = onSyncWifiOnlyPermissionsAction + ) { + Column { + if (!canAccessWifiSsid) + Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required)) + Text( + stringResource( + R.string.wifi_permissions_background_location_disclaimer, stringResource( + R.string.app_name) + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + SwitchSetting( + icon = null, + name = stringResource(R.string.settings_ignore_vpns), + summaryOn = stringResource(R.string.settings_ignore_vpns_on), + summaryOff = stringResource(R.string.settings_ignore_vpns_off), + checked = ignoreVpns, + onCheckedChange = onUpdateIgnoreVpns + ) + } +} + +@Composable +fun SyncIntervalSetting( + icon: ImageVector, + @StringRes name: Int, + syncInterval: Long?, + onUpdateSyncInterval: (Long) -> Unit +) { + var showSyncIntervalDialog by remember { mutableStateOf(false) } + Setting( + icon = icon, + name = stringResource(name), + summary = + if (syncInterval == null) + stringResource(R.string.settings_sync_summary_manually) + else + stringResource(R.string.settings_sync_summary_periodically, syncInterval / 60), + onClick = { + showSyncIntervalDialog = true + } + ) + if (showSyncIntervalDialog) { + val syncIntervalNames = stringArrayResource(R.array.settings_sync_interval_names) + val syncIntervalSeconds = stringArrayResource(R.array.settings_sync_interval_seconds) + MultipleChoiceInputDialog( + title = stringResource(name), + namesAndValues = syncIntervalNames.zip(syncIntervalSeconds), + initialValue = (syncInterval ?: SYNC_INTERVAL_MANUALLY).toString(), + onValueSelected = { newValue -> + try { + val seconds = newValue.toLong() + onUpdateSyncInterval(seconds) + } catch (_: NumberFormatException) { + } + showSyncIntervalDialog = false + }, + onDismiss = { + showSyncIntervalDialog = false + } + ) + } +} + +@Composable +fun AuthenticationSettings( + credentials: Credentials, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + isEnabled: Boolean = true, + onUpdateCredentials: (Credentials) -> Unit = {}, + onAuthenticateOAuth: () -> Unit = {} +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + if (credentials.authState != null || credentials.username != null || credentials.password != null || credentials.certificateAlias != null) + Column { + SettingsHeader(false) { + Text(stringResource(R.string.settings_authentication)) + } + + // username/password + if (credentials.username != null || credentials.password != null) { + var showUsernameDialog by remember { mutableStateOf(false) } + Setting( + icon = Icons.Default.AccountCircle, + name = stringResource(R.string.settings_username), + summary = credentials.username, + enabled = isEnabled, + onClick = { + showUsernameDialog = true + } + ) + if (showUsernameDialog) + EditTextInputDialog( + title = stringResource(R.string.settings_username), + initialValue = credentials.username, + onValueEntered = { newValue -> + onUpdateCredentials(credentials.copy(username = newValue)) + }, + onDismiss = { showUsernameDialog = false } + ) + + var showPasswordDialog by remember { mutableStateOf(false) } + Setting( + icon = Icons.Default.Password, + name = stringResource(R.string.settings_password), + summary = stringResource(R.string.settings_password_summary), + enabled = isEnabled, + onClick = { + showPasswordDialog = true + } + ) + if (showPasswordDialog) + EditTextInputDialog( + title = stringResource(R.string.settings_password), + inputLabel = stringResource(R.string.settings_new_password), + initialValue = null, // Do not show the existing password + passwordField = true, + onValueEntered = { newValue -> + onUpdateCredentials(credentials.copy(password = newValue.toSensitiveString())) + }, + onDismiss = { showPasswordDialog = false } + ) + } + + // OAuth + if (credentials.authState != null) { + Setting( + icon = Icons.Default.Password, + name = stringResource(R.string.settings_reauthorize_oauth), + summary = stringResource(R.string.settings_reauthorize_oauth_summary), + enabled = isEnabled, + onClick = onAuthenticateOAuth + ) + } + + // client certificate + Setting( + icon = null, + name = stringResource(R.string.settings_certificate_alias), + summary = credentials.certificateAlias ?: stringResource(R.string.settings_certificate_alias_empty), + enabled = isEnabled, + onClick = { + val activity = context as Activity + KeyChain.choosePrivateKeyAlias(activity, { newAlias -> + if (newAlias != null) + onUpdateCredentials(credentials.copy(certificateAlias = newAlias)) + else + scope.launch { + if (snackbarHostState.showSnackbar( + context.getString(R.string.settings_certificate_alias_empty), + actionLabel = context.getString(R.string.settings_certificate_install) + ) == SnackbarResult.ActionPerformed) { + val intent = KeyChain.createInstallIntent() + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + } + } + }, null, null, null, -1, credentials.certificateAlias) + } + ) + } +} + +@Composable +fun CalDavSettings( + timeRangePastDays: Int?, + onUpdateTimeRangePastDays: (Int?) -> Unit = {}, + defaultAlarmMinBefore: Int?, + onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {}, + manageCalendarColors: Boolean, + onUpdateManageCalendarColors: (Boolean) -> Unit = {}, + eventColors: Boolean, + onUpdateEventColors: (Boolean) -> Unit = {} +) { + Column { + SettingsHeader { + Text(stringResource(R.string.settings_caldav)) + } + + var showTimeRangePastDialog by remember { mutableStateOf(false) } + Setting( + icon = Icons.Default.History, + name = stringResource(R.string.settings_sync_time_range_past), + summary = + if (timeRangePastDays != null) + pluralStringResource(R.plurals.settings_sync_time_range_past_days, timeRangePastDays, timeRangePastDays) + else + stringResource(R.string.settings_sync_time_range_past_none), + onClick = { + showTimeRangePastDialog = true + } + ) + if (showTimeRangePastDialog) + EditTextInputDialog( + title = stringResource(R.string.settings_sync_time_range_past_message), + initialValue = timeRangePastDays?.toString() ?: "", + onValueEntered = { newValue -> + val days = try { + newValue.toInt() + } catch (_: NumberFormatException) { + null + } + onUpdateTimeRangePastDays(days) + showTimeRangePastDialog = false + }, + onDismiss = { showTimeRangePastDialog = false } + ) + + var showDefaultAlarmDialog by remember { mutableStateOf(false) } + Setting( + icon = null, + name = stringResource(R.string.settings_default_alarm), + summary = + if (defaultAlarmMinBefore != null) + pluralStringResource(R.plurals.settings_default_alarm_on, defaultAlarmMinBefore, defaultAlarmMinBefore) + else + stringResource(R.string.settings_default_alarm_off), + onClick = { + showDefaultAlarmDialog = true + } + ) + if (showDefaultAlarmDialog) + EditTextInputDialog( + title = stringResource(R.string.settings_default_alarm_message), + initialValue = defaultAlarmMinBefore?.toString() ?: "", + onValueEntered = { newValue -> + val minBefore = try { + newValue.toInt() + } catch (_: NumberFormatException) { + null + } + onUpdateDefaultAlarmMinBefore(minBefore) + showDefaultAlarmDialog = false + }, + onDismiss = { showDefaultAlarmDialog = false } + ) + + SwitchSetting( + icon = null, + name = stringResource(R.string.settings_manage_calendar_colors), + summaryOn = stringResource(R.string.settings_manage_calendar_colors_on), + summaryOff = stringResource(R.string.settings_manage_calendar_colors_off), + checked = manageCalendarColors, + onCheckedChange = onUpdateManageCalendarColors + ) + + SwitchSetting( + icon = null, + name = stringResource(R.string.settings_event_colors), + summaryOn = stringResource(R.string.settings_event_colors_on), + summaryOff = stringResource(R.string.settings_event_colors_off), + checked = eventColors, + onCheckedChange = onUpdateEventColors + ) + } +} + +@Composable +fun CardDavSettings( + contactGroupMethod: GroupMethod, + onUpdateContactGroupMethod: (GroupMethod) -> Unit = {} +) { + Column { + SettingsHeader { + Text(stringResource(R.string.settings_carddav)) + } + + val groupMethodNames = stringArrayResource(R.array.settings_contact_group_method_entries) + val groupMethodValues = stringArrayResource(R.array.settings_contact_group_method_values) + var showGroupMethodDialog by remember { mutableStateOf(false) } + Setting( + icon = Icons.Default.Contacts, + name = stringResource(R.string.settings_contact_group_method), + summary = groupMethodNames[groupMethodValues.indexOf(contactGroupMethod.name)], + onClick = { + showGroupMethodDialog = true + } + ) + if (showGroupMethodDialog) + MultipleChoiceInputDialog( + title = stringResource(R.string.settings_contact_group_method), + namesAndValues = groupMethodNames.zip(groupMethodValues), + initialValue = contactGroupMethod.name, + onValueSelected = { newValue -> + onUpdateContactGroupMethod(GroupMethod.valueOf(newValue)) + showGroupMethodDialog = false + }, + onDismiss = { showGroupMethodDialog = false } + ) + } +} + +@Composable +@Preview +fun AccountSettingsScreen_Preview() { + AppTheme { + AccountSettingsScreen( + accountName = "Account Name Here", + onNavUp = {}, + status = "Some Status", + + // Sync settings + canAccessWifiSsid = true, + onSyncWifiOnlyPermissionsAction = {}, + hasContactsSync = true, + contactsSyncInterval = 80000L, + onUpdateContactsSyncInterval = {}, + hasCalendarsSync = true, + calendarSyncInterval = 50000L, + onUpdateCalendarSyncInterval = {}, + hasTasksSync = true, + tasksSyncInterval = 900000L, + onUpdateTasksSyncInterval = {}, + syncOnlyOnWifi = true, + onUpdateSyncOnlyOnWifi = {}, + onlyOnSsids = listOf("HeyWifi", "Another"), + onUpdateOnlyOnSsids = {}, + ignoreVpns = true, + onUpdateIgnoreVpns = {}, + + // Authentication Settings + credentials = Credentials(username = "test", password = "test".toSensitiveString()), + onUpdateCredentials = {}, + isCredentialsUpdateAllowed = true, + + // CalDav Settings + timeRangePastDays = 365, + onUpdateTimeRangePastDays = {}, + defaultAlarmMinBefore = 585, + onUpdateDefaultAlarmMinBefore = {}, + manageCalendarColors = false, + onUpdateManageCalendarColors = {}, + eventColors = false, + onUpdateEventColors = {}, + + // CardDav Settings + contactGroupMethod = GroupMethod.GROUP_VCARDS, + onUpdateContactGroupMethod = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt new file mode 100644 index 0000000..1449854 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CollectionActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + const val EXTRA_COLLECTION_ID = "collection_id" + } + + val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT)!! } + val collectionId by lazy { intent.getLongExtra(EXTRA_COLLECTION_ID, -1) } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + CollectionScreen( + collectionId = collectionId, + onFinish = ::finish, + onNavUp = ::onSupportNavigateUp + ) + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt new file mode 100644 index 0000000..cbea975 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt @@ -0,0 +1,418 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.DoNotDisturbOn +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.bitfire.davdroid.R +import at.bitfire.davdroid.repository.DavSyncStatsRepository +import at.bitfire.davdroid.sync.SyncDataType +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog +import at.bitfire.davdroid.ui.composable.ProgressBar +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun CollectionScreen( + collectionId: Long, + onFinish: () -> Unit, + onNavUp: () -> Unit +) { + val model: CollectionScreenModel = hiltViewModel( + creationCallback = { factory: CollectionScreenModel.Factory -> + factory.create(collectionId) + } + ) + + val collectionOrNull by model.collection.collectAsStateWithLifecycle(null) + if (model.invalid) { + onFinish() + return + } + + val collection = collectionOrNull ?: return + CollectionScreen( + inProgress = model.inProgress, + error = model.error, + onResetError = model::resetError, + color = collection.color, + sync = collection.sync, + onSetSync = model::setSync, + readOnly = model.readOnly.collectAsStateWithLifecycle(CollectionScreenModel.ReadOnlyState.READ_WRITE).value, + onSetForceReadOnly = model::setForceReadOnly, + title = collection.title(), + displayName = collection.displayName, + description = collection.description, + owner = model.owner.collectAsStateWithLifecycle(null).value, + lastSynced = model.lastSynced.collectAsStateWithLifecycle(emptyList()).value, + supportsWebPush = collection.supportsWebPush, + pushSubscriptionCreated = collection.pushSubscriptionCreated, + pushSubscriptionExpires = collection.pushSubscriptionExpires, + url = collection.url.toString(), + onDelete = model::delete, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollectionScreen( + inProgress: Boolean, + error: Exception? = null, + onResetError: () -> Unit = {}, + color: Int?, + sync: Boolean, + onSetSync: (Boolean) -> Unit = {}, + readOnly: CollectionScreenModel.ReadOnlyState, + onSetForceReadOnly: (Boolean) -> Unit = {}, + title: String, + displayName: String? = null, + description: String? = null, + owner: String? = null, + lastSynced: List = emptyList(), + supportsWebPush: Boolean = false, + pushSubscriptionCreated: Long? = null, + pushSubscriptionExpires: Long? = null, + url: String, + onDelete: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + AppTheme { + if (error != null) + ExceptionInfoDialog( + exception = error, + onDismiss = onResetError + ) + + Scaffold( + topBar = { + MediumTopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = stringResource(R.string.navigate_up)) + } + }, + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + var showDeleteDialog by remember { mutableStateOf(false) } + IconButton( + onClick = { showDeleteDialog = true }, + enabled = !inProgress + ) { + Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.collection_delete)) + } + + if (showDeleteDialog) + DeleteCollectionDialog( + displayName = title, + onDismiss = { showDeleteDialog = false }, + onConfirm = { + onDelete() + showDeleteDialog = false + } + ) + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (inProgress) + ProgressBar( + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp)) + + if (color != null) { + Box( + Modifier + .background(Color(color)) + .fillMaxWidth() + .height(16.dp) + ) + Spacer(Modifier.height(8.dp)) + } + + Column(Modifier.padding(8.dp)) { + CollectionScreen_Entry( + icon = Icons.Default.Sync, + title = stringResource(R.string.collection_synchronization), + text = + if (sync) + stringResource(R.string.collection_synchronization_on) + else + stringResource(R.string.collection_synchronization_off), + control = { + Switch( + checked = sync, + onCheckedChange = onSetSync + ) + } + ) + + CollectionScreen_Entry( + icon = Icons.Default.DoNotDisturbOn, + title = stringResource(R.string.collection_read_only), + text = when (readOnly) { + CollectionScreenModel.ReadOnlyState.READ_ONLY_BY_SERVER -> + stringResource(R.string.collection_read_only_by_server) + CollectionScreenModel.ReadOnlyState.READ_ONLY_BY_SETTING -> + stringResource(R.string.collection_read_only_by_setting) + CollectionScreenModel.ReadOnlyState.READ_ONLY_BY_USER -> + stringResource(R.string.collection_read_only_forced) + else -> stringResource(R.string.collection_read_write) + }, + control = { + Switch( + checked = readOnly.isReadOnly(), + enabled = readOnly.canUserChange(), + onCheckedChange = onSetForceReadOnly + ) + } + ) + + if (displayName != null) + CollectionScreen_Entry( + title = stringResource(R.string.collection_title), + text = title + ) + + if (description != null) + CollectionScreen_Entry( + title = stringResource(R.string.collection_description), + text = description + ) + + if (owner != null) + CollectionScreen_Entry( + icon = Icons.Default.AccountBox, + title = stringResource(R.string.collection_owner), + text = owner + ) + + if (supportsWebPush) { + val text = + if (pushSubscriptionCreated != null && pushSubscriptionExpires != null) { + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withZone(ZoneId.systemDefault()) + stringResource( + R.string.collection_push_subscribed_at, + formatter.format(Instant.ofEpochSecond(pushSubscriptionCreated)), + formatter.format(Instant.ofEpochSecond(pushSubscriptionExpires)) + ) + } else + stringResource(R.string.collection_push_web_push) + CollectionScreen_Entry( + icon = Icons.Default.CloudSync, + title = stringResource(R.string.collection_push_support), + text = text + ) + } + + Column(Modifier.padding(start = 44.dp)) { + if (sync && lastSynced.isNotEmpty()) { + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + + for (lastSync in lastSynced) { + val dataType = when (lastSync.dataType) { + SyncDataType.EVENTS.name -> stringResource(R.string.collection_datatype_events) + SyncDataType.TASKS.name -> stringResource(R.string.collection_datatype_tasks) + SyncDataType.CONTACTS.name -> stringResource(R.string.collection_datatype_contacts) + else -> lastSync.dataType + } + Text( + text = stringResource(R.string.collection_last_sync, dataType), + style = MaterialTheme.typography.titleMedium + ) + + val time = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastSync.lastSynced), ZoneId.systemDefault()) + Text( + text = formatter.format(time), + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(Modifier.height(16.dp)) + } + } + + Text( + text = stringResource(R.string.collection_url), + style = MaterialTheme.typography.titleMedium + ) + SelectionContainer { + Text( + text = url, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier + ) + } + } + } + } + } + } +} + +@Composable +fun CollectionScreen_Entry( + icon: ImageVector? = null, + title: String? = null, + text: String? = null, + control: @Composable (() -> Unit)? = null +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp) + ) { + if (icon != null) + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(end = 12.dp) + .size(32.dp) + ) + else + Spacer(Modifier.width(44.dp)) + + Column(Modifier.weight(1f)) { + if (title != null) + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + if (text != null) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge + ) + } + + if (control != null) + control() + } +} + +@Composable +@Preview +fun CollectionScreen_Preview() { + CollectionScreen( + inProgress = true, + color = 0xff14c0c4.toInt(), + sync = true, + readOnly = CollectionScreenModel.ReadOnlyState.READ_ONLY_BY_USER, + url = "https://example.com/calendar", + title = "Some Calendar, with some additional text to make it wrap around and stuff.", + displayName = "Some Calendar, with some additional text to make it wrap around and stuff.", + description = "This is some description of the calendar. It can be long and wrap around.", + owner = "Some One", + lastSynced = listOf( + DavSyncStatsRepository.LastSynced( + dataType = "Some Sync Data Type", + lastSynced = 1234567890 + ) + ), + supportsWebPush = true, + pushSubscriptionCreated = 1731846565, + pushSubscriptionExpires = 1731847565 + ) +} + + +@Composable +fun DeleteCollectionDialog( + displayName: String, + onDismiss: () -> Unit = {}, + onConfirm: () -> Unit = {} +) { + AlertDialog( + icon = { + Icon(Icons.Default.DeleteForever, contentDescription = null) + }, + title = { + Text(stringResource(R.string.collection_delete)) + }, + text = { + Text(stringResource(R.string.collection_delete_warning, displayName)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.dialog_delete)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + onDismissRequest = onDismiss + ) +} + +@Composable +@Preview +fun DeleteCollectionDialog_Preview() { + DeleteCollectionDialog( + displayName = "Some Calendar" + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt new file mode 100644 index 0000000..9208678 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt @@ -0,0 +1,144 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavSyncStatsRepository +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.util.DavUtils.lastSegment +import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = CollectionScreenModel.Factory::class) +class CollectionScreenModel @AssistedInject constructor( + private val accountRepository: AccountRepository, + @Assisted val collectionId: Long, + db: AppDatabase, + private val collectionRepository: DavCollectionRepository, + private val collectionSelectedUseCase: Lazy, + settings: SettingsManager, + syncStatsRepository: DavSyncStatsRepository +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(collectionId: Long): CollectionScreenModel + } + + /** Whether an operation (like deleting the collection) is currently in progress */ + var inProgress by mutableStateOf(false) + private set + + var invalid by mutableStateOf(false) + var error by mutableStateOf(null) + private set + + val collection = collectionRepository.getFlow(collectionId) + .map { + if (it == null) + invalid = true + it + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + + enum class ReadOnlyState { + READ_ONLY_BY_SETTING, + READ_ONLY_BY_SERVER, + READ_ONLY_BY_USER, + READ_WRITE; + + fun canUserChange() = this == READ_WRITE || this == READ_ONLY_BY_USER + fun isReadOnly() = this != READ_WRITE + } + + /** whether address-books are read-only by policy (if yes, it overrides everything else) */ + private val forceReadOnlyAddressBooks = settings.getBooleanFlow(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false) + + val readOnly: Flow = combine(collection, forceReadOnlyAddressBooks) { collection, forceReadOnlyAddressBook -> + when { + collection?.type == Collection.TYPE_ADDRESSBOOK && forceReadOnlyAddressBook -> + ReadOnlyState.READ_ONLY_BY_SETTING + collection?.privWriteContent == false -> + ReadOnlyState.READ_ONLY_BY_SERVER + collection?.forceReadOnly == true -> + ReadOnlyState.READ_ONLY_BY_USER + else -> + ReadOnlyState.READ_WRITE + } + } + + + private val principalDao = db.principalDao() + val owner: Flow = collection.map { collection -> + collection?.ownerId?.let { ownerId -> + val principal = principalDao.getAsync(ownerId) + principal.displayName ?: principal.url.lastSegment + } + } + + val lastSynced = syncStatsRepository.getLastSyncedFlow(collectionId) + + + /** Scope for operations that must not be cancelled. */ + private val noCancellationScope = CoroutineScope(SupervisorJob()) + + /** + * Deletes the collection from the database and the server. + */ + fun delete() { + val collection = collection.value ?: return + + inProgress = true + noCancellationScope.launch { + try { + collectionRepository.deleteRemote(collection) + } catch (e: Exception) { + error = e + } finally { + inProgress = false + } + } + } + + fun resetError() { + error = null + } + + fun setForceReadOnly(forceReadOnly: Boolean) { + viewModelScope.launch { + collectionRepository.setForceReadOnly(collectionId, forceReadOnly) + collectionSelectedUseCase.get().handleWithDelay(collectionId) + } + } + + fun setSync(sync: Boolean) { + viewModelScope.launch { + collectionRepository.setSync(collectionId, sync) + collectionSelectedUseCase.get().handleWithDelay(collectionId) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionSelectedUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionSelectedUseCase.kt new file mode 100644 index 0000000..5ee5427 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionSelectedUseCase.kt @@ -0,0 +1,89 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.push.PushRegistrationManager +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import at.bitfire.davdroid.ui.account.CollectionSelectedUseCase.Companion.DELAY_MS +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Performs actions when a collection was (un)selected for synchronization. + * + * @see handleWithDelay + */ +@Singleton +class CollectionSelectedUseCase @Inject constructor( + private val accountRepository: AccountRepository, + private val collectionRepository: DavCollectionRepository, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + private val pushRegistrationManager: PushRegistrationManager, + private val serviceRepository: DavServiceRepository, + private val syncWorkerManager: SyncWorkerManager +) { + + private val delayJobs: ConcurrentHashMap = ConcurrentHashMap() + private val scope = CoroutineScope(SupervisorJob()) + + /** + * After a delay of [DELAY_MS] ms: + * + * 1. Enqueues a one-time sync for account of the collection. + * 2. Updates push subscriptions for the service of the collection. + * + * Resets delay when called again before delay finishes. + * + * @param collectionId ID of the collection that was (un)selected for synchronization + */ + suspend fun handleWithDelay(collectionId: Long) { + val collection = collectionRepository.getAsync(collectionId) ?: return + val service = serviceRepository.get(collection.serviceId) ?: return + val account = accountRepository.fromName(service.accountName) + + // Atomically cancel, launch and remember delay coroutine of given account + delayJobs.compute(account) { _, previousJob -> + // Stop previous delay, if exists + previousJob?.cancel() + + scope.launch(defaultDispatcher) { + // wait + delay(DELAY_MS) + + // enqueue sync + syncWorkerManager.enqueueOneTimeAllAuthorities(account) + + // update push subscriptions + pushRegistrationManager.update(service.id) + + // remove complete job + delayJobs -= account + } + } + } + + + companion object { + + /** + * Length of delay in milliseconds + */ + const val DELAY_MS = 5000L // 5 seconds + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt new file mode 100644 index 0000000..4feb939 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt @@ -0,0 +1,275 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.EventNote +import androidx.compose.material.icons.filled.Contacts +import androidx.compose.material.icons.filled.RemoveCircle +import androidx.compose.material.icons.filled.Task +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.ui.AppTheme +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Composable +fun CollectionsList( + collections: LazyPagingItems, + onChangeSync: (collectionId: Long, sync: Boolean) -> Unit, + modifier: Modifier = Modifier, + onSubscribe: (collection: Collection) -> Unit = {}, + onCollectionDetails: ((collection: Collection) -> Unit)? = null, + state: LazyListState = rememberLazyListState() +) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + modifier = modifier, + state = state + ) { + items( + count = collections.itemCount, + key = collections.itemKey { it.id } + ) { index -> + collections[index]?.let { item -> + if (item.type == Collection.TYPE_WEBCAL) + CollectionsList_Item_Webcal( + item, + onSubscribe = { onSubscribe(item) } + ) + else + CollectionsList_Item_Standard( + item, + onChangeSync = { onChangeSync(item.id, it) }, + onCollectionDetails = onCollectionDetails + ) + } + } + + // make sure we can scroll down far enough so that the last item is not covered by a FAB + item { + Spacer(Modifier.height(140.dp)) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CollectionList_Item( + color: Color? = null, + title: String, + description: String? = null, + addressBook: Boolean = false, + calendar: Boolean = false, + todoList: Boolean = false, + journal: Boolean = false, + readOnly: Boolean = false, + onShowDetails: (() -> Unit)? = null, + syncControl: @Composable () -> Unit +) { + var modifier = Modifier.fillMaxWidth() + if (onShowDetails != null) + modifier = modifier.clickable(onClick = onShowDetails) + + ElevatedCard( + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + modifier = modifier + ) { + Row(Modifier.height(IntrinsicSize.Max)) { + Box( + Modifier + .background(color ?: Color.Transparent) + .width(8.dp) + .fillMaxHeight()) + + Column(Modifier.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)) + + if (description != null) + Text(description, style = MaterialTheme.typography.bodyMedium) + } + + syncControl() + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (addressBook) + CollectionList_Item_Chip(Icons.Default.Contacts, stringResource(R.string.account_contacts)) + + if (calendar) + CollectionList_Item_Chip(Icons.Default.Today, stringResource(R.string.account_calendar)) + if (todoList) + CollectionList_Item_Chip(Icons.Default.Task, stringResource(R.string.account_task_list)) + if (journal) + CollectionList_Item_Chip(Icons.AutoMirrored.Default.EventNote, stringResource(R.string.account_journal)) + + if (readOnly) + CollectionList_Item_Chip(Icons.Default.RemoveCircle, stringResource(R.string.account_read_only)) + } + } + } + } +} + +@Composable +fun CollectionsList_Item_Standard( + collection: Collection, + onChangeSync: (sync: Boolean) -> Unit = {}, + onCollectionDetails: ((collection: Collection) -> Unit)? = null +) { + CollectionList_Item( + color = collection.color?.let { Color(it) }, + title = collection.title(), + description = collection.description, + addressBook = collection.type == Collection.TYPE_ADDRESSBOOK, + calendar = collection.supportsVEVENT == true, + todoList = collection.supportsVTODO == true, + journal = collection.supportsVJOURNAL == true, + readOnly = collection.readOnly(), + onShowDetails = { + if (onCollectionDetails != null) + onCollectionDetails(collection) + } + ) { + val context = LocalContext.current + Switch( + checked = collection.sync, + onCheckedChange = onChangeSync, + modifier = Modifier + .padding(start = 4.dp, top = 4.dp, bottom = 4.dp) + .semantics { + contentDescription = context.getString(R.string.account_synchronize_this_collection) + } + ) + } +} + +@Composable +@Preview(locale = "de") +fun CollectionsList_Item_Standard_Preview() { + AppTheme { + CollectionsList_Item_Standard( + Collection( + type = Collection.TYPE_CALENDAR, + url = "https://example.com/caldav/sample".toHttpUrl(), + displayName = "Sample Calendar", + description = "This Sample Calendar even has some lengthy description.", + color = 0xffff0000.toInt(), + sync = true, + forceReadOnly = true, + supportsVEVENT = true, + supportsVTODO = true, + supportsVJOURNAL = true + ) + ) + } +} + +@Composable +fun CollectionsList_Item_Webcal( + collection: Collection, + onSubscribe: () -> Unit = {} +) { + CollectionList_Item( + color = collection.color?.let { Color(it) }, + title = collection.title(), + description = collection.description, + calendar = true, + readOnly = true + ) { + OutlinedButton( + onClick = onSubscribe, + modifier = Modifier.padding(start = 4.dp) + ) { + Text("Subscribe") + } + } +} + +@Composable +@Preview +fun CollectionList_Item_Webcal_Preview() { + AppTheme { + CollectionsList_Item_Webcal( + Collection( + type = Collection.TYPE_WEBCAL, + url = "https://example.com/caldav/sample".toHttpUrl(), + displayName = "Sample Subscription", + description = "This Sample Subscription even has some lengthy description.", + color = 0xffff0000.toInt() + ) + ) + } +} + +@Composable +fun CollectionList_Item_Chip(icon: ImageVector, text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + icon, + contentDescription = text, + modifier = Modifier.size(20.dp) + ) + Text( + text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 4.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt new file mode 100644 index 0000000..6a704d0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CreateAddressBookActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + val account by lazy { + intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") + } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + CreateAddressBookScreen( + account = account, + onNavUp = ::onSupportNavigateUp, + onFinish = ::finish + ) + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt new file mode 100644 index 0000000..a6bc6cd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavHomeSetRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = CreateAddressBookModel.Factory::class) +class CreateAddressBookModel @AssistedInject constructor( + @Assisted val account: Account, + private val collectionRepository: DavCollectionRepository, + homeSetRepository: DavHomeSetRepository +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): CreateAddressBookModel + } + + val addressBookHomeSets = homeSetRepository.getAddressBookHomeSetsFlow(account) + + + // UI state + + data class UiState( + val error: Exception? = null, + val success: Boolean = false, + + val displayName: String = "", + val description: String = "", + val selectedHomeSet: HomeSet? = null, + val isCreating: Boolean = false + ) { + val canCreate = !isCreating && displayName.isNotBlank() && selectedHomeSet != null + } + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun setDisplayName(displayName: String) { + uiState = uiState.copy(displayName = displayName) + } + + fun setDescription(description: String) { + uiState = uiState.copy(description = description) + } + + fun setHomeSet(homeSet: HomeSet) { + uiState = uiState.copy(selectedHomeSet = homeSet) + } + + + // actions + + /* Creating collections shouldn't be cancelled when the view is destroyed, otherwise we might + end up with collections on the server that are not represented in the database/UI. */ + private val createCollectionScope = CoroutineScope(SupervisorJob()) + + fun createAddressBook() { + val homeSet = uiState.selectedHomeSet ?: return + uiState = uiState.copy(isCreating = true) + + createCollectionScope.launch { + uiState = try { + collectionRepository.createAddressBook( + account = account, + homeSet = homeSet, + displayName = uiState.displayName, + description = uiState.description + ) + + uiState.copy(isCreating = false, success = true) + } catch (e: Exception) { + uiState.copy(isCreating = false, error = e) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt new file mode 100644 index 0000000..44ff97e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt @@ -0,0 +1,203 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog +import at.bitfire.davdroid.ui.composable.ProgressBar +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Composable +fun CreateAddressBookScreen( + account: Account, + onNavUp: () -> Unit = {}, + onFinish: () -> Unit = {} +) { + val model: CreateAddressBookModel = hiltViewModel( + creationCallback = { factory: CreateAddressBookModel.Factory -> + factory.create(account) + } + ) + val uiState = model.uiState + + if (uiState.success) + onFinish() + + CreateAddressBookScreen( + error = uiState.error, + onResetError = model::resetError, + displayName = uiState.displayName, + onSetDisplayName = model::setDisplayName, + description = uiState.description, + onSetDescription = model::setDescription, + homeSets = model.addressBookHomeSets.collectAsStateWithLifecycle(emptyList()).value, + selectedHomeSet = uiState.selectedHomeSet, + onSelectHomeSet = model::setHomeSet, + canCreate = uiState.canCreate, + isCreating = uiState.isCreating, + onCreate = model::createAddressBook, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateAddressBookScreen( + error: Exception? = null, + onResetError: () -> Unit = {}, + displayName: String = "", + onSetDisplayName: (String) -> Unit = {}, + description: String = "", + onSetDescription: (String) -> Unit = {}, + homeSets: List, + selectedHomeSet: HomeSet? = null, + onSelectHomeSet: (HomeSet) -> Unit = {}, + canCreate: Boolean = false, + isCreating: Boolean = false, + onCreate: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + AppTheme { + if (error != null) + ExceptionInfoDialog( + exception = error, + onDismiss = onResetError + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.create_addressbook)) }, + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.navigate_up)) + } + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (isCreating) + ProgressBar( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column( + Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = displayName, + onValueChange = onSetDisplayName, + label = { Text(stringResource(R.string.create_collection_display_name)) }, + singleLine = true, + enabled = !isCreating, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = description, + onValueChange = onSetDescription, + label = { Text(stringResource(R.string.create_collection_description_optional)) }, + singleLine = true, + enabled = !isCreating, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onCreate() + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + HomeSetSelection( + homeSet = selectedHomeSet, + homeSets = homeSets, + onSelectHomeSet = onSelectHomeSet, + enabled = !isCreating, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.create_addressbook_maybe_not_supported), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Button( + onClick = onCreate, + enabled = canCreate + ) { + Text(stringResource(R.string.create_addressbook)) + } + } + } + } + } +} + +@Composable +@Preview +fun CreateAddressBookScreen_Preview() { + CreateAddressBookScreen( + displayName = "Address Book", + homeSets = listOf( + HomeSet(0, 0, true, "https://example.com/some/homeset".toHttpUrl()) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt new file mode 100644 index 0000000..7a8be75 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt @@ -0,0 +1,48 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import at.bitfire.davdroid.ui.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CreateCalendarActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + val account by lazy { + intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") + } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppTheme { + CreateCalendarScreen( + account = account, + onNavUp = ::onSupportNavigateUp, + onFinish = ::finish + ) + } + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt new file mode 100644 index 0000000..ff0d72b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt @@ -0,0 +1,158 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavHomeSetRepository +import at.bitfire.synctools.icalendar.Css3Color +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import java.text.Collator +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale + +@HiltViewModel(assistedFactory = CreateCalendarModel.Factory::class) +class CreateCalendarModel @AssistedInject constructor( + @Assisted val account: Account, + private val collectionRepository: DavCollectionRepository, + homeSetRepository: DavHomeSetRepository +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): CreateCalendarModel + } + + val calendarHomeSets = homeSetRepository.getCalendarHomeSetsFlow(account) + + data class TimeZoneInfo( + val id: String, + val displayName: String, + ) + + /** List of available time zones as pairs. */ + val timeZones: Flow> = flow { + val timeZones = mutableListOf() + val locale = Locale.getDefault() + for (id in ZoneId.getAvailableZoneIds()) + timeZones += TimeZoneInfo( + id, + ZoneId.of(id).getDisplayName(TextStyle.FULL, locale), + ) + + val collator = Collator.getInstance() + val result = timeZones.sortedBy { collator.getCollationKey(it.displayName) } + + emit(result) + }.flowOn(Dispatchers.Default) + + + // UI state + + data class UiState( + val error: Exception? = null, + val success: Boolean = false, + + val color: Int = Css3Color.entries.random().argb, + val displayName: String = "", + val description: String = "", + val timeZoneId: String? = null, + val supportVEVENT: Boolean = true, + val supportVTODO: Boolean = true, + val supportVJOURNAL: Boolean = true, + val homeSet: HomeSet? = null, + val isCreating: Boolean = false + ) { + val canCreate = !isCreating && displayName.isNotBlank() && homeSet != null + } + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun setColor(color: Int) { + uiState = uiState.copy(color = color) + } + + fun setDisplayName(displayName: String) { + uiState = uiState.copy(displayName = displayName) + } + + fun setDescription(description: String) { + uiState = uiState.copy(description = description) + } + + fun setTimeZoneId(timeZoneId: String?) { + uiState = uiState.copy(timeZoneId = timeZoneId) + } + + fun setSupportVEVENT(supportVEVENT: Boolean) { + uiState = uiState.copy(supportVEVENT = supportVEVENT) + } + + fun setSupportVTODO(supportVTODO: Boolean) { + uiState = uiState.copy(supportVTODO = supportVTODO) + } + + fun setSupportVJOURNAL(supportVJOURNAL: Boolean) { + uiState = uiState.copy(supportVJOURNAL = supportVJOURNAL) + } + + fun setHomeSet(homeSet: HomeSet) { + uiState = uiState.copy(homeSet = homeSet) + } + + + // actions + + /* Creating collections shouldn't be cancelled when the view is destroyed, otherwise we might + end up with collections on the server that are not represented in the database/UI. */ + private val createCollectionScope = CoroutineScope(SupervisorJob()) + + fun createCalendar() { + val homeSet = uiState.homeSet ?: return + uiState = uiState.copy(isCreating = true) + + createCollectionScope.launch { + uiState = try { + collectionRepository.createCalendar( + account = account, + homeSet = homeSet, + color = uiState.color, + displayName = uiState.displayName, + description = uiState.description, + timeZoneId = uiState.timeZoneId, + supportVEVENT = uiState.supportVEVENT, + supportVTODO = uiState.supportVTODO, + supportVJOURNAL = uiState.supportVJOURNAL + ) + + uiState.copy(isCreating = false, success = true) + } catch (e: Exception) { + uiState.copy(isCreating = false, error = e) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt new file mode 100644 index 0000000..e1b3af7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt @@ -0,0 +1,381 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog +import at.bitfire.davdroid.ui.composable.ProgressBar +import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog +import at.bitfire.synctools.icalendar.Css3Color +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Composable +fun CreateCalendarScreen( + account: Account, + onFinish: () -> Unit, + onNavUp: () -> Unit +) { + val model: CreateCalendarModel = hiltViewModel( + creationCallback = { factory: CreateCalendarModel.Factory -> + factory.create(account) + } + ) + val uiState = model.uiState + + if (uiState.success) + onFinish() + + CreateCalendarScreen( + isCreating = uiState.isCreating, + error = uiState.error, + onResetError = model::resetError, + color = uiState.color, + onSetColor = model::setColor, + displayName = uiState.displayName, + onSetDisplayName = model::setDisplayName, + description = uiState.description, + onSetDescription = model::setDescription, + timeZones = model.timeZones.collectAsStateWithLifecycle(emptyList()).value, + timeZone = uiState.timeZoneId, + onSelectTimeZone = model::setTimeZoneId, + supportVEVENT = uiState.supportVEVENT, + onSetSupportVEVENT = model::setSupportVEVENT, + supportVTODO = uiState.supportVTODO, + onSetSupportVTODO = model::setSupportVTODO, + supportVJOURNAL = uiState.supportVJOURNAL, + onSetSupportVJOURNAL = model::setSupportVJOURNAL, + homeSets = model.calendarHomeSets.collectAsStateWithLifecycle(emptyList()).value, + selectedHomeSet = uiState.homeSet, + onSelectHomeSet = model::setHomeSet, + canCreate = uiState.canCreate, + onCreate = model::createCalendar, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateCalendarScreen( + error: Exception? = null, + onResetError: () -> Unit = {}, + color: Int = Css3Color.green.argb, + onSetColor: (Int) -> Unit = {}, + displayName: String = "", + onSetDisplayName: (String) -> Unit = {}, + description: String = "", + onSetDescription: (String) -> Unit = {}, + timeZones: List, + timeZone: String? = null, + onSelectTimeZone: (String?) -> Unit = {}, + supportVEVENT: Boolean = true, + onSetSupportVEVENT: (Boolean) -> Unit = {}, + supportVTODO: Boolean = true, + onSetSupportVTODO: (Boolean) -> Unit = {}, + supportVJOURNAL: Boolean = true, + onSetSupportVJOURNAL: (Boolean) -> Unit = {}, + homeSets: List, + selectedHomeSet: HomeSet? = null, + onSelectHomeSet: (HomeSet) -> Unit = {}, + canCreate: Boolean = false, + isCreating: Boolean = false, + onCreate: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val context = LocalContext.current + + AppTheme { + if (error != null) + ExceptionInfoDialog( + exception = error, + onDismiss = onResetError + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.create_calendar)) }, + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up)) + } + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (isCreating) + ProgressBar( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column( + Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(IntrinsicSize.Max) + ) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = displayName, + onValueChange = onSetDisplayName, + label = { Text(stringResource(R.string.create_collection_display_name)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .padding(end = 8.dp) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + var showColorPicker by remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showColorPicker = true + }, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color(color) + ), + shape = RoundedCornerShape(4.dp), + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) + .semantics { + contentDescription = context.getString(R.string.create_collection_color) + } + ) { /* no content */ } + if (showColorPicker) { + CalendarColorPickerDialog( + onSelectColor = { color -> + onSetColor(color) + showColorPicker = false + }, + onDismiss = { showColorPicker = false } + ) + } + } + + OutlinedTextField( + value = description, + onValueChange = onSetDescription, + label = { Text(stringResource(R.string.create_collection_description_optional)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onCreate() } + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.padding(top = 8.dp) + ) { + OutlinedTextField( + label = { Text(stringResource(R.string.create_calendar_time_zone_optional)) }, + value = timeZone ?: stringResource(R.string.create_calendar_time_zone_none), + onValueChange = { /* read-only */ }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxHeight() + ) { + Text( + text = stringResource(R.string.create_calendar_time_zone_none), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .clickable { + onSelectTimeZone(null) + expanded = false + } + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + + for (tz in timeZones) + Text( + text = tz.displayName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .clickable { + onSelectTimeZone(tz.id) + expanded = false + } + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + Text( + stringResource(R.string.create_calendar_type), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp) + ) + CheckBoxRow( + label = stringResource(R.string.create_calendar_type_vevent), + value = supportVEVENT, + onValueChange = onSetSupportVEVENT + ) + CheckBoxRow( + label = stringResource(R.string.create_calendar_type_vtodo), + value = supportVTODO, + onValueChange = onSetSupportVTODO + ) + CheckBoxRow( + label = stringResource(R.string.create_calendar_type_vjournal), + value = supportVJOURNAL, + onValueChange = onSetSupportVJOURNAL + ) + + HomeSetSelection( + homeSet = selectedHomeSet, + homeSets = homeSets, + onSelectHomeSet = onSelectHomeSet, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.create_calendar_maybe_not_supported), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Button( + onClick = onCreate, + enabled = canCreate + ) { + Text(stringResource(R.string.create_calendar)) + } + } + } + } + } +} + +@Composable +fun CheckBoxRow( + label: String, + value: Boolean, + onValueChange: (Boolean) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onValueChange(!value) } + ) { + Checkbox( + checked = value, + onCheckedChange = onValueChange + ) + Text( + label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +@Preview +fun CreateCalendarScreenPreview() { + CreateCalendarScreen( + timeZones = listOf( + CreateCalendarModel.TimeZoneInfo( + id = "Europe/Vienna", + displayName = "Vienna (Europe)" + ) + ), + timeZone = "Europe/Vienna", + + homeSets = listOf( + HomeSet( + id = 0, + serviceId = 0, + personal = true, + url = "https://example.com/some/homeset".toHttpUrl() + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt new file mode 100644 index 0000000..f4e4be7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt @@ -0,0 +1,29 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavHomeSetRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class GetBindableHomeSetsFromServiceUseCase @Inject constructor( + val homeSetRepository: DavHomeSetRepository +) { + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(serviceFlow: Flow): Flow> = + serviceFlow.flatMapLatest { service -> + if (service == null) + flowOf(emptyList()) + else + homeSetRepository.getBindableByServiceFlow(service.id) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt new file mode 100644 index 0000000..e4c2a46 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt @@ -0,0 +1,72 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.CollectionType +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Gets a list of collections for a service and type, optionally filtered by "show only personal" setting. + * + * Takes the "force read-only address books" setting into account: if set, all address books will have "forceReadOnly" set. + */ +class GetServiceCollectionPagerUseCase @Inject constructor( + val collectionRepository: DavCollectionRepository, + val settings: SettingsManager +) { + + companion object { + const val PAGER_SIZE = 20 + } + + val forceReadOnlyAddressBooksFlow = settings.getBooleanFlow(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false) + + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + serviceFlow: Flow, + @CollectionType collectionType: String, + showOnlyPersonalFlow: Flow + ): Flow> = + combine(serviceFlow, showOnlyPersonalFlow, forceReadOnlyAddressBooksFlow) { service, onlyPersonal, forceReadOnlyAddressBooks -> + service?.let { service -> + val dataFlow = Pager( + config = PagingConfig(PAGER_SIZE), + pagingSourceFactory = { + if (onlyPersonal == true) + collectionRepository.pagePersonalByServiceAndType(service.id, collectionType) + else + collectionRepository.pageByServiceAndType(service.id, collectionType) + } + ).flow + + // set "forceReadOnly" for every address book if requested + if (forceReadOnlyAddressBooks && collectionType == Collection.TYPE_ADDRESSBOOK) + dataFlow.map { pagingData -> + pagingData.map { collection -> + collection.copy(forceReadOnly = true) + } + } + else + dataFlow + } ?: flowOf(PagingData.empty()) + }.flatMapLatest { it } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt new file mode 100644 index 0000000..811072c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt @@ -0,0 +1,123 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import okhttp3.HttpUrl.Companion.toHttpUrl + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeSetSelection( + homeSet: HomeSet?, + homeSets: List, + onSelectHomeSet: (HomeSet) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Column(modifier) { + // select first home set if none is selected + LaunchedEffect(homeSets) { + if (homeSet == null) + homeSets.firstOrNull()?.let(onSelectHomeSet) + } + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + label = { Text(stringResource(R.string.create_collection_home_set)) }, + value = homeSet?.title() ?: "", + onValueChange = { /* read-only */ }, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Column(Modifier.padding(horizontal = 8.dp) + ) { + for (item in homeSets) { + Column( + modifier = Modifier + .clickable(enabled = enabled) { + onSelectHomeSet(item) + expanded = false + } + .padding(vertical = 8.dp) + ) { + Text( + text = item.title(), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = item.url.encodedPath, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } +} + +@Composable +@Preview +fun HomeSetSelection_Preview() { + val homeSets = listOf( + HomeSet( + id = 0, + serviceId = 0, + personal = true, + url = "https://example.com/homeset/first".toHttpUrl() + ), + HomeSet( + id = 0, + serviceId = 0, + personal = true, + url = "https://example.com/homeset/second".toHttpUrl() + ) + ) + HomeSetSelection( + homeSet = homeSets.last(), + homeSets = homeSets, + onSelectHomeSet = {} + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt new file mode 100644 index 0000000..11bd5de --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountDialog.kt @@ -0,0 +1,98 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R + +@Composable +fun RenameAccountDialog( + oldName: String, + onRenameAccount: (newName: String) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + var accountName by remember { mutableStateOf(TextFieldValue(oldName, selection = TextRange(oldName.length))) } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.DriveFileRenameOutline, contentDescription = null) }, + title = { Text(stringResource(R.string.account_rename)) }, + text = { Column { + Text( + stringResource(R.string.account_rename_new_name_description), + modifier = Modifier.padding(bottom = 8.dp) + ) + + val focusRequester = remember { FocusRequester() } + TextField( + value = accountName, + onValueChange = { accountName = it }, + label = { Text(stringResource(R.string.account_rename_new_name)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onRenameAccount(accountName.text) + } + ), + modifier = Modifier.focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + }}, + confirmButton = { + Button( + onClick = { + onRenameAccount(accountName.text) + }, + enabled = oldName != accountName.text + ) { + Text(stringResource(R.string.account_rename_rename)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@Composable +@Preview +fun RenameAccountDialog_Preview() { + RenameAccountDialog("Account Name") +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt new file mode 100644 index 0000000..a0c5001 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt @@ -0,0 +1,54 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.res.stringResource +import androidx.core.app.TaskStackBuilder +import at.bitfire.davdroid.R +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class WifiPermissionsActivity: AppCompatActivity() { + + companion object { + const val EXTRA_ACCOUNT = "account" + } + + private val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + WifiPermissionsScreen( + backgroundPermissionOptionLabel = + if (Build.VERSION.SDK_INT >= 30) + packageManager.backgroundPermissionOptionLabel.toString() + else + stringResource(R.string.wifi_permissions_background_location_permission_label), + onEnableLocationService = { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + if (intent.resolveActivity(packageManager) != null) + startActivity(intent) + }, + onNavUp = ::onSupportNavigateUp + ) + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsModel.kt new file mode 100644 index 0000000..cd3ff01 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.content.Context +import android.content.IntentFilter +import android.location.LocationManager +import androidx.core.content.getSystemService +import androidx.core.location.LocationManagerCompat +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.util.broadcastReceiverFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class WifiPermissionsModel @Inject constructor( + @ApplicationContext context: Context +): ViewModel() { + + private val locationManager = context.getSystemService()!! + + val locationEnabled = broadcastReceiverFlow(context, IntentFilter(LocationManager.MODE_CHANGED_ACTION), immediate = true) + .map { LocationManagerCompat.isLocationEnabled(locationManager) } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsScreen.kt new file mode 100644 index 0000000..73d039f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsScreen.kt @@ -0,0 +1,266 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.Manifest +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.composable.PermissionSwitchRow +import at.bitfire.davdroid.util.PermissionUtils + +@Composable +fun WifiPermissionsScreen( + model: WifiPermissionsModel = viewModel(), + backgroundPermissionOptionLabel: String, + onEnableLocationService: (Boolean) -> Unit, + onNavUp: () -> Unit +) { + val locationServiceEnabled by model.locationEnabled.collectAsStateWithLifecycle(false) + WifiPermissionsScreen( + backgroundPermissionOptionLabel = backgroundPermissionOptionLabel, + locationServiceEnabled = locationServiceEnabled, + onEnableLocationService = onEnableLocationService, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WifiPermissionsScreen( + backgroundPermissionOptionLabel: String, + locationServiceEnabled: Boolean, + onEnableLocationService: (Boolean) -> Unit, + onNavUp: () -> Unit +) { + AppTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(R.string.navigate_up) + ) + } + }, + title = { Text(stringResource(R.string.wifi_permissions_label)) }, + actions = { + val uriHandler = LocalUriHandler.current + IconButton(onClick = { + uriHandler.openUri( + ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_FAQ) + .appendPath(ExternalUris.Homepage.PATH_FAQ_LOCATION_PERMISSION) + .withStatParams("WifiPermissionsScreen") + .build().toString() + ) + }) { + Icon(Icons.AutoMirrored.Default.Help, stringResource(R.string.help)) + } + } + ) + } + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + WifiPermissionsScreenContent( + backgroundPermissionOptionLabel = backgroundPermissionOptionLabel, + locationServiceEnabled = locationServiceEnabled, + onEnableLocationService = onEnableLocationService + ) + } + } + } +} + +@Composable +fun WifiPermissionsScreenContent( + backgroundPermissionOptionLabel: String, + locationServiceEnabled: Boolean, + onEnableLocationService: (Boolean) -> Unit +) { + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState())) { + + // Disclaimer + Row { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + stringResource( + R.string.wifi_permissions_background_location_disclaimer, stringResource( + R.string.app_name) + ), + style = MaterialTheme.typography.bodyMedium, + + ) + Text( + stringResource( + R.string.wifi_permissions_background_location_disclaimer2, stringResource( + R.string.app_name) + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + Icon(Icons.Default.CloudOff, null, modifier = Modifier.padding(8.dp)) + } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + // Permission switches + Text( + stringResource(R.string.wifi_permissions_intro), + style = MaterialTheme.typography.bodyLarge + ) + + // Android 8.1+: location permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) + LocationPermission( + modifier = Modifier.padding(top = 16.dp) + ) + + // Android 10+: background location permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + BackgroundLocationPermission( + backgroundPermissionOptionLabel = backgroundPermissionOptionLabel, + modifier = Modifier.padding(top = 16.dp) + ) + + // Android 9+: location service + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + LocationService( + locationServiceEnabled = locationServiceEnabled, + modifier = Modifier.padding(top = 16.dp), + onEnableLocationService = onEnableLocationService + ) + + // If permissions have actively been denied + Text( + stringResource(R.string.permissions_app_settings_hint), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 16.dp) + ) + val context = LocalContext.current + OutlinedButton( + modifier = Modifier.padding(top = 8.dp), + onClick = { PermissionUtils.showAppSettings(context) } + ) { + Text(stringResource(R.string.permissions_app_settings)) + } + } +} + +@Composable +fun LocationPermission( + modifier: Modifier = Modifier +) { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + Manifest.permission.ACCESS_FINE_LOCATION // since Android 10, fine location is required + else + Manifest.permission.ACCESS_COARSE_LOCATION // Android 8+: coarse location is enough + + PermissionSwitchRow( + text = stringResource(R.string.wifi_permissions_location_permission), + permissions = listOf(permission), + summaryWhenGranted = stringResource(R.string.wifi_permissions_location_permission_on), + summaryWhenNotGranted = stringResource(R.string.wifi_permissions_location_permission_off), + modifier = modifier + ) +} + +@RequiresApi(Build.VERSION_CODES.Q) +@Composable +fun BackgroundLocationPermission( + backgroundPermissionOptionLabel: String, + modifier: Modifier = Modifier +) { + PermissionSwitchRow( + text = stringResource(R.string.wifi_permissions_background_location_permission), + permissions = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + summaryWhenGranted = stringResource(R.string.wifi_permissions_background_location_permission_on, backgroundPermissionOptionLabel), + summaryWhenNotGranted = stringResource(R.string.wifi_permissions_background_location_permission_off, backgroundPermissionOptionLabel), + modifier = modifier + ) +} + +@Composable +fun LocationService( + locationServiceEnabled: Boolean, + modifier: Modifier = Modifier, + onEnableLocationService: (Boolean) -> Unit +) { + Row(modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.wifi_permissions_location_enabled), + style = MaterialTheme.typography.bodyLarge + ) + Text( + stringResource( + if (locationServiceEnabled) + R.string.wifi_permissions_location_enabled_on + else + R.string.wifi_permissions_location_enabled_off + ), + style = MaterialTheme.typography.bodyMedium + ) + } + Switch( + checked = locationServiceEnabled, + onCheckedChange = onEnableLocationService + ) + } +} + +@Composable +@Preview +fun WifiPermissionsScreen_Preview() { + AppTheme { + WifiPermissionsScreen( + backgroundPermissionOptionLabel = stringResource(R.string.wifi_permissions_background_location_permission_label), + locationServiceEnabled = true, + onEnableLocationService = {}, + onNavUp = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ActionCard.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ActionCard.kt new file mode 100644 index 0000000..1c8f2cf --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ActionCard.kt @@ -0,0 +1,80 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NotificationAdd +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun ActionCard( + modifier: Modifier = Modifier, + icon: ImageVector? = null, + actionText: String? = null, + onAction: () -> Unit = {}, + content: @Composable () -> Unit +) { + Card(Modifier + .fillMaxWidth() + .then(modifier) + ) { + Column(Modifier + .padding(top = 8.dp, start = 8.dp, end = 8.dp) + .fillMaxWidth(), + ) { + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { + if (icon != null) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon(icon, "", Modifier + .align(Alignment.Top) + .padding(8.dp)) + content() + } + else + content() + } + + if (actionText != null) + OutlinedButton( + onClick = onAction, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Text(actionText) + } + } + } +} + +@Composable +@Preview +fun ActionCard_Sample() { + ActionCard( + icon = Icons.Default.NotificationAdd, + actionText = "Some Action" + ) { + Column { + Text("Some Content. Some Content. Some Content. Some Content. ") + Text("Other Content. Other Content. Other Content. Other Content. Other Content. Other Content. Other Content. ", style = MaterialTheme.typography.bodyMedium) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt new file mode 100644 index 0000000..a0fb398 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt @@ -0,0 +1,73 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun Assistant( + nextLabel: String? = null, + nextEnabled: Boolean = true, + isLoading: Boolean = false, + onNext: () -> Unit = {}, + content: @Composable () -> Unit +) { + Column(Modifier.fillMaxSize()) { + if (isLoading) + ProgressBar( + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp)) + + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .weight(1f)) { + content() + } + + BottomAppBar( + modifier = Modifier + .fillMaxWidth() + .imePadding(), + actions = { + if (nextLabel != null) + Button( + enabled = nextEnabled, + onClick = onNext, + modifier = Modifier + .padding(horizontal = 8.dp) + .wrapContentSize(Alignment.CenterEnd) + ) { + Text(nextLabel) + } + } + ) + } +} + +@Composable +@Preview +fun Assistant_Preview_InScaffold() { + Assistant(nextLabel = "Next") { + Text("Some Content") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Boxes.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Boxes.kt new file mode 100644 index 0000000..009ef67 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Boxes.kt @@ -0,0 +1,37 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun PixelBoxes( + colors: Array, + modifier: Modifier = Modifier +) { + Row(modifier) { + for (color in colors) + Box(Modifier.padding(4.dp)) { + Box(Modifier + .background(color) + .size(12.dp)) + } + } +} + +@Composable +@Preview +fun PixelBoxes_Sample() { + PixelBoxes(arrayOf(Color.Magenta, Color.Yellow, Color.Cyan)) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/CardWithImage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/CardWithImage.kt new file mode 100644 index 0000000..d23fe8d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/CardWithImage.kt @@ -0,0 +1,146 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TabletAndroid +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R + +@Composable +fun CardWithImage( + title: String, + modifier: Modifier = Modifier, + image: Painter? = null, + imageContentDescription: String? = null, + imageAlignment: Alignment = Alignment.Center, + imageContentScale: ContentScale = ContentScale.Crop, + message: String? = null, + subtitle: String? = null, + icon: ImageVector? = null, + iconContentDescription: String? = null, + content: @Composable ColumnScope.() -> Unit = {} +) { + Card(modifier) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + image?.let { + Image( + painter = it, + contentDescription = imageContentDescription, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 126.dp), + contentScale = imageContentScale, + alignment = imageAlignment + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Icon( + imageVector = it, + contentDescription = iconContentDescription, + modifier = Modifier + .size(44.dp) + .padding(end = 12.dp) + ) + } + + Column(Modifier.fillMaxWidth()) { + Text( + text = title, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.titleLarge + ) + subtitle?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.titleMedium + ) + } + } + } + message?.let { + Text( + text = it, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + + content() + } + } + } +} + +@Preview +@Composable +fun CardWithImage_Preview() { + CardWithImage( + image = painterResource(R.drawable.intro_tasks), + title = "Demo card", + message = "This is the message to be displayed under the title, but before the content." + ) +} + +@Preview +@Composable +fun CardWithImage_Preview_WithIconAndSubtitleAndContent() { + CardWithImage( + title = "Demo card", + icon = Icons.Default.TabletAndroid, + subtitle = "Subtitle", + message = "This is the message to be displayed under the title, but before the content." + ) { + Text("Content") + } +} + +@Preview +@Composable +fun CardWithImage_Preview_WithIconAndContentNoMessage() { + CardWithImage( + title = "Demo card", + icon = Icons.Default.TabletAndroid + ) { + Text("Content") + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt new file mode 100644 index 0000000..2682273 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt @@ -0,0 +1,100 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import android.accounts.Account +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.DebugInfoActivity +import okhttp3.HttpUrl +import java.io.IOException + +@Composable +fun ExceptionInfoDialog( + exception: Throwable, + account: Account? = null, + remoteResource: HttpUrl? = null, + onDismiss: () -> Unit +) { + val context = LocalContext.current + + val titleRes = when (exception) { + is HttpException -> R.string.exception_httpexception + is IOException -> R.string.exception_ioexception + else -> R.string.exception + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(titleRes) + ) + }, + icon = { + Icon(Icons.Rounded.Error, null) + }, + text = { + val message = if (exception is HttpException) { + when (exception.statusCode) { + 403 -> context.getString(R.string.debug_info_http_403_description) + 404 -> context.getString(R.string.debug_info_http_404_description) + 405 -> context.getString(R.string.debug_info_http_405_description) + in 500..599 -> context.getString(R.string.debug_info_http_5xx_description) + else -> null + } + } else null + Text( + text = message ?: "${exception::class.java.name}\n${exception.localizedMessage}" + ) + }, + dismissButton = { + OutlinedButton( + onClick = { + val intent = DebugInfoActivity.IntentBuilder(context).withCause(exception) + if (account != null) + intent.withAccount(account) + if (remoteResource != null) + intent.withRemoteResource(remoteResource) + context.startActivity(intent.build()) + } + ) { + Text(stringResource(R.string.exception_show_details)) + } + }, + confirmButton = { + Button(onClick = onDismiss) { + Text(stringResource(android.R.string.ok)) + } + } + ) +} + +@Composable +@Preview +fun ExceptionInfoDialog_Preview() { + ExceptionInfoDialog( + exception = Exception("Test exception"), + onDismiss = {} + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt new file mode 100644 index 0000000..6b2bc54 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt @@ -0,0 +1,197 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@Composable +fun EditTextInputDialog( + title: String, + initialValue: String? = null, + inputLabel: String? = null, + passwordField: Boolean = false, + keyboardType: KeyboardType = if (passwordField) KeyboardType.Password else KeyboardType.Text, + onValueEntered: (String) -> Unit = {}, + onDismiss: () -> Unit = {}, +) { + val state = rememberTextFieldState( + initialText = initialValue ?: "", + initialSelection = TextRange(initialValue?.length ?: 0) + ) + + val confirmEnabled = state.text != initialValue + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + title, + style = MaterialTheme.typography.bodyLarge + ) + }, + text = { + val focusRequester = remember { FocusRequester() } + if (passwordField) + PasswordTextField( + password = state, + labelText = inputLabel, + modifier = Modifier.focusRequester(focusRequester) + ) + else + TextField( + label = { inputLabel?.let { Text(it) } }, + state = state, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done + ), + onKeyboardAction = { + if (confirmEnabled) { + onValueEntered(state.text.toString()) + onDismiss() + } + }, + modifier = Modifier.focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + }, + confirmButton = { + Button( + onClick = { + onValueEntered(state.text.toString()) + onDismiss() + }, + enabled = confirmEnabled + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + OutlinedButton( + onClick = onDismiss + ) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@Composable +@Preview +fun EditTextInputDialog_Preview() { + EditTextInputDialog( + title = "Enter Some Text", + inputLabel = "Some Label", + initialValue = "initial value" + ) +} + +@Composable +@Preview +fun EditTextInputDialog_Preview_Password() { + EditTextInputDialog( + title = "New Password", + passwordField = true, + initialValue = "some password" + ) +} + + +@Composable +fun MultipleChoiceInputDialog( + title: String, + namesAndValues: List>, + initialValue: String? = null, + onValueSelected: (String) -> Unit = {}, + onDismiss: () -> Unit = {}, +) { + Dialog(onDismissRequest = onDismiss) { + Card { + Column { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + + LazyColumn(Modifier.padding(8.dp)) { + items( + count = namesAndValues.size, + key = { index -> namesAndValues[index].second }, + itemContent = { index -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + val (name, value) = namesAndValues[index] + RadioButton( + selected = value == initialValue, + onClick = { + onValueSelected(value) + onDismiss() + } + ) + Text( + name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .weight(1f) + .clickable { + onValueSelected(value) + onDismiss() + } + ) + } + } + ) + } + } + } + } +} + +@Composable +@Preview +fun MultipleChoiceInputDialog_Preview() { + MultipleChoiceInputDialog( + title = "Some Title", + namesAndValues = listOf( + "Some Name" to "Some Value", + "Some Other Name" to "Some Other Value" + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt new file mode 100644 index 0000000..a340ba5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt @@ -0,0 +1,136 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import android.net.Uri +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString + +@Composable +fun PasswordTextField( + password: TextFieldState, + labelText: String?, + modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + onKeyboardAction: KeyboardActionHandler? = null, + enabled: Boolean = true, + isError: Boolean = false +) { + var passwordVisible by remember { mutableStateOf(false) } + + Column { + OutlinedSecureTextField( + state = password, + label = labelText?.let { { Text(it) } }, + leadingIcon = leadingIcon, + isError = isError, + enabled = enabled, + modifier = modifier.focusGroup(), + keyboardOptions = keyboardOptions, + onKeyboardAction = onKeyboardAction, + textObfuscationMode = if (passwordVisible) TextObfuscationMode.Visible else TextObfuscationMode.RevealLastTyped, + trailingIcon = { + IconButton( + enabled = enabled, + onClick = { passwordVisible = !passwordVisible } + ) { + if (passwordVisible) + Icon(Icons.Default.VisibilityOff, stringResource(R.string.login_password_hide)) + else + Icon(Icons.Default.Visibility, stringResource(R.string.login_password_show)) + } + } + ) + Text( + modifier = Modifier.padding(vertical = 8.dp), + text = HtmlCompat.fromHtml( + stringResource( + R.string.settings_app_password_hint, + appPasswordHelpUrl().toString() + ), + 0 + ).toAnnotatedString() + ) + } +} + +fun appPasswordHelpUrl(): Uri = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_INTRODUCTION) + .fragment(ExternalUris.Manual.FRAGMENT_AUTHENTICATION_METHODS) + .build() + + +@Composable +@Preview +fun PasswordTextField_Sample() { + PasswordTextField( + password = rememberTextFieldState(""), + labelText = "labelText", + enabled = true, + isError = false, + ) +} + +@Composable +@Preview +fun PasswordTextField_Sample_Filled() { + PasswordTextField( + password = rememberTextFieldState("password"), + labelText = "labelText", + enabled = true, + isError = false, + ) +} + +@Composable +@Preview +fun PasswordTextField_Sample_Error() { + PasswordTextField( + password = rememberTextFieldState("password"), + labelText = "labelText", + enabled = true, + isError = true, + ) +} + +@Composable +@Preview +fun PasswordTextField_Sample_Disabled() { + PasswordTextField( + password = rememberTextFieldState("password"), + labelText = "labelText", + enabled = false, + isError = false, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PermissionSwitchRow.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PermissionSwitchRow.kt new file mode 100644 index 0000000..ad6a6f5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PermissionSwitchRow.kt @@ -0,0 +1,144 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.ui.AppTheme +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +@Composable +fun PermissionSwitchRow( + text: String, + allPermissionsGranted: Boolean, + summaryWhenGranted: String, + summaryWhenNotGranted: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Normal, + onLaunchRequest: () -> Unit +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = text, + modifier = Modifier.fillMaxWidth(), + fontWeight = fontWeight, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = if (allPermissionsGranted) summaryWhenGranted else summaryWhenNotGranted, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium + ) + } + Switch( + checked = allPermissionsGranted, + thumbContent = if (allPermissionsGranted) { + { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.padding(4.dp) + ) + } + } else null, + onCheckedChange = { checked -> + if (checked) { + onLaunchRequest() + } + } + ) + } +} + +@Composable +@OptIn(ExperimentalPermissionsApi::class) +fun PermissionSwitchRow( + text: String, + permissions: List, + summaryWhenGranted: String, + summaryWhenNotGranted: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Normal +) { + if (LocalInspectionMode.current) { + // preview + PermissionSwitchRow( + text = text, + fontWeight = fontWeight, + summaryWhenGranted = summaryWhenGranted, + summaryWhenNotGranted = summaryWhenNotGranted, + allPermissionsGranted = false, + onLaunchRequest = {}, + modifier = modifier + ) + return + } + + val state = rememberMultiplePermissionsState(permissions = permissions.toList()) + PermissionSwitchRow( + text = text, + fontWeight = fontWeight, + summaryWhenGranted = summaryWhenGranted, + summaryWhenNotGranted = summaryWhenNotGranted, + allPermissionsGranted = state.allPermissionsGranted, + onLaunchRequest = state::launchMultiplePermissionRequest, + modifier = modifier + ) +} + +@Preview +@Composable +fun PermissionSwitchRow_Preview_NotGranted() { + AppTheme { + PermissionSwitchRow( + text = "Contacts", + allPermissionsGranted = false, + summaryWhenGranted = "Granted", + summaryWhenNotGranted = "Not granted", + onLaunchRequest = {} + ) + } +} + +@Preview +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun PermissionSwitchRow_Preview_Granted() { + AppTheme { + Surface { + PermissionSwitchRow( + text = "Contacts", + allPermissionsGranted = true, + summaryWhenGranted = "Granted", + summaryWhenNotGranted = "Not granted", + onLaunchRequest = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ProgressBar.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ProgressBar.kt new file mode 100644 index 0000000..a452205 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ProgressBar.kt @@ -0,0 +1,46 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap + + +@Composable +fun ProgressBar( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.secondary, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap +) { + LinearProgressIndicator( + modifier = modifier, + color = color, + trackColor = trackColor, + strokeCap = strokeCap + ) +} + +@Composable +fun ProgressBar( + progress: () -> Float, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.secondary, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap +) { + LinearProgressIndicator( + progress = progress, + modifier = modifier, + color = color, + trackColor = trackColor, + strokeCap = strokeCap + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/RadioButtons.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/RadioButtons.kt new file mode 100644 index 0000000..a7f2e45 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/RadioButtons.kt @@ -0,0 +1,97 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.ui.AppTheme + +@Composable +fun RadioButtons( + options: List = listOf(), + initiallySelectedIdx: Int? = null, + onOptionSelected: (Int) -> Unit = { _ -> }, + optionTextPadding: PaddingValues = PaddingValues(10.dp), + modifier: Modifier = Modifier +) { + var selectedIdx by remember { mutableStateOf(initiallySelectedIdx) } + Column( + // Modifier.selectableGroup() is essential to ensure correct accessibility behavior + modifier = modifier.selectableGroup() + ) { + options.forEachIndexed { idx, text -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = (selectedIdx == idx), + onClick = { + selectedIdx = idx + onOptionSelected(idx) + }, + role = Role.Companion.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (idx == selectedIdx), + onClick = null, // null recommended for accessibility with screen readers + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(optionTextPadding) + ) + } + } + } +} + +@Preview +@Composable +private fun RadioButtons_Preview_NoInitialSelection() { + AppTheme { + RadioButtons( + options = listOf( + "Option 1", + "Option 2 is the longest of all the options, so we can see whether line breaks are not a problem.", + "Option 3") + ) + } +} + +@Preview +@Composable +private fun RadioButtons_Preview_InitialSelection() { + AppTheme { + RadioButtons( + options = listOf( + "Option 1", + "Option 2", + "Option 3" + ), + initiallySelectedIdx = 1 + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/RadioWithSwitch.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/RadioWithSwitch.kt new file mode 100644 index 0000000..f84c18d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/RadioWithSwitch.kt @@ -0,0 +1,86 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview + +/** + * Provides a radio button with a text, a switch at the end, and an optional summary to be shown + * under the main text. + * + * @param title The "proper" text of the Radio button. Shown in the middle of the row, between the + * radio button and the switch. + * @param summary If not `null`, shown below the title. Used to give more context or information. + * Supports formatting and interactions. + * @param isSelected Whether the item is currently selected. Refers to the radio button. + * @param isToggled Whether the switch is toggled. + * @param modifier Any modifiers to apply to the row. + * @param enabled Whether the radio button should be enabled. + * @param onSelected Gets called whenever the user requests this row to be enabled. Either by + * selecting the radio button or tapping the text. + * @param onToggled Gets called whenever the switch gets updated. Contains the checked status. + */ +@Composable +fun RadioWithSwitch( + title: String, + summary: (@Composable () -> Unit)?, + isSelected: Boolean, + isToggled: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onSelected: () -> Unit, + onToggled: (Boolean) -> Unit +) { + Row(modifier) { + RadioButton(selected = isSelected, onClick = onSelected, enabled = enabled) + + Column( + modifier = Modifier + .weight(1f) + .clickable(enabled = enabled, role = Role.RadioButton, onClick = onSelected) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth() + ) + summary?.let { sum -> + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + sum() + } + } + } + + Switch( + checked = isToggled, + onCheckedChange = onToggled + ) + } +} + +@Preview +@Composable +private fun RadioWithSwitch_Preview() { + RadioWithSwitch( + title = "RadioWithSwitch Preview", + summary = { Text("An example summary") }, + isSelected = true, + isToggled = false, + onSelected = { }, + onToggled = { } + ) +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SafeAndroidUriHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SafeAndroidUriHandler.kt new file mode 100644 index 0000000..5d262f3 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SafeAndroidUriHandler.kt @@ -0,0 +1,29 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import android.content.Context +import android.widget.Toast +import androidx.compose.ui.platform.AndroidUriHandler +import androidx.compose.ui.platform.UriHandler +import at.bitfire.davdroid.R +import java.util.logging.Level +import java.util.logging.Logger + +class SafeAndroidUriHandler( + val context: Context +): UriHandler { + + override fun openUri(uri: String) { + try { + AndroidUriHandler(context).openUri(uri) + } catch (e: Exception) { + Logger.getGlobal().log(Level.WARNING, "No browser available", e) + // no browser available + Toast.makeText(context, R.string.install_browser, Toast.LENGTH_LONG).show() + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt new file mode 100644 index 0000000..19b52ea --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt @@ -0,0 +1,97 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import android.os.Build +import android.security.KeyChain +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R +import kotlinx.coroutines.launch + +@Composable +fun SelectClientCertificateCard( + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + suggestedAlias: String? = null, + chosenAlias: String?, + onAliasChosen: (String) -> Unit = {} +) { + Card(modifier = modifier) { + Column(Modifier.padding(8.dp)) { + Text( + if (!chosenAlias.isNullOrEmpty()) + stringResource(R.string.login_client_certificate_selected, chosenAlias) + else + stringResource(R.string.login_no_client_certificate_optional), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(8.dp) + ) + + val activity = LocalActivity.current + val scope = rememberCoroutineScope() + OutlinedButton( + enabled = enabled, + onClick = { + if (activity != null) + KeyChain.choosePrivateKeyAlias(activity, { alias -> + if (alias != null) + onAliasChosen(alias) + else { + // Show a Snackbar to add a certificate if no certificate was found + // API Versions < 29 does that itself + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + scope.launch { + if (snackbarHostState.showSnackbar( + message = activity.getString(R.string.login_no_certificate_found), + actionLabel = activity.getString(R.string.login_install_certificate), + duration = SnackbarDuration.Long + ) == SnackbarResult.ActionPerformed) + activity.startActivity(KeyChain.createInstallIntent()) + } + } + }, null, null, null, -1, suggestedAlias) + } + ) { + Text(stringResource(R.string.login_select_certificate)) + } + } + } +} + +@Composable +@Preview +fun SelectClientCertificateCard_Preview_CertSelected() { + SelectClientCertificateCard( + snackbarHostState = SnackbarHostState(), + suggestedAlias = "Test", + chosenAlias = "Test" + ) +} + +@Composable +@Preview +fun SelectClientCertificateCard_Preview_NothingSelected() { + SelectClientCertificateCard( + snackbarHostState = SnackbarHostState(), + suggestedAlias = null, + chosenAlias = null + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Settings.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Settings.kt new file mode 100644 index 0000000..07191e4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Settings.kt @@ -0,0 +1,192 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsHeader(divider: Boolean = false, content: @Composable () -> Unit) { + if (divider) + Divider(Modifier.padding(vertical = 8.dp)) + + Row( + Modifier + .padding(top = 16.dp, start = 52.dp, end = 16.dp, bottom = 8.dp) + .fillMaxWidth() + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.secondary + ) + ) { + content() + } + } +} + +@Composable +@Preview +fun SettingsHeader_Sample() { + Column { + SettingsHeader(divider = true) { + Text("Some Settings Section") + } + } +} + + +@Composable +fun Setting( + icon: @Composable () -> Unit, + name: @Composable () -> Unit, + summary: String?, + end: @Composable () -> Unit = {}, + enabled: Boolean = true, + onClick: () -> Unit = {} +) { + var modifier = Modifier.fillMaxWidth() + modifier = if (enabled) + modifier.clickable(onClick = onClick) + else + modifier.alpha(.38f) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 8.dp) + ) { + Box( + modifier = Modifier + .width(44.dp) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + + Column( + Modifier + .padding(start = 8.dp) + .weight(1f) + ) { + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { + name() + } + + if (summary != null) + Text(summary, style = MaterialTheme.typography.bodyMedium) + } + + end() + } +} + +@Composable +fun Setting( + name: String, + summary: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + onClick: () -> Unit = {} +) { + Setting( + icon = { + if (icon != null) + Icon(icon, contentDescription = name) + }, + name = { + Text(name, style = MaterialTheme.typography.bodyLarge) + }, + summary = summary, + enabled = enabled, + onClick = onClick + ) +} + +@Composable +@Preview +fun Setting_Sample() { + Setting( + icon = Icons.Default.Folder, + name = "Setting", + summary = "Currently off" + ) +} + +@Composable +@Preview +fun Setting_Sample_Disabled() { + Setting( + icon = Icons.Default.Folder, + enabled = false, + name = "Setting", + summary = "Currently off" + ) +} + +@Composable +fun SwitchSetting( + checked: Boolean, + name: String, + summaryOn: String? = null, + summaryOff: String? = null, + enabled: Boolean = true, + icon: ImageVector? = null, + onCheckedChange: (Boolean) -> Unit = {} +) { + Setting( + icon = { + if (icon != null) + Icon(icon, name) + }, + name = { + Text(name) + }, + summary = if (checked) summaryOn else summaryOff, + end = { + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + modifier = Modifier.padding(horizontal = 4.dp) + ) + }, + enabled = enabled + ) { + onCheckedChange(!checked) + } +} + +@Composable +@Preview +fun SwitchSetting_Sample() { + SwitchSetting( + name = "Some Switched Setting", + checked = true, + summaryOn = "Currently on" + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/icon/CalendarImport.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/icon/CalendarImport.kt new file mode 100644 index 0000000..f956f4c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/icon/CalendarImport.kt @@ -0,0 +1,67 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.icon + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Icons.Filled.CalendarImport: ImageVector + get() { + if (_CalendarImport != null) { + return _CalendarImport!! + } + _CalendarImport = ImageVector.Builder( + name = "Filled.CalendarImport", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(12f, 12f) + lineTo(8f, 16f) + horizontalLineTo(11f) + verticalLineTo(22f) + horizontalLineTo(13f) + verticalLineTo(16f) + horizontalLineTo(16f) + moveTo(19f, 3f) + horizontalLineTo(18f) + verticalLineTo(1f) + horizontalLineTo(16f) + verticalLineTo(3f) + horizontalLineTo(8f) + verticalLineTo(1f) + horizontalLineTo(6f) + verticalLineTo(3f) + horizontalLineTo(5f) + curveTo(3.9f, 3f, 3f, 3.9f, 3f, 5f) + verticalLineTo(19f) + curveTo(3f, 20.11f, 3.9f, 21f, 5f, 21f) + horizontalLineTo(9f) + verticalLineTo(19f) + horizontalLineTo(5f) + verticalLineTo(8f) + horizontalLineTo(19f) + verticalLineTo(19f) + horizontalLineTo(15f) + verticalLineTo(21f) + horizontalLineTo(19f) + curveTo(20.11f, 21f, 21f, 20.11f, 21f, 19f) + verticalLineTo(5f) + curveTo(21f, 3.9f, 20.11f, 3f, 19f, 3f) + close() + } + }.build() + + return _CalendarImport!! + } + +@Suppress("ObjectPropertyName") +private var _CalendarImport: ImageVector? = null diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPage.kt new file mode 100644 index 0000000..1658d7d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPage.kt @@ -0,0 +1,58 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.Composable +import androidx.core.net.toUri +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel.Companion.HINT_AUTOSTART_PERMISSION +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel.Companion.HINT_BATTERY_OPTIMIZATIONS +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class BatteryOptimizationsPage @Inject constructor( + @ApplicationContext val context: Context, + val settingsManager: SettingsManager +): IntroPage() { + + override fun getShowPolicy(): ShowPolicy { + // show fragment when: + // 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or + // 2a. evil manufacturer AND + // 2b. "don't show anymore" has not been clicked + return if ( + (!BatteryOptimizationsPageModel.isExempted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) || + (BatteryOptimizationsPageModel.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false) + ) + ShowPolicy.SHOW_ALWAYS + else + ShowPolicy.DONT_SHOW + } + + @Composable + override fun ComposePage() { + BatteryOptimizationsPageContent() + } + + + @SuppressLint("BatteryLife") + object IgnoreBatteryOptimizationsContract: ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + return Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:$input".toUri() + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Unit? { + return null + } + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt new file mode 100644 index 0000000..6864442 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageContent.kt @@ -0,0 +1,225 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import java.util.Locale + +@Composable +fun BatteryOptimizationsPageContent( + model: BatteryOptimizationsPageModel = viewModel() +) { + val ignoreBatteryOptimizationsResultLauncher = rememberLauncherForActivityResult( + BatteryOptimizationsPage.IgnoreBatteryOptimizationsContract + ) { + model.checkBatteryOptimizations() + } + + val hintBatteryOptimizations by model.hintBatteryOptimizations.collectAsStateWithLifecycle(false) + val uiState = model.uiState + LaunchedEffect(uiState) { + if (uiState.shouldBeExempted && !uiState.isExempted) + ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID) + } + + val hintAutostartPermission by model.hintAutostartPermission.collectAsStateWithLifecycle(false) + BatteryOptimizationsPageContent( + dontShowBattery = hintBatteryOptimizations == false, + onChangeDontShowBattery = model::updateHintBatteryOptimizations, + isExempted = uiState.isExempted, + shouldBeExempted = uiState.shouldBeExempted, + onChangeShouldBeExempted = model::updateShouldBeExempted, + dontShowAutostart = hintAutostartPermission == false, + onChangeDontShowAutostart = model::updateHintAutostartPermission, + manufacturerWarning = BatteryOptimizationsPageModel.manufacturerWarning + ) +} + +@Composable +fun BatteryOptimizationsPageContent( + dontShowBattery: Boolean, + onChangeDontShowBattery: (Boolean) -> Unit = {}, + isExempted: Boolean, + shouldBeExempted: Boolean, + onChangeShouldBeExempted: (Boolean) -> Unit = {}, + dontShowAutostart: Boolean, + onChangeDontShowAutostart: (Boolean) -> Unit = {}, + manufacturerWarning: Boolean +) { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Card( + modifier = Modifier.padding(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.intro_battery_title), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f) + ) + Switch( + checked = shouldBeExempted, + onCheckedChange = { + // Only accept click events if not whitelisted + if (!isExempted) { + onChangeShouldBeExempted(it) + } + }, + enabled = !dontShowBattery + ) + } + Text( + text = stringResource( + R.string.intro_battery_text, + stringResource(R.string.app_name) + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 12.dp) + ) + AnimatedVisibility(visible = !isExempted) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = dontShowBattery, + onCheckedChange = { onChangeDontShowBattery(dontShowBattery) }, + enabled = !isExempted + ) + Text( + text = stringResource(R.string.intro_battery_dont_show), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .clickable { onChangeDontShowBattery(dontShowBattery) } + ) + } + } + } + } + if (manufacturerWarning) { + Card( + modifier = Modifier + .padding(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource( + R.string.intro_autostart_title, + Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + ), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.intro_autostart_text), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 12.dp) + ) + OutlinedButton( + onClick = { + uriHandler.openUri( + ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_FAQ) + .appendPath(ExternalUris.Homepage.PATH_FAQ_SYNC_NOT_RUN) + .appendQueryParameter( + "manufacturer", + Build.MANUFACTURER.lowercase(Locale.ROOT) + ) + .withStatParams("BatteryOptimizationsPage") + .build().toString() + ) + } + ) { + Text(stringResource(R.string.intro_more_info)) + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = dontShowAutostart, + onCheckedChange = { onChangeDontShowAutostart(dontShowAutostart) } + ) + Text( + text = stringResource(R.string.intro_autostart_dont_show), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .clickable { onChangeDontShowAutostart(dontShowAutostart) } + ) + } + } + } + } + Text( + text = stringResource( + R.string.intro_leave_unchecked, + stringResource(R.string.app_settings_reset_hints) + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun BatteryOptimizationsContent_Preview() { + AppTheme { + BatteryOptimizationsPageContent( + dontShowBattery = true, + isExempted = false, + shouldBeExempted = true, + dontShowAutostart = false, + manufacturerWarning = true + ) + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageModel.kt new file mode 100644 index 0000000..e05374e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsPageModel.kt @@ -0,0 +1,118 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import android.content.IntentFilter +import android.os.Build +import android.os.PowerManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.getSystemService +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel.Companion.evilManufacturers +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.davdroid.util.broadcastReceiverFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class BatteryOptimizationsPageModel @Inject constructor( + @ApplicationContext val context: Context, + private val settings: SettingsManager +): ViewModel() { + + companion object { + + /** + * Whether the request for whitelisting from battery optimizations shall be shown. + * If this setting is true or null/not set, the notice shall be shown. Only if this + * setting is false, the notice shall not be shown. + */ + const val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations" + + /** + * Whether the autostart permission notice shall be shown. If this setting is true + * or null/not set, the notice shall be shown. Only if this setting is false, the notice + * shall not be shown. + * + * Type: Boolean + */ + const val HINT_AUTOSTART_PERMISSION = "hint_AutostartPermissions" + + /** + * List of manufacturers which are known to restrict background processes or otherwise + * block synchronization. + * + * See https://www.davx5.com/faq/synchronization-is-not-run-as-expected for why this is evil. + * See https://github.com/jaredrummler/AndroidDeviceNames/blob/master/json/ for manufacturer values. + */ + private val evilManufacturers = arrayOf( + "asus", "lenovo", "letv", "meizu", "nokia", + "oneplus", "oppo", "sony", "vivo", "wiko", "xiaomi", "zte") + + /** + * Whether the device has been produced by an evil manufacturer. + * + * Always true for debug builds (to test the UI). + * + * @see evilManufacturers + */ + val manufacturerWarning = + (evilManufacturers.contains(Build.MANUFACTURER.lowercase(Locale.ROOT)) || BuildConfig.DEBUG) + + fun isExempted(context: Context) = + context.getSystemService()!!.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + } + + data class UiState( + val shouldBeExempted: Boolean = true, + val isExempted: Boolean = false + ) + + var uiState by mutableStateOf(UiState()) + private set + + val hintBatteryOptimizations = settings.getBooleanFlow(HINT_BATTERY_OPTIMIZATIONS) + + val hintAutostartPermission = settings.getBooleanFlow(HINT_AUTOSTART_PERMISSION) + + init { + viewModelScope.launch { + broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true).collect { + checkBatteryOptimizations() + } + } + } + + fun checkBatteryOptimizations() { + val exempted = isExempted(context) + uiState = uiState.copy(shouldBeExempted = exempted, isExempted = exempted) + + // if DAVx5 is whitelisted, always show a reminder as soon as it's not whitelisted anymore + if (exempted) + settings.remove(HINT_BATTERY_OPTIMIZATIONS) + } + + fun updateShouldBeExempted(value: Boolean) { + uiState = uiState.copy(shouldBeExempted = value) + } + + fun updateHintBatteryOptimizations(value: Boolean) { + settings.putBoolean(HINT_BATTERY_OPTIMIZATIONS, value) + } + + fun updateHintAutostartPermission(value: Boolean) { + settings.putBoolean(HINT_AUTOSTART_PERMISSION, value) + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt new file mode 100644 index 0000000..97d919f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt @@ -0,0 +1,72 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.rememberCoroutineScope +import at.bitfire.davdroid.ui.AppTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class IntroActivity : AppCompatActivity() { + + val model by viewModels() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val pages = model.pages + + setContent { + AppTheme { + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState { pages.size } + + BackHandler { + if (pagerState.settledPage == 0) { + setResult(Activity.RESULT_CANCELED) + finish() + } else scope.launch { + pagerState.animateScrollToPage(pagerState.settledPage - 1) + } + } + + IntroScreen( + pages = pages, + pagerState = pagerState, + onDonePressed = { + setResult(Activity.RESULT_OK) + finish() + } + ) + } + } + } + + + /** + * For launching the [IntroActivity]. Result is `true` when the user cancelled the intro. + */ + object Contract: ActivityResultContract() { + override fun createIntent(context: Context, input: Unit?): Intent = + Intent(context, IntroActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean { + return resultCode == Activity.RESULT_CANCELED + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroModel.kt new file mode 100644 index 0000000..b2ec1a6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroModel.kt @@ -0,0 +1,50 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.logging.Logger +import javax.inject.Inject + +@HiltViewModel +class IntroModel @Inject constructor( + introPageFactory: IntroPageFactory, + private val logger: Logger +): ViewModel() { + + private val introPages = introPageFactory.introPages + + val pages: List by lazy { + calculatePages() + } + + + private fun calculatePages(): List { + for (page in introPages) + logger.fine("Found intro page ${page::class.java} with order ${page.getShowPolicy()}") + + // Calculate which intro pages shall be shown + val activePages: Map = introPages + .associateWith { page -> + page.getShowPolicy().also { policy -> + logger.fine("IntroActivity: found intro page ${page::class.java} with $policy") + } + } + .filterValues { it != IntroPage.ShowPolicy.DONT_SHOW } + + // Show intro screen when there's at least one page that shall [always] be shown + val anyShowAlways = activePages.values.any { it == IntroPage.ShowPolicy.SHOW_ALWAYS } + return if (anyShowAlways) { + val pages = mutableListOf() + activePages.filterValues { it != IntroPage.ShowPolicy.DONT_SHOW }.forEach { page, _ -> + pages += page + } + pages + } else + emptyList() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroPage.kt new file mode 100644 index 0000000..fe267a7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroPage.kt @@ -0,0 +1,43 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import androidx.compose.runtime.Composable + +abstract class IntroPage { + + enum class ShowPolicy { + DONT_SHOW, + SHOW_ALWAYS, + SHOW_ONLY_WITH_OTHERS + } + + /** + * Whether insets are handled by [ComposePage]. + * + * If `true`, [ComposePage] must add top/side insets for edge-to-edge layout itself. Bottom insets are handled by the bottom bar. + * If `false`, [IntroScreen] will apply all insets to give [ComposePage] a safe content area. + */ + open val customTopInsets: Boolean = false + + /** + * Used to determine whether an intro page of this type (for instance, + * the [BatteryOptimizationsPage]) should be shown. + * + * @return Order with which an instance of this page type shall be created and shown. Possible values: + * + * * < 0: only show the page when there is at least one other page with positive order (lower numbers are shown first) + * * [DONT_SHOW] (0): don't show the page + * * ≥ 0: show the page (lower numbers are shown first) + */ + abstract fun getShowPolicy(): ShowPolicy + + /** + * Composes this page. Will only be called when [getShowPolicy] is not [DONT_SHOW]. + */ + @Composable + abstract fun ComposePage() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroPageFactory.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroPageFactory.kt new file mode 100644 index 0000000..09f1fca --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroPageFactory.kt @@ -0,0 +1,11 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +interface IntroPageFactory { + + val introPages: Array + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt new file mode 100644 index 0000000..a1efb0b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroScreen.kt @@ -0,0 +1,286 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.M3ColorScheme +import kotlinx.coroutines.launch + +@Composable +fun IntroScreen( + pages: List, + pagerState: PagerState = rememberPagerState { pages.size }, + onDonePressed: () -> Unit +) { + val scope = rememberCoroutineScope() + + Scaffold( + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .background(M3ColorScheme.primaryLight) + // consume bottom and side insets of safe drawing area, like BottomAppBar + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .height(90.dp) + ) { + PositionIndicator( + index = pagerState.currentPage, + max = pages.size, + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 128.dp) + .align(Alignment.Center) + .fillMaxWidth(), + selectedIndicatorColor = MaterialTheme.colorScheme.onPrimary, + unselectedIndicatorColor = MaterialTheme.colorScheme.tertiary, + indicatorSize = 15f + ) + + ButtonWithIcon( + icon = if (pagerState.currentPage + 1 == pagerState.pageCount) { + Icons.Default.Check + } else { + Icons.AutoMirrored.Default.ArrowForward + }, + contentDescription = stringResource(R.string.intro_next), + modifier = Modifier + .padding(end = 16.dp) + .align(Alignment.CenterEnd), + color = M3ColorScheme.tertiaryLight + ) { + if (pagerState.currentPage + 1 == pagerState.pageCount) { + onDonePressed() + } else scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + } + }, + contentWindowInsets = WindowInsets(0) + ) { paddingValues -> + Column(modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + HorizontalPager(state = pagerState) { idxPage -> + val page = pages[idxPage] + Box( + modifier = if (page.customTopInsets) + Modifier // ComposePage() handles insets itself + else + // consume top and horizontal sides of safe drawing padding (like TopAppBar) + // bottom is handled by the bottom bar + Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)) + ) { + page.ComposePage() + } + } + } + } +} + +@Preview( + showSystemUi = true, + showBackground = true +) +@Composable +fun IntroScreen_Preview() { + AppTheme { + IntroScreen( + listOf( + object : IntroPage() { + override fun getShowPolicy(): ShowPolicy = ShowPolicy.SHOW_ALWAYS + + @Composable + override fun ComposePage() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + Text("Some Text") + } + } + }, + object : IntroPage() { + override fun getShowPolicy(): ShowPolicy = ShowPolicy.SHOW_ALWAYS + + @Composable + override fun ComposePage() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary) + ) { + Text("Some Text") + } + } + } + ), + onDonePressed = {} + ) + } +} + + +@Composable +fun PositionIndicator( + index: Int, + max: Int, + modifier: Modifier = Modifier, + selectedIndicatorColor: Color = MaterialTheme.colorScheme.tertiary, + unselectedIndicatorColor: Color = contentColorFor(selectedIndicatorColor), + indicatorSize: Float = 20f, + indicatorPadding: Float = 20f +) { + val selectedPosition by animateFloatAsState( + targetValue = index.toFloat(), + label = "position" + ) + + Canvas(modifier = modifier) { + // idx * indicatorSize * 2 + idx * indicatorPadding + indicatorSize + // idx * (indicatorSize * 2 + indicatorPadding) + indicatorSize + val padding = indicatorSize * 2 + indicatorPadding + + val totalWidth = indicatorSize * 2 * max + indicatorPadding * (max - 1) + translate( + left = size.width / 2 - totalWidth / 2 + ) { + for (idx in 0 until max) { + drawCircle( + color = unselectedIndicatorColor, + radius = indicatorSize, + center = Offset( + x = idx * padding + indicatorSize, + y = size.height / 2 + ) + ) + } + + drawCircle( + color = selectedIndicatorColor, + radius = indicatorSize, + center = Offset( + x = selectedPosition * padding + indicatorSize, + y = size.height / 2 + ) + ) + } + } +} + +@Preview( + showBackground = true, + backgroundColor = 0xff000000 +) +@Composable +fun PositionIndicator_Preview() { + var index by remember { mutableIntStateOf(0) } + + PositionIndicator( + index = index, + max = 5, + modifier = Modifier + .width(200.dp) + .height(50.dp) + .clickable { if (index == 4) index = 0 else index++ } + ) +} + + +@Composable +fun ButtonWithIcon( + icon: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + size: Dp = 56.dp, + color: Color = MaterialTheme.colorScheme.tertiary, + contentColor: Color = contentColorFor(backgroundColor = color), + onClick: () -> Unit +) { + Surface( + color = color, + contentColor = contentColor, + modifier = modifier + .size(size) + .aspectRatio(1f), + onClick = onClick, + shape = CircleShape + ) { + AnimatedContent( + targetState = icon, + label = "Button Icon" + ) { + Icon( + imageVector = it, + contentDescription = contentDescription, + modifier = Modifier.padding(12.dp) + ) + } + } +} + +@Preview +@Composable +fun ButtonWithIcon_Preview() { + AppTheme { + ButtonWithIcon( + icon = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null + ) { } + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt new file mode 100644 index 0000000..98fc0c2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourcePage.kt @@ -0,0 +1,159 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.composable.CardWithImage +import at.bitfire.davdroid.ui.composable.RadioButtons +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.logging.Logger +import javax.inject.Inject + +class OpenSourcePage @Inject constructor( + private val settingsManager: SettingsManager +): IntroPage() { + + override fun getShowPolicy(): ShowPolicy { + return if (System.currentTimeMillis() > (settingsManager.getLongOrNull(Model.SETTING_NEXT_DONATION_POPUP) ?: 0)) + ShowPolicy.SHOW_ALWAYS + else + ShowPolicy.DONT_SHOW + } + + @Composable + override fun ComposePage() { + Page() + } + + @Composable + private fun Page(model: Model = viewModel()) { + OpenSourcePage( + dontShowForMonthsOptions = model.donationPopupIntervalOptions, + onDontShowForMonths = model::setDontShowForMonths + ) + } + + @HiltViewModel + class Model @Inject constructor( + private val settings: SettingsManager, + private val logger: Logger + ): ViewModel() { + + companion object { + const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup" + } + + /** + * Possible number of months (30 days) to hide the donation popup for. + */ + val donationPopupIntervalOptions = listOf(1, 3, 9) + + /** + * Set the next time the donation popup should be shown. + * + * @param months Number of months (30 days) to hide the donation popup for. + */ + fun setDontShowForMonths(months: Int) { + logger.info("Setting next donation popup to $months months") + val oneMonth = 30*86400000L // 30 days (~ 1 month) + val nextReminder = oneMonth * months + System.currentTimeMillis() + settings.putLong(SETTING_NEXT_DONATION_POPUP, nextReminder) + } + + } + +} + +@Composable +fun OpenSourcePage( + dontShowForMonthsOptions: List, + onDontShowForMonths: (Int) -> Unit = {} +) { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(8.dp) + ) { + CardWithImage( + title = stringResource(R.string.intro_open_source_title), + image = painterResource(R.drawable.intro_open_source), + imageContentScale = ContentScale.Inside, + message = stringResource( + R.string.intro_open_source_text, + stringResource(R.string.app_name) + ), + modifier = Modifier.padding(vertical = 8.dp) + ) { + OutlinedButton( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + onClick = { + uriHandler.openUri( + ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_OPEN_SOURCE) + .withStatParams("OpenSourcePage") + .build() + .toString() + ) + } + ) { + Text( + stringResource(R.string.intro_open_source_details) + ) + } + + Text( + text = stringResource(R.string.intro_open_source_dont_show), + style = MaterialTheme.typography.bodyLarge + ) + RadioButtons( + options = dontShowForMonthsOptions.map { months -> + pluralStringResource(R.plurals.intro_open_source_dont_show_months, months, months) + }, + onOptionSelected = { idx -> + val months = dontShowForMonthsOptions[idx] + onDontShowForMonths(months) + }, + modifier = Modifier.padding(bottom = 12.dp) + ) + + } + } +} + +@Preview +@Composable +fun OpenSourcePagePreview() { + AppTheme { + OpenSourcePage( + dontShowForMonthsOptions = listOf(1, 3, 9) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroPage.kt new file mode 100644 index 0000000..080d6bf --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroPage.kt @@ -0,0 +1,41 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import android.content.Context +import androidx.compose.runtime.Composable +import at.bitfire.davdroid.ui.PermissionsModel +import at.bitfire.davdroid.ui.PermissionsScreen +import at.bitfire.davdroid.util.PermissionUtils +import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS +import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS +import at.bitfire.ical4android.TaskProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class PermissionsIntroPage @Inject constructor( + @ApplicationContext private val context: Context +): IntroPage() { + + var model: PermissionsModel? = null + + override fun getShowPolicy(): ShowPolicy { + // show PermissionsFragment as intro fragment when no permissions are granted + val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + + TaskProvider.PERMISSIONS_JTX + + TaskProvider.PERMISSIONS_OPENTASKS + + TaskProvider.PERMISSIONS_TASKS_ORG + return if (PermissionUtils.haveAnyPermission(context, permissions)) + ShowPolicy.DONT_SHOW + else + ShowPolicy.SHOW_ALWAYS + } + + @Composable + override fun ComposePage() { + PermissionsScreen() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroPage.kt new file mode 100644 index 0000000..839146e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroPage.kt @@ -0,0 +1,31 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import androidx.compose.runtime.Composable +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.sync.TasksAppManager +import at.bitfire.davdroid.ui.TasksCard +import at.bitfire.davdroid.ui.TasksModel +import javax.inject.Inject + +class TasksIntroPage @Inject constructor( + private val settingsManager: SettingsManager, + private val tasksAppManager: TasksAppManager +): IntroPage() { + + override fun getShowPolicy(): ShowPolicy { + return if (tasksAppManager.currentProvider() != null || settingsManager.getBooleanOrNull(TasksModel.HINT_OPENTASKS_NOT_INSTALLED) == false) + ShowPolicy.DONT_SHOW + else + ShowPolicy.SHOW_ALWAYS + } + + @Composable + override fun ComposePage() { + TasksCard() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/WelcomePage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/WelcomePage.kt new file mode 100644 index 0000000..4863b2f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/WelcomePage.kt @@ -0,0 +1,170 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.M3ColorScheme + +class WelcomePage: IntroPage() { + + override val customTopInsets: Boolean = true + + override fun getShowPolicy() = ShowPolicy.SHOW_ONLY_WITH_OTHERS + + @Composable + override fun ComposePage() { + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) + ContentLandscape() + else + ContentPortrait() + } + + + @Composable + private fun ContentPortrait() { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = M3ColorScheme.primaryLight) // fill background color edge-to-edge + .safeContentPadding() + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + .weight(2f) + ) + + Text( + text = stringResource(R.string.intro_slogan1), + color = Color.White, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 34.sp), + lineHeight = 38.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp) + ) + + Text( + text = stringResource(R.string.intro_slogan2), + color = Color.White, + style = MaterialTheme.typography.labelLarge.copy(fontSize = 48.sp), + lineHeight = 52.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 32.dp) + ) + + Spacer(modifier = Modifier.weight(0.1f)) + } + } + + @Composable + @Preview( + device = "id:3.7in WVGA (Nexus One)", + showSystemUi = true + ) + fun Preview_ContentPortrait_Light() { + AppTheme(darkTheme = false) { + ContentPortrait() + } + } + + @Composable + @Preview( + device = "id:3.7in WVGA (Nexus One)", + showSystemUi = true + ) + fun Preview_ContentPortrait_Dark() { + AppTheme(darkTheme = true) { + ContentPortrait() + } + } + + + @Preview( + showSystemUi = true, + device = "id:medium_tablet" + ) + @Composable + private fun ContentLandscape() { + AppTheme { + Row( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.primary) + .safeContentPadding(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .fillMaxHeight() + .weight(1f) + ) + + Column( + modifier = Modifier + .padding(horizontal = 32.dp) + .weight(2f) + ) { + Text( + text = stringResource(R.string.intro_slogan1), + color = Color.White, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 34.sp), + lineHeight = 38.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = stringResource(R.string.intro_slogan2), + color = Color.White, + style = MaterialTheme.typography.labelLarge.copy(fontSize = 48.sp), + lineHeight = 52.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth() + ) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt new file mode 100644 index 0000000..3260b1e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsPage.kt @@ -0,0 +1,236 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.accounts.Account +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.vcard4android.GroupMethod + +@Composable +fun AccountDetailsPage( + snackbarHostState: SnackbarHostState, + onAccountCreated: (Account) -> Unit, + model: LoginScreenModel = viewModel() +) { + val uiState by model.accountDetailsUiState.collectAsStateWithLifecycle() + uiState.createdAccount?.let(onAccountCreated) + + val context = LocalContext.current + LaunchedEffect(uiState.couldNotCreateAccount) { + if (uiState.couldNotCreateAccount) { + snackbarHostState.showSnackbar(context.getString(R.string.login_account_not_added)) + model.resetCouldNotCreateAccount() + } + } + + AccountDetailsPageContent( + accountName = uiState.accountName, + suggestedAccountNames = uiState.suggestedAccountNames, + accountNameAlreadyExists = uiState.accountNameExists, + onUpdateAccountName = { model.updateAccountName(it) }, + showApostropheWarning = uiState.showApostropheWarning, + groupMethod = uiState.groupMethod, + groupMethodReadOnly = uiState.groupMethodReadOnly, + onUpdateGroupMethod = { model.updateGroupMethod(it) }, + onCreateAccount = { model.createAccount() }, + creatingAccount = uiState.creatingAccount + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountDetailsPageContent( + suggestedAccountNames: Set, + accountName: String, + accountNameAlreadyExists: Boolean, + onUpdateAccountName: (String) -> Unit = {}, + showApostropheWarning: Boolean, + groupMethod: GroupMethod, + groupMethodReadOnly: Boolean, + onUpdateGroupMethod: (GroupMethod) -> Unit = {}, + onCreateAccount: () -> Unit = {}, + creatingAccount: Boolean +) { + Assistant( + nextLabel = stringResource(R.string.login_finish), + onNext = onCreateAccount, + nextEnabled = !creatingAccount && accountName.isNotBlank() && !accountNameAlreadyExists, + isLoading = creatingAccount + ) { + Column(Modifier.padding(8.dp)) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + val offerDropdown = suggestedAccountNames.isNotEmpty() + OutlinedTextField( + value = accountName, + onValueChange = onUpdateAccountName, + label = { Text(stringResource(R.string.login_account_name)) }, + isError = accountNameAlreadyExists, + supportingText = + if (accountNameAlreadyExists) { + { Text(stringResource(R.string.login_account_name_already_taken)) } + } else + null, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ), + trailingIcon = if (offerDropdown) { + { ExposedDropdownMenuDefaults.TrailingIcon(expanded) } + } else null, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryEditable) + .fillMaxWidth() + ) + + if (offerDropdown) + DropdownMenu( // ExposedDropdownMenu takes focus away from the text field when expanded + expanded = expanded, + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = false) // prevent focus from being taken away + ) { + for (name in suggestedAccountNames) + DropdownMenuItem( + text = { Text(name) }, + onClick = { + onUpdateAccountName(name) + expanded = false + } + ) + } + } + + // apostrophe warning + if (showApostropheWarning) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 16.dp) + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.padding(top = 8.dp, end = 8.dp, bottom = 8.dp) + ) + Text( + stringResource(R.string.login_account_avoid_apostrophe), + style = MaterialTheme.typography.bodyLarge + ) + } + + // email address info + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 16.dp) + ) { + Icon( + Icons.Default.Email, + contentDescription = null, + modifier = Modifier.padding(top = 8.dp, end = 8.dp, bottom = 8.dp) + ) + Text( + stringResource(R.string.login_account_name_info), + style = MaterialTheme.typography.bodyLarge + ) + } + + // group type selector + Text( + stringResource(R.string.login_account_contact_group_method), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp) + ) + val groupMethodNames = stringArrayResource(R.array.settings_contact_group_method_entries) + val groupMethodValues = stringArrayResource(R.array.settings_contact_group_method_values).map { GroupMethod.valueOf(it) } + for ((name, method) in groupMethodNames.zip(groupMethodValues)) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = groupMethod == method, + enabled = !groupMethodReadOnly, + onClick = { onUpdateGroupMethod(method) } + ) + + var modifier = Modifier.padding(vertical = 4.dp) + if (!groupMethodReadOnly) + modifier = modifier.clickable(onClick = { onUpdateGroupMethod(method) }) + Text( + name, + style = MaterialTheme.typography.bodyLarge, + modifier = modifier + ) + } + } + } + } +} + +@Composable +@Preview +fun AccountDetailsPage_Content_Preview() { + AccountDetailsPageContent( + suggestedAccountNames = setOf("name1", "name2@example.com"), + accountName = "account@example.com", + accountNameAlreadyExists = false, + showApostropheWarning = false, + groupMethod = GroupMethod.GROUP_VCARDS, + groupMethodReadOnly = false, + creatingAccount = true + ) +} + +@Composable +@Preview +fun AccountDetailsPage_Content_Preview_With_Apostrophe() { + AccountDetailsPageContent( + suggestedAccountNames = setOf("name1", "name2@example.com"), + accountName = "account'example.com", + accountNameAlreadyExists = true, + showApostropheWarning = true, + groupMethod = GroupMethod.CATEGORIES, + groupMethodReadOnly = true, + creatingAccount = false + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt new file mode 100644 index 0000000..20a0d3c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt @@ -0,0 +1,213 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.PasswordTextField +import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard + +object AdvancedLogin : LoginType { + + override val title: Int + get() = R.string.login_type_advanced + + override val helpUrl: Uri? + get() = null + + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val model: AdvancedLoginModel = hiltViewModel( + creationCallback = { factory: AdvancedLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + AdvancedLoginScreen( + snackbarHostState = snackbarHostState, + url = uiState.url, + onSetUrl = model::setUrl, + username = uiState.username, + onSetUsername = model::setUsername, + password = uiState.password, + certAlias = uiState.certAlias, + onSetCertAlias = model::setCertAlias, + canContinue = uiState.canContinue, + onLogin = { + onLogin(uiState.asLoginInfo()) + } + ) + } + +} + +@Composable +fun AdvancedLoginScreen( + snackbarHostState: SnackbarHostState, + url: String, + onSetUrl: (String) -> Unit = {}, + username: String, + onSetUsername: (String) -> Unit = {}, + password: TextFieldState, + certAlias: String, + onSetCertAlias: (String) -> Unit = {}, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + + Assistant( + nextLabel = stringResource(R.string.login_login), + nextEnabled = canContinue, + onNext = onLogin + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_type_advanced), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + val manualUrl = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_ACCOUNTS_COLLECTIONS) + .fragment(ExternalUris.Manual.FRAGMENT_SERVICE_DISCOVERY) + .build() + val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT) + Text( + text = urlInfo.toAnnotatedString(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp) + ) + + OutlinedTextField( + value = url, + onValueChange = onSetUrl, + label = { Text(stringResource(R.string.login_base_url)) }, + placeholder = { Text("dav.example.com/path") }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Folder, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + OutlinedTextField( + value = username, + onValueChange = onSetUsername, + label = { Text(stringResource(R.string.login_user_name_optional)) }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.AccountCircle, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier.fillMaxWidth() + ) + + PasswordTextField( + password = password, + labelText = stringResource(R.string.login_password_optional), + leadingIcon = { + Icon(Icons.Default.Password, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SelectClientCertificateCard( + snackbarHostState = snackbarHostState, + suggestedAlias = null, + chosenAlias = certAlias, + onAliasChosen = onSetCertAlias + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +@Preview +fun AdvancedLoginScreen_Preview_Empty() { + AdvancedLoginScreen( + snackbarHostState = SnackbarHostState(), + url = "", + username = "", + password = rememberTextFieldState(""), + certAlias = "", + canContinue = false + ) +} + +@Composable +@Preview +fun AdvancedLoginScreen_Preview_AllFilled() { + AdvancedLoginScreen( + snackbarHostState = SnackbarHostState(), + url = "dav.example.com", + username = "someuser", + password = rememberTextFieldState("password"), + certAlias = "someCert", + canContinue = true + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt new file mode 100644 index 0000000..4ffd2f6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt @@ -0,0 +1,82 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.DavUtils.toURIorNull +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import at.bitfire.davdroid.util.trimToNull +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel(assistedFactory = AdvancedLoginModel.Factory::class) +class AdvancedLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo, +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): AdvancedLoginModel + } + + data class UiState( + val url: String = "", + val username: String = "", + val password: TextFieldState = TextFieldState(), + val certAlias: String = "" + ) { + + val urlWithPrefix = + if (url.startsWith("http://") || url.startsWith("https://")) + url + else + "https://$url" + val uri = urlWithPrefix.toURIorNull() + + val canContinue = uri != null + + fun asLoginInfo() = LoginInfo( + baseUri = uri, + credentials = Credentials( + username = username.trimToNull(), + password = password.text.trimToNull()?.toSensitiveString(), + certificateAlias = certAlias.trimToNull() + ) + ) + + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = uiState.copy( + url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: "", + username = initialLoginInfo.credentials?.username ?: "", + password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: ""), + certAlias = initialLoginInfo.credentials?.certificateAlias ?: "" + ) + } + + fun setUrl(url: String) { + uiState = uiState.copy(url = url) + } + + fun setUsername(username: String) { + uiState = uiState.copy(username = username) + } + + fun setCertAlias(certAlias: String) { + uiState = uiState.copy(certAlias = certAlias) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt new file mode 100644 index 0000000..82cbe4d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt @@ -0,0 +1,184 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.DebugInfoActivity +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.ProgressBar + +@Composable +fun DetectResourcesPage( + model: LoginScreenModel = viewModel() +) { + val uiState = model.detectResourcesUiState + DetectResourcesPageContent( + loading = uiState.loading, + foundNothing = uiState.foundNothing, + encountered401 = uiState.encountered401, + logs = uiState.logs + ) +} + +@Composable +fun DetectResourcesPageContent( + loading: Boolean, + foundNothing: Boolean, + encountered401: Boolean, + logs: String? +) { + Column(Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + if (loading) + DetectResourcesPageContent_InProgress() + else if (foundNothing) + DetectResourcesPageContent_NothingFound( + encountered401 = encountered401, + logs = logs + ) + } +} + +@Composable +@Preview +fun DetectResourcesPageContent_InProgress() { + Column(Modifier.fillMaxWidth()) { + ProgressBar( + //color = MaterialTheme.colors.secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp)) + + Column(Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_configuration_detection), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + stringResource(R.string.login_querying_server), + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@Composable +fun DetectResourcesPageContent_NothingFound( + encountered401: Boolean, + logs: String? +) { + Column(Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_configuration_detection), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.CloudOff, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) + Text( + stringResource(R.string.login_no_service), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f) + ) + } + + Text( + stringResource(R.string.login_no_service_info), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + + val urlServices = ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_TESTED_SERVICES) + .withStatParams("DetectResourcesPage") + .build() + val testedServices = HtmlCompat.fromHtml( + stringResource(R.string.login_see_tested_services, urlServices), + HtmlCompat.FROM_HTML_MODE_COMPACT + ).toAnnotatedString() + Text( + text = testedServices, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + + if (encountered401) + Text( + stringResource(R.string.login_check_credentials), + modifier = Modifier.padding(vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge + ) + + if (logs != null && logs.isNotEmpty()) { + Text( + stringResource(R.string.login_logs_available), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val context = LocalContext.current + Button( + onClick = { + val intent = DebugInfoActivity.IntentBuilder(context) + .withLogs(logs) + .build() + context.startActivity(intent) + } + ) { + Text(stringResource(R.string.login_view_logs)) + } + } + } + } + } +} + +@Composable +@Preview +fun DetectResourcesPageContent_NothingFound() { + DetectResourcesPageContent_NothingFound( + encountered401 = false, + logs = "SOME LOGS" + ) +} + +@Composable +@Preview +fun DetectResourcesPage_NothingFound_401() { + DetectResourcesPageContent_NothingFound( + encountered401 = true, + logs = "" + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt new file mode 100644 index 0000000..1ec8687 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt @@ -0,0 +1,162 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.PasswordTextField + +object EmailLogin : LoginType { + + override val title: Int + get() = R.string.login_type_email + + override val helpUrl: Uri? + get() = null + + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val model: EmailLoginModel = hiltViewModel( + creationCallback = { factory: EmailLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + EmailLoginScreen( + email = uiState.email, + onSetEmail = model::setEmail, + password = uiState.password, + canContinue = uiState.canContinue, + onLogin = { onLogin(uiState.asLoginInfo()) } + ) + } + +} + + +@Composable +fun EmailLoginScreen( + email: String, + onSetEmail: (String) -> Unit = {}, + password: TextFieldState, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + + Assistant( + nextLabel = stringResource(R.string.login_login), + nextEnabled = canContinue, + onNext = onLogin + ) { + Column(Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_type_email), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + val manualUrl = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_ACCOUNTS_COLLECTIONS) + .fragment(ExternalUris.Manual.FRAGMENT_SERVICE_DISCOVERY) + .build() + val emailInfo = HtmlCompat.fromHtml(stringResource(R.string.login_email_address_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT) + Text( + text = emailInfo.toAnnotatedString(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp) + ) + + OutlinedTextField( + value = email, + onValueChange = onSetEmail, + label = { Text(stringResource(R.string.login_email_address)) }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Email, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + PasswordTextField( + password = password, + labelText = stringResource(R.string.login_password), + leadingIcon = { + Icon(Icons.Default.Password, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + onKeyboardAction = { + if (canContinue) + onLogin() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + + +@Composable +@Preview +fun EmailLoginScreen_Preview() { + EmailLoginScreen( + email = "test@example.com", + password = rememberTextFieldState(""), + canContinue = false + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt new file mode 100644 index 0000000..5c8ceae --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt @@ -0,0 +1,64 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.DavUtils.toURIorNull +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel(assistedFactory = EmailLoginModel.Factory::class) +class EmailLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): EmailLoginModel + } + + data class UiState( + val email: String = "", + val password: TextFieldState = TextFieldState() + ) { + val uri = "mailto:$email".toURIorNull() + + val canContinue // we have to use get() because password is not immutable + get() = uri != null && password.text.toString().isNotEmpty() + + fun asLoginInfo(): LoginInfo { + return LoginInfo( + baseUri = uri, + credentials = Credentials( + username = email, + password = password.text.toSensitiveString() + ) + ) + } + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = uiState.copy( + email = initialLoginInfo.credentials?.username ?: "", + password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: "") + ) + } + + fun setEmail(email: String) { + uiState = uiState.copy(email = email) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/FastmailLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/FastmailLogin.kt new file mode 100644 index 0000000..f3c286d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/FastmailLogin.kt @@ -0,0 +1,184 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.ActivityNotFoundException +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import java.util.logging.Level +import java.util.logging.Logger + +object FastmailLogin : LoginType { + + override val title: Int + get() = R.string.login_fastmail + + override val helpUrl: Uri + get() = ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_TESTED_SERVICES) + .appendPath("fastmail") + .withStatParams("LoginTypeFastmail") + .build() + + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val model: FastmailLoginModel = hiltViewModel( + creationCallback = { factory: FastmailLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + LaunchedEffect(uiState.result) { + if (uiState.result != null) { + onLogin(uiState.result) + model.resetResult() + } + } + + LaunchedEffect(uiState.error) { + if (uiState.error != null) + snackbarHostState.showSnackbar(uiState.error) + } + + // contract to open the browser for authentication + val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> + if (authResponse != null) + model.authenticate(authResponse) + else + model.authCodeFailed() + } + + FastmailLoginScreen( + email = uiState.email, + onSetEmail = model::setEmail, + canContinue = uiState.canContinue, + onLogin = { + if (uiState.canContinue) { + val authRequest = model.signIn() + + try { + authRequestContract.launch(authRequest) + } catch (e: ActivityNotFoundException) { + Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e) + model.signInFailed() + } + } + } + ) + } +} + +@Composable +fun FastmailLoginScreen( + email: String, + onSetEmail: (String) -> Unit = {}, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + stringResource(R.string.login_fastmail), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + email, + singleLine = true, + onValueChange = onSetEmail, + leadingIcon = { + Icon(Icons.Default.Email, null) + }, + label = { Text(stringResource(R.string.login_fastmail_account)) }, + placeholder = { Text("example@fastmail.com") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + if (email.isEmpty()) + focusRequester.requestFocus() + } + + Button( + enabled = canContinue, + onClick = { onLogin() }, + modifier = Modifier + .padding(top = 8.dp) + .wrapContentSize() + ) { + Text(stringResource(R.string.login_fastmail_sign_in)) + } + } +} + +@Composable +@Preview(showBackground = true) +fun FastmailLoginScreen_Preview_Empty() { + FastmailLoginScreen( + email = "", + canContinue = false + ) +} + +@Composable +@Preview(showBackground = true) +fun FastmailLoginScreen_Preview_WithDefaultEmail() { + FastmailLoginScreen( + email = "example@gmail.com", + canContinue = true + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/FastmailLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/FastmailLoginModel.kt new file mode 100644 index 0000000..96ef0d4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/FastmailLoginModel.kt @@ -0,0 +1,114 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.network.OAuthFastmail +import at.bitfire.davdroid.network.OAuthIntegration +import at.bitfire.davdroid.settings.Credentials +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import java.util.Locale +import java.util.logging.Level +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = FastmailLoginModel.Factory::class) +class FastmailLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo, + private val authService: AuthorizationService, + @ApplicationContext val context: Context, + private val logger: Logger +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): FastmailLoginModel + } + + override fun onCleared() { + authService.dispose() + } + + + data class UiState( + val email: String = "", + val error: String? = null, + + /** login info (set after successful login) */ + val result: LoginInfo? = null + ) { + val canContinue = email.isNotEmpty() + val emailWithDomain = if (email.contains("@")) email else "$email@fastmail.com" + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = uiState.copy( + email = initialLoginInfo.credentials?.username ?: "", + error = null, + result = null + ) + } + + fun setEmail(email: String) { + uiState = uiState.copy(email = email) + } + + + fun authorizationContract() = OAuthIntegration.AuthorizationContract(authService) + + fun signIn() = + OAuthFastmail.signIn( + email = uiState.emailWithDomain, + locale = Locale.getDefault().toLanguageTag() + ) + + fun signInFailed() { + uiState = uiState.copy(error = context.getString(R.string.install_browser)) + } + + fun authenticate(authResponse: AuthorizationResponse) { + viewModelScope.launch { + try { + val credentials = Credentials(authState = OAuthIntegration.authenticate(authService, authResponse)) + + // success, provide login info to continue + uiState = uiState.copy( + result = LoginInfo( + baseUri = OAuthFastmail.baseUri, + credentials = credentials, + suggestedAccountName = uiState.emailWithDomain + ) + ) + } catch (e: Exception) { + logger.log(Level.WARNING, "Fastmail authentication failed", e) + uiState = uiState.copy(error = e.message) + } + } + } + + fun authCodeFailed() { + uiState = uiState.copy(error = context.getString(R.string.login_oauth_couldnt_obtain_auth_code)) + } + + fun resetResult() { + uiState = uiState.copy(result = null) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLogin.kt new file mode 100644 index 0000000..3dad746 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLogin.kt @@ -0,0 +1,254 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.ActivityNotFoundException +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.setup.GoogleLogin.GOOGLE_POLICY_URL +import java.util.logging.Level +import java.util.logging.Logger + +object GoogleLogin : LoginType { + + override val title: Int + get() = R.string.login_type_google + + override val helpUrl: Uri + get() = ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_TESTED_SERVICES) + .appendPath("google") + .withStatParams(javaClass.name) + .build() + + + // Google API Services User Data Policy + const val GOOGLE_POLICY_URL = + "https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes" + + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val model: GoogleLoginModel = hiltViewModel( + creationCallback = { factory: GoogleLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + LaunchedEffect(uiState.result) { + if (uiState.result != null) { + onLogin(uiState.result) + model.resetResult() + } + } + + LaunchedEffect(uiState.error) { + if (uiState.error != null) + snackbarHostState.showSnackbar(uiState.error) + } + + // contract to open the browser for authentication + val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> + if (authResponse != null) + model.authenticate(authResponse) + else + model.authCodeFailed() + } + + GoogleLoginScreen( + email = uiState.email, + onSetEmail = model::setEmail, + customClientId = uiState.customClientId, + onSetCustomClientId = model::setCustomClientId, + canContinue = uiState.canContinue, + onLogin = { + if (uiState.canContinue) { + val authRequest = model.signIn() + + try { + authRequestContract.launch(authRequest) + } catch (e: ActivityNotFoundException) { + Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e) + model.signInFailed() + } + } + } + ) + } +} + +@Composable +fun GoogleLoginScreen( + email: String, + onSetEmail: (String) -> Unit = {}, + customClientId: String, + onSetCustomClientId: (String) -> Unit = {}, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val context = LocalContext.current + + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + stringResource(R.string.login_type_google), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + email, + singleLine = true, + onValueChange = onSetEmail, + leadingIcon = { + Icon(Icons.Default.Email, null) + }, + label = { Text(stringResource(R.string.login_google_account)) }, + placeholder = { Text("example@gmail.com") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + if (email.isEmpty()) + focusRequester.requestFocus() + } + + OutlinedTextField( + customClientId, + singleLine = true, + onValueChange = onSetCustomClientId, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onLogin() } + ), + label = { Text(stringResource(R.string.login_google_client_id)) }, + placeholder = { Text("[...].apps.googleusercontent.com") }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + Button( + enabled = canContinue, + onClick = { onLogin() }, + modifier = Modifier + .padding(top = 8.dp) + .wrapContentSize() + ) { + Image( + painter = painterResource(R.drawable.google_g_logo), + contentDescription = stringResource(R.string.login_google), + modifier = Modifier.size(18.dp) + ) + Text( + text = stringResource(R.string.login_google), + modifier = Modifier.padding(start = 12.dp) + ) + } + + Spacer(Modifier.padding(8.dp)) + + val privacyPolicyUrl = ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_PRIVACY) + .withStatParams(javaClass.name) + .build() + val privacyPolicyNote = HtmlCompat.fromHtml( + stringResource( + R.string.login_google_client_privacy_policy, + context.getString(R.string.app_name), + privacyPolicyUrl.toString() + ), 0 + ).toAnnotatedString() + Text( + text = privacyPolicyNote, + style = MaterialTheme.typography.bodyMedium + ) + + val limitedUseNote = HtmlCompat.fromHtml( + stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0 + ).toAnnotatedString() + Text( + text = limitedUseNote, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 12.dp) + ) + } +} + +@Composable +@Preview(showBackground = true) +fun GoogleLoginScreen_Preview_Empty() { + GoogleLoginScreen( + email = "", + customClientId = "", + canContinue = false + ) +} + +@Composable +@Preview(showBackground = true) +fun GoogleLoginScreen_Preview_WithDefaultEmail() { + GoogleLoginScreen( + email = "example@gmail.com", + customClientId = "some-client-id", + canContinue = true + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginModel.kt new file mode 100644 index 0000000..be274ef --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginModel.kt @@ -0,0 +1,130 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.accounts.AccountManager +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.network.OAuthGoogle +import at.bitfire.davdroid.network.OAuthIntegration +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.trimToNull +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import java.util.Locale +import java.util.logging.Level +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = GoogleLoginModel.Factory::class) +class GoogleLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo, + private val authService: AuthorizationService, + @ApplicationContext val context: Context, + private val logger: Logger +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): GoogleLoginModel + } + + override fun onCleared() { + authService.dispose() + } + + + data class UiState( + val email: String = "", + val customClientId: String = "", + val error: String? = null, + + /** login info (set after successful login) */ + val result: LoginInfo? = null + ) { + val canContinue = email.isNotEmpty() + val emailWithDomain = if (email.contains("@")) email else "$email@gmail.com" + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = uiState.copy( + email = initialLoginInfo.credentials?.username ?: findGoogleAccount() ?: "", + error = null, + result = null + ) + } + + fun setEmail(email: String) { + uiState = uiState.copy(email = email) + } + + fun setCustomClientId(clientId: String) { + uiState = uiState.copy(customClientId = clientId) + } + + + fun authorizationContract() = OAuthIntegration.AuthorizationContract(authService) + + fun signIn() = + OAuthGoogle.signIn( + email = uiState.emailWithDomain, + customClientId = uiState.customClientId.trimToNull(), + locale = Locale.getDefault().toLanguageTag() + ) + + fun signInFailed() { + uiState = uiState.copy(error = context.getString(R.string.install_browser)) + } + + fun authenticate(authResponse: AuthorizationResponse) { + viewModelScope.launch { + try { + val credentials = Credentials(authState = OAuthIntegration.authenticate(authService, authResponse)) + + // success, provide login info to continue + uiState = uiState.copy( + result = LoginInfo( + baseUri = OAuthGoogle.baseUri(uiState.emailWithDomain), + credentials = credentials, + suggestedAccountName = uiState.emailWithDomain + ) + ) + } catch (e: Exception) { + logger.log(Level.WARNING, "Google authentication failed", e) + uiState = uiState.copy(error = e.message) + } + } + } + + fun authCodeFailed() { + uiState = uiState.copy(error = context.getString(R.string.login_oauth_couldnt_obtain_auth_code)) + } + + fun resetResult() { + uiState = uiState.copy(result = null) + } + + private fun findGoogleAccount(): String? { + val accountManager = AccountManager.get(context) + return accountManager + .getAccountsByType("com.google") + .map { it.name } + .firstOrNull() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt new file mode 100644 index 0000000..90bd6c0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt @@ -0,0 +1,150 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.ui.account.AccountActivity +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import dagger.hilt.android.AndroidEntryPoint +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Activity to initially connect to a server and create an account. + * Fields for server/user data can be pre-filled with extras in the Intent. + */ +@AndroidEntryPoint +class LoginActivity @Inject constructor(): AppCompatActivity() { + + @Inject lateinit var loginTypesProvider: LoginTypesProvider + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val (initialLoginType, skipLoginTypePage) = loginTypesProvider.intentToInitialLoginType(intent) + + setContent { + LoginScreen( + initialLoginType = initialLoginType, + skipLoginTypePage = skipLoginTypePage, + initialLoginInfo = loginInfoFromIntent(intent), + onNavUp = { onSupportNavigateUp() }, + onFinish = { newAccount -> + finish() + + newAccount?.let { newAccount -> + val intent = Intent(this, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, newAccount) + startActivity(intent) + } + } + ) + } + } + + companion object { + + /** + * When set, "login by URL" will be activated by default, and the URL field will be set to this value. + * When not set, "login by email" will be activated by default. + */ + const val EXTRA_URL = "url" + + /** + * When set, and {@link #EXTRA_PASSWORD} is set too, the user name field will be set to this value. + * When set, and {@link #EXTRA_URL} is not set, the email address field will be set to this value. + */ + const val EXTRA_USERNAME = "username" + + /** + * When set, the password field will be set to this value. + */ + const val EXTRA_PASSWORD = "password" + + /** + * When set, Nextcloud Login Flow will be used. + */ + const val EXTRA_LOGIN_FLOW = "loginFlow" + + + /** + * Extracts login information from given intent, validates it and returns it in [LoginInfo]. + * + * @param intent Contains base url, username and password. + * @return Extracted login info. Contains null values if given info is invalid. + */ + fun loginInfoFromIntent(intent: Intent): LoginInfo { + var givenUri: String? = null + var givenUsername: String? = null + var givenPassword: String? = null + + // extract URI or email and optionally username/password from Intent data + val logger = Logger.getGlobal() + intent.data?.normalizeScheme()?.let { uri -> + val realScheme = when (uri.scheme) { + // replace caldav[s]:// and carddav[s]:// with http[s]:// + "caldav", "carddav" -> "http" + "caldavs", "carddavs", "davx5" -> "https" + + // keep these + "http", "https", "mailto" -> uri.scheme + + // unknown scheme + else -> null + } + + when (realScheme) { + "http", "https" -> { + // extract user info + uri.userInfo?.split(':')?.let { userInfo -> + givenUsername = userInfo.getOrNull(0) + givenPassword = userInfo.getOrNull(1) + } + + // use real scheme, drop user info and fragment + givenUri = try { + URI(realScheme, null, uri.host, uri.port, uri.path, uri.query, null).toString() + } catch (_: URISyntaxException) { + logger.warning("Couldn't construct URI from login Intent data: $uri") + null + } + } + + "mailto" -> + givenUsername = uri.schemeSpecificPart + } + } + + if (givenUri == null) + givenUri = intent.getStringExtra(EXTRA_URL) + + // always prefer username/password from the extras + if (intent.hasExtra(EXTRA_USERNAME)) + givenUsername = intent.getStringExtra(EXTRA_USERNAME) + if (intent.hasExtra(EXTRA_PASSWORD)) + givenPassword = intent.getStringExtra(EXTRA_PASSWORD) + + return LoginInfo( + baseUri = try { + URI(givenUri) + } catch (_: Exception) { + null + }, + credentials = Credentials( + username = givenUsername, + password = givenPassword?.toSensitiveString() + ) + ) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt new file mode 100644 index 0000000..6cf99f8 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt @@ -0,0 +1,25 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun LoginDetailsPage( + snackbarHostState: SnackbarHostState, + model: LoginScreenModel = viewModel() +) { + val uiState = model.loginDetailsUiState + uiState.loginType.LoginScreen( + snackbarHostState = snackbarHostState, + initialLoginInfo = uiState.loginInfo, + onLogin = { loginInfo -> + model.updateLoginInfo(loginInfo) + model.navToNextPage() + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginInfo.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginInfo.kt new file mode 100644 index 0000000..385a0ad --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginInfo.kt @@ -0,0 +1,19 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.vcard4android.GroupMethod +import java.net.URI + +data class LoginInfo( + val baseUri: URI? = null, + val credentials: Credentials? = null, + + val suggestedAccountName: String? = null, + + /** group method that should be pre-selected */ + val suggestedGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS +) \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt new file mode 100644 index 0000000..c0c32e1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt @@ -0,0 +1,136 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.accounts.Account +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams + +@Composable +fun LoginScreen( + initialLoginInfo: LoginInfo = LoginInfo(), + skipLoginTypePage: Boolean = false, + initialLoginType: LoginType = UrlLogin, + onNavUp: () -> Unit, + onFinish: (Account?) -> Unit +) { + val model: LoginScreenModel = hiltViewModel { factory: LoginScreenModel.Factory -> + factory.create(initialLoginType, skipLoginTypePage, initialLoginInfo) + } + + // handle back/up navigation + BackHandler { + model.navBack() + } + if (model.finish) { + onFinish(null) + return + } + + // get specific help URL from current login type (may be null → show "tested with" page) + val loginType = model.loginTypeUiState.loginType + + LoginScreenContent( + page = model.page, + helpUri = loginType.helpUrl, + onNavUp = onNavUp, + onFinish = onFinish + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun LoginScreenContent( + page: LoginScreenModel.Page, + helpUri: Uri?, + onNavUp: () -> Unit = {}, + onFinish: (newAccount: Account?) -> Unit = {} +) { + val snackbarHostState = remember { SnackbarHostState() } + AppTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(R.string.navigate_up) + ) + } + }, + title = { + Text(stringResource(R.string.login_title)) + }, + actions = { + val uriHandler = LocalUriHandler.current + val specificHelpUri = helpUri ?: ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_TESTED_SERVICES) + .withStatParams("LoginScreen") + .build() + IconButton(onClick = { + // show tested-with page + uriHandler.openUri(specificHelpUri.toString()) + }) { + Icon(Icons.AutoMirrored.Default.Help, stringResource(R.string.help)) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box( + Modifier + .fillMaxSize() + .padding(top = padding.calculateTopPadding()) + ) { + + when (page) { + LoginScreenModel.Page.LoginType -> + LoginTypePage(snackbarHostState = snackbarHostState) + + LoginScreenModel.Page.LoginDetails -> + LoginDetailsPage(snackbarHostState = snackbarHostState) + + LoginScreenModel.Page.DetectResources -> + DetectResourcesPage() + + LoginScreenModel.Page.AccountDetails -> + AccountDetailsPage( + snackbarHostState = snackbarHostState, + onAccountCreated = { account -> + onFinish(account) + } + ) + } + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt new file mode 100644 index 0000000..f1b16d6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt @@ -0,0 +1,326 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.accounts.Account +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.di.DefaultDispatcher +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.servicedetection.DavResourceFinder +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.vcard4android.GroupMethod +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = LoginScreenModel.Factory::class) +class LoginScreenModel @AssistedInject constructor( + @Assisted val initialLoginType: LoginType, + @Assisted val skipLoginTypePage: Boolean, + @Assisted val initialLoginInfo: LoginInfo, + private val accountRepository: AccountRepository, + @ApplicationContext val context: Context, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + private val logger: Logger, + val loginTypesProvider: LoginTypesProvider, + private val resourceFinderFactory: DavResourceFinder.Factory, + settingsManager: SettingsManager +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create( + initialLoginType: LoginType, + skipLoginTypePage: Boolean, + initialLoginInfo: LoginInfo + ): LoginScreenModel + } + + enum class Page { + LoginType, + LoginDetails, + DetectResources, + AccountDetails + } + + private val startPage = if (skipLoginTypePage) + Page.LoginDetails + else + Page.LoginType + + var page by mutableStateOf(startPage) + private set + + var finish by mutableStateOf(false) + private set + + + // navigation events + + fun navToNextPage() { + when (page) { + Page.LoginType -> { + // continue to login details + loginDetailsUiState = loginDetailsUiState.copy( + loginType = loginTypeUiState.loginType + ) + page = Page.LoginDetails + } + + Page.LoginDetails -> { + // continue to resource detection + loginInfo = loginDetailsUiState.loginInfo + page = Page.DetectResources + + detectResources() + } + + Page.DetectResources -> { + // continue to account details + val emails = foundConfig?.calDAV?.emails.orEmpty().toSet() + val initialAccountName = emails.firstOrNull() + ?: loginInfo.suggestedAccountName + ?: loginInfo.credentials?.username + ?: loginInfo.baseUri?.host + ?: "" + updateAccountNameAndEmails(initialAccountName, emails) + updateGroupMethod(loginInfo.suggestedGroupMethod) + page = Page.AccountDetails + } + + Page.AccountDetails -> { + // last page + } + } + } + + fun navBack() { + when (page) { + Page.LoginType -> + finish = true + + Page.LoginDetails -> + if (loginTypesProvider.maybeNonInteractive) + finish = true + else + page = Page.LoginType + + Page.DetectResources -> { + cancelResourceDetection() + page = Page.LoginDetails + } + + Page.AccountDetails -> + page = Page.LoginDetails + } + } + + + // UI element state – first page: login type + + data class LoginTypeUiState( + val loginType: LoginType + ) + + var loginTypeUiState by mutableStateOf(LoginTypeUiState(loginType = initialLoginType)) + private set + + fun selectLoginType(loginType: LoginType) { + loginTypeUiState = loginTypeUiState.copy(loginType = loginType) + loginDetailsUiState = loginDetailsUiState.copy(loginType = loginType) + } + + + // UI element state – second page: login details + + // base URI and credentials + private var loginInfo: LoginInfo = initialLoginInfo + + data class LoginDetailsUiState( + val loginType: LoginType, + val loginInfo: LoginInfo + ) + + var loginDetailsUiState by mutableStateOf(LoginDetailsUiState( + loginType = initialLoginType, + loginInfo = loginInfo + )) + private set + + fun updateLoginInfo(loginInfo: LoginInfo) { + loginDetailsUiState = loginDetailsUiState.copy(loginInfo = loginInfo) + } + + + // UI element state – third page: detect resources + + data class DetectResourcesUiState( + val loading: Boolean = false, + val foundNothing: Boolean = false, + val encountered401: Boolean = false, + val logs: String? = null + ) + + var detectResourcesUiState by mutableStateOf(DetectResourcesUiState()) + private set + + private var foundConfig: DavResourceFinder.Configuration? = null + private var detectResourcesJob: Job? = null + + private fun detectResources() { + detectResourcesUiState = detectResourcesUiState.copy(loading = true) + detectResourcesJob = viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + runInterruptible { + resourceFinderFactory.create(loginInfo.baseUri!!, loginInfo.credentials).use { finder -> + finder.findInitialConfiguration() + } + } + } + + if (result.calDAV != null || result.cardDAV != null) { + foundConfig = result + navToNextPage() + + } else { + foundConfig = null + detectResourcesUiState = detectResourcesUiState.copy( + loading = false, + foundNothing = true, + encountered401 = result.encountered401, + logs = result.logs + ) + } + } + } + + private fun cancelResourceDetection() { + detectResourcesJob?.cancel() + } + + + // UI element state – last page: account details + + data class AccountDetailsUiState( + val accountName: String = "", + val suggestedAccountNames: Set = emptySet(), + val accountNameExists: Boolean = false, + val groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS, + val groupMethodReadOnly: Boolean = false, + val creatingAccount: Boolean = false, + val createdAccount: Account? = null, + val couldNotCreateAccount: Boolean = false + ) { + val showApostropheWarning = accountName.contains('\'') || accountName.contains('"') + } + + private val forcedGroupMethod = settingsManager + .getStringFlow(AccountSettings.KEY_CONTACT_GROUP_METHOD) + .map { groupMethodName -> + // map group method name to GroupMethod + if (groupMethodName != null) + try { + GroupMethod.valueOf(groupMethodName) + } catch (e: IllegalArgumentException) { + logger.warning("Invalid forced group method: $groupMethodName") + null + } + else + null + } + + // backing field that is combined with dynamic content for the resulting UI State + private var _accountDetailsUiState = MutableStateFlow(AccountDetailsUiState()) + val accountDetailsUiState = combine(_accountDetailsUiState, forcedGroupMethod) { uiState, method -> + // set group type to read-only if group method is forced + var combinedState = uiState.copy(groupMethodReadOnly = method != null) + + // apply forced group method, if applicable + if (method != null) + combinedState = combinedState.copy(groupMethod = method) + + combinedState + }.stateIn(viewModelScope, SharingStarted.Lazily, _accountDetailsUiState.value) + + fun updateAccountName(accountName: String) { + _accountDetailsUiState.update { currentState -> + currentState.copy( + accountName = accountName, + accountNameExists = accountRepository.exists(accountName) + ) + } + } + + fun updateAccountNameAndEmails(accountName: String, emails: Set) { + _accountDetailsUiState.update { currentState -> + currentState.copy( + accountName = accountName, + accountNameExists = accountRepository.exists(accountName), + suggestedAccountNames = emails + ) + } + } + + fun updateGroupMethod(groupMethod: GroupMethod) { + _accountDetailsUiState.update { currentState -> + currentState.copy(groupMethod = groupMethod) + } + } + + fun resetCouldNotCreateAccount() { + _accountDetailsUiState.update { currentState -> + currentState.copy(couldNotCreateAccount = false) + } + } + + fun createAccount() { + _accountDetailsUiState.update { currentState -> + currentState.copy(creatingAccount = true) + } + + viewModelScope.launch { + val account = withContext(defaultDispatcher) { + accountRepository.createBlocking( + accountDetailsUiState.value.accountName, + loginInfo.credentials, + foundConfig!!, + accountDetailsUiState.value.groupMethod + ) + } + + _accountDetailsUiState.update { currentState -> + if (account != null) + currentState.copy(createdAccount = account) + else + currentState.copy( + creatingAccount = false, + couldNotCreateAccount = true + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginType.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginType.kt new file mode 100644 index 0000000..88de996 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginType.kt @@ -0,0 +1,25 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.net.Uri +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable + +interface LoginType { + + val title: Int + + /** Optional URL to a provider-specific help page. */ + val helpUrl: Uri? + + @Composable + fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypePage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypePage.kt new file mode 100644 index 0000000..1d5dcfb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypePage.kt @@ -0,0 +1,32 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun LoginTypePage( + snackbarHostState: SnackbarHostState, + model: LoginScreenModel = viewModel() +) { + val uiState = model.loginTypeUiState + + // show login type selection page + model.loginTypesProvider.LoginTypePage( + snackbarHostState = snackbarHostState, + selectedLoginType = uiState.loginType, + onSelectLoginType = { loginType -> + model.selectLoginType(loginType) + }, + setInitialLoginInfo = { loginInfo -> + model.updateLoginInfo(loginInfo) + }, + onContinue = { + model.navToNextPage() + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypesProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypesProvider.kt new file mode 100644 index 0000000..6faa126 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypesProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable + +interface LoginTypesProvider { + + data class LoginAction( + val loginType: LoginType, + val skipLoginTypePage: Boolean + ) + + val defaultLoginType: LoginType + + /** + * Which login type to use and whether to skip the login type page. Used for Nextcloud login + * flow and may be used for other intent started flows. + */ + fun intentToInitialLoginType(intent: Intent): LoginAction = LoginAction(defaultLoginType, false) + + /** Whether the [LoginTypePage] may be non-interactive. This causes it to be skipped in back navigation. */ + val maybeNonInteractive: Boolean + get() = false + + @Composable + fun LoginTypePage( + snackbarHostState: SnackbarHostState, + selectedLoginType: LoginType, + onSelectLoginType: (LoginType) -> Unit, + setInitialLoginInfo: (LoginInfo) -> Unit, + onContinue: () -> Unit + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLogin.kt new file mode 100644 index 0000000..8080137 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLogin.kt @@ -0,0 +1,240 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +import android.net.Uri +import android.provider.Browser +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.ProgressBar +import kotlinx.coroutines.launch + +object NextcloudLogin : LoginType { + + override val title: Int + get() = R.string.login_type_nextcloud + + override val helpUrl: Uri + get() = ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_TESTED_SERVICES) + .appendPath("nextcloud") + .withStatParams(javaClass.simpleName) + .build() + + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val model: NextcloudLoginModel = hiltViewModel( + creationCallback = { factory: NextcloudLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val context = LocalContext.current + val checkResultCallback = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + model.onReturnFromBrowser() + } + + val uiState = model.uiState + LaunchedEffect(uiState.loginUrl) { + if (uiState.loginUrl != null) { + val loginUri = uiState.loginUrl.toString().toUri() + + if (haveCustomTabs(context)) { + // Custom Tabs are available + @Suppress("DEPRECATION") + val browser = CustomTabsIntent.Builder() + .build() + browser.intent.data = loginUri + browser.intent.putExtra( + Browser.EXTRA_HEADERS, + bundleOf("Accept-Language" to Locale.current.toLanguageTag()) + ) + checkResultCallback.launch(browser.intent) + } else { + // fallback: launch normal browser + val browser = Intent(Intent.ACTION_VIEW, loginUri) + browser.addCategory(Intent.CATEGORY_BROWSABLE) + if (browser.resolveActivity(context.packageManager) != null) { + checkResultCallback.launch(browser) + } else + this@LaunchedEffect.launch { + snackbarHostState.showSnackbar(context.getString(R.string.install_browser)) + } + } + } + } + + // continue to resource detection when result is set in model + LaunchedEffect(uiState.result) { + if (uiState.result != null) { + onLogin(uiState.result) + model.resetResult() + } + } + + NextcloudLoginScreen( + baseUrl = uiState.baseUrl, + onUpdateBaseUrl = { model.updateBaseUrl(it) }, + canContinue = uiState.canContinue, + inProgress = uiState.inProgress, + error = uiState.error, + onLogin = { model.startLoginFlow() } + ) + } + +} + +@Composable +fun NextcloudLoginScreen( + baseUrl: String, + onUpdateBaseUrl: (String) -> Unit = {}, + canContinue: Boolean, + inProgress: Boolean, + error: String? = null, + onLogin: () -> Unit = {} +) { + Assistant( + nextLabel = stringResource(R.string.login_login), + nextEnabled = canContinue, + onNext = onLogin + ) { + if (inProgress) + ProgressBar( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_nextcloud_login_with_nextcloud), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Column { + Text( + stringResource(R.string.login_nextcloud_login_flow_text), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = baseUrl, + onValueChange = onUpdateBaseUrl, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .focusRequester(focusRequester), + enabled = !inProgress, + leadingIcon = { + Icon(Icons.Default.Cloud, null) + }, + label = { + Text(stringResource(R.string.login_nextcloud_login_flow_server_address)) + }, + placeholder = { Text("cloud.example.com") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onLogin() } + ), + singleLine = true + ) + LaunchedEffect(Unit) { + if (baseUrl.isEmpty()) + focusRequester.requestFocus() + } + + if (error != null) + Card(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(8.dp) + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp) + ) + Text( + error, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } +} + +@Composable +@Preview +fun NextcloudLoginScreen_Preview() { + NextcloudLoginScreen( + baseUrl = "cloud.example.com", + canContinue = true, + inProgress = false, + error = null + ) +} + +@Composable +@Preview +fun NextcloudLoginScreen_Preview_InProgressError() { + NextcloudLoginScreen( + baseUrl = "cloud.example.com", + canContinue = true, + inProgress = true, + error = "Some Error" + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt new file mode 100644 index 0000000..bee5072 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt @@ -0,0 +1,180 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.network.NextcloudLoginFlow +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.logging.Level +import java.util.logging.Logger + +@HiltViewModel(assistedFactory = NextcloudLoginModel.Factory::class) +class NextcloudLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo, + @ApplicationContext val context: Context, + private val logger: Logger, + private val loginFlow: NextcloudLoginFlow +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): NextcloudLoginModel + } + + /*companion object { + const val STATE_POLL_URL = "poll_url" + const val STATE_TOKEN = "token" + }*/ + + data class UiState( + val baseUrl: String = "", + val inProgress: Boolean = false, + val error: String? = null, + + /** URL to open in the browser (set during Login Flow) */ + val loginUrl: HttpUrl? = null, + + /** login info (set after successful login) */ + val result: LoginInfo? = null + ) { + + val baseHttpUrl: HttpUrl? = run { + val baseUrlWithPrefix = + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) + baseUrl + else + "https://$baseUrl" + + baseUrlWithPrefix.toHttpUrlOrNull() + } + + val canContinue = !inProgress && baseHttpUrl != null + + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + val baseUri = initialLoginInfo.baseUri + if (baseUri != null) + uiState = uiState.copy( + baseUrl = baseUri.toString() + .removePrefix("https://") + .removeSuffix(NextcloudLoginFlow.FLOW_V1_PATH) + .removeSuffix(NextcloudLoginFlow.FLOW_V2_PATH) + .removeSuffix(NextcloudLoginFlow.DAV_PATH) + ) + + uiState = uiState.copy( + error = null, + result = null + ) + } + + fun updateBaseUrl(baseUrl: String) { + uiState = uiState.copy(baseUrl = baseUrl) + } + + // Login flow state + /*private var pollUrl: HttpUrl? + get() = state.get(STATE_POLL_URL)?.toHttpUrlOrNull() + set(value) { + state[STATE_POLL_URL] = value.toString() + } + private var token: String? + get() = state.get(STATE_TOKEN) + set(value) { + state[STATE_TOKEN] = value + }*/ + + override fun onCleared() { + loginFlow.close() + } + + + /** + * Starts the Login Flow. + */ + fun startLoginFlow() { + val baseUrl = uiState.baseHttpUrl + if (uiState.inProgress || baseUrl == null) + return + + uiState = uiState.copy( + inProgress = true, + error = null + ) + + viewModelScope.launch { + try { + val loginUrl = loginFlow.initiate(baseUrl) + + uiState = uiState.copy( + loginUrl = loginUrl, + inProgress = false + ) + + } catch (e: Exception) { + logger.log(Level.WARNING, "Initiating Login Flow failed", e) + + uiState = uiState.copy( + inProgress = false, + error = e.toString() + ) + } + } + } + + /** + * Called when the custom tab / browser activity is finished. If memory is low, our + * [NextcloudLogin] and its model have been cleared in the meanwhile. So if + * we need certain data from the model, we have to make sure that these data are retained when the + * model is cleared (saved state). + */ + fun onReturnFromBrowser() = viewModelScope.launch { + // Login Flow has been started in browser by UI, should not be started again + uiState = uiState.copy( + loginUrl = null, + inProgress = true + ) + + val loginInfo = try { + loginFlow.fetchLoginInfo() + } catch (e: Exception) { + logger.log(Level.WARNING, "Fetching login info failed", e) + uiState = uiState.copy( + inProgress = false, + error = e.toString() + ) + return@launch + } + + uiState = uiState.copy( + inProgress = false, + result = loginInfo + ) + } + + fun resetResult() { + uiState = uiState.copy( + loginUrl = null, + result = null + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt new file mode 100644 index 0000000..9a5291b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt @@ -0,0 +1,184 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.hilt.navigation.compose.hiltViewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.PasswordTextField + +object UrlLogin : LoginType { + + override val title + get() = R.string.login_type_url + + override val helpUrl: Uri? + get() = null + + @Composable + override fun LoginScreen( + snackbarHostState: SnackbarHostState, + initialLoginInfo: LoginInfo, + onLogin: (LoginInfo) -> Unit + ) { + val model: UrlLoginModel = hiltViewModel( + creationCallback = { factory: UrlLoginModel.Factory -> + factory.create(loginInfo = initialLoginInfo) + } + ) + + val uiState = model.uiState + UrlLoginScreen( + url = uiState.url, + onSetUrl = model::setUrl, + username = uiState.username, + onSetUsername = model::setUsername, + password = uiState.password, + canContinue = uiState.canContinue, + onLogin = { + if (uiState.canContinue) + onLogin(uiState.asLoginInfo()) + } + ) + } + +} + +@Composable +fun UrlLoginScreen( + url: String, + onSetUrl: (String) -> Unit = {}, + username: String, + onSetUsername: (String) -> Unit = {}, + password: TextFieldState, + canContinue: Boolean, + onLogin: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + + Assistant( + nextLabel = stringResource(R.string.login_login), + nextEnabled = canContinue, + onNext = onLogin + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_type_url), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + + val manualUrl = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_ACCOUNTS_COLLECTIONS) + .fragment(ExternalUris.Manual.FRAGMENT_SERVICE_DISCOVERY) + .build() + val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT) + Text( + text = urlInfo.toAnnotatedString(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp) + ) + + OutlinedTextField( + value = url, + onValueChange = onSetUrl, + label = { Text(stringResource(R.string.login_base_url)) }, + placeholder = { Text("dav.example.com/path") }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Folder, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + OutlinedTextField( + value = username, + onValueChange = onSetUsername, + label = { Text(stringResource(R.string.login_user_name)) }, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.AccountCircle, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + modifier = Modifier.fillMaxWidth() + ) + + PasswordTextField( + password = password, + labelText = stringResource(R.string.login_password), + leadingIcon = { + Icon(Icons.Default.Password, null) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + onKeyboardAction = { + if (canContinue) + onLogin() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +@Preview +fun UrlLoginScreen_Preview() { + UrlLoginScreen( + url = "https://example.com", + username = "user", + password = rememberTextFieldState(""), + canContinue = false + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt new file mode 100644 index 0000000..7f0de6d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt @@ -0,0 +1,77 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.DavUtils.toURIorNull +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import at.bitfire.davdroid.util.trimToNull +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel(assistedFactory = UrlLoginModel.Factory::class) +class UrlLoginModel @AssistedInject constructor( + @Assisted val initialLoginInfo: LoginInfo +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(loginInfo: LoginInfo): UrlLoginModel + } + + data class UiState( + val url: String = "", + val username: String = "", + val password: TextFieldState = TextFieldState() + ) { + + val urlWithPrefix = + if (url.startsWith("http://") || url.startsWith("https://")) + url + else + "https://$url" + val uri = urlWithPrefix.trim().toURIorNull() + + val canContinue // we have to use get() because password is not immutable + get() = uri != null && username.isNotEmpty() && password.text.toString().isNotEmpty() + + fun asLoginInfo(): LoginInfo = + LoginInfo( + baseUri = uri, + credentials = Credentials( + username = username.trimToNull(), + password = password.text.toString().trimToNull()?.toSensitiveString() + ) + ) + + } + + var uiState by mutableStateOf(UiState()) + private set + + init { + uiState = UiState( + url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: "", + username = initialLoginInfo.credentials?.username ?: "", + password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: "") + ) + } + + fun setUrl(url: String) { + uiState = uiState.copy(url = url) + } + + fun setUsername(username: String) { + uiState = uiState.copy(username = username) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt new file mode 100644 index 0000000..4f3d84d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt @@ -0,0 +1,26 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AddWebdavMountActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AddWebdavMountScreen( + onNavUp = { onSupportNavigateUp() }, + onFinish = { finish() } + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt new file mode 100644 index 0000000..c945122 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt @@ -0,0 +1,108 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import at.bitfire.davdroid.util.trimToNull +import at.bitfire.davdroid.webdav.WebDavMountRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import javax.inject.Inject + +@HiltViewModel +class AddWebdavMountModel @Inject constructor( + @ApplicationContext val context: Context, + val db: AppDatabase, + private val mountRepository: WebDavMountRepository +): ViewModel() { + + data class UiState( + val isLoading: Boolean = false, + val success: Boolean = false, + val error: String? = null, + val displayName: String = "", + val url: String = "", + val username: String = "", + val password: TextFieldState = TextFieldState(), + val certificateAlias: String? = null + ) { + val urlWithPrefix = + if (url.startsWith("http://", true) || url.startsWith("https://", true)) + url + else + "https://$url" + val httpUrl = urlWithPrefix.toHttpUrlOrNull() + val canContinue = displayName.isNotBlank() && httpUrl != null + } + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun setDisplayName(displayName: String) { + uiState = uiState.copy(displayName = displayName) + } + + fun setUrl(url: String) { + uiState = uiState.copy(url = url) + } + + fun setUsername(username: String) { + uiState = uiState.copy(username = username) + } + + fun setCertificateAlias(certAlias: String) { + uiState = uiState.copy(certificateAlias = certAlias) + } + + + fun addMount() { + if (uiState.isLoading) + return + val url = uiState.httpUrl ?: return + uiState = uiState.copy(isLoading = true) + + val displayName = uiState.displayName + val credentials = Credentials( + username = uiState.username.trimToNull(), + password = uiState.password.text.trimToNull()?.toSensitiveString(), + certificateAlias = uiState.certificateAlias + ) + + viewModelScope.launch { + var error: String? = null + try { + if (!mountRepository.addMount(url, displayName, credentials)) + error = context.getString(R.string.webdav_add_mount_no_support) + else { + uiState = uiState.copy(success = true) + + // refresh quota + mountRepository.refreshAllQuota() + } + } catch (e: Exception) { + error = e.localizedMessage + } + + uiState = uiState.copy(isLoading = false, error = error) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt new file mode 100644 index 0000000..1e136af --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt @@ -0,0 +1,266 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.Assistant +import at.bitfire.davdroid.ui.composable.PasswordTextField +import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard + +@Composable +fun AddWebdavMountScreen( + onNavUp: () -> Unit = {}, + onFinish: () -> Unit = {}, + model: AddWebdavMountModel = viewModel() +) { + val uiState = model.uiState + + if (uiState.success) { + onFinish() + return + } + + AppTheme { + AddWebDavMountScreen( + isLoading = uiState.isLoading, + error = uiState.error, + onResetError = model::resetError, + displayName = uiState.displayName, + onSetDisplayName = model::setDisplayName, + url = uiState.url, + onSetUrl = model::setUrl, + username = uiState.username, + onSetUsername = model::setUsername, + password = uiState.password, + certificateAlias = uiState.certificateAlias, + onSetCertificateAlias = model::setCertificateAlias, + canContinue = uiState.canContinue, + onAddMount = { model.addMount() }, + onNavUp = onNavUp + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun AddWebDavMountScreen( + isLoading: Boolean, + error: String?, + onResetError: () -> Unit = {}, + displayName: String, + onSetDisplayName: (String) -> Unit = {}, + url: String, + onSetUrl: (String) -> Unit = {}, + username: String, + onSetUsername: (String) -> Unit = {}, + password: TextFieldState, + certificateAlias: String?, + onSetCertificateAlias: (String) -> Unit = {}, + canContinue: Boolean, + onAddMount: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(error) { + if (error != null) { + snackbarHostState.showSnackbar(error) + onResetError() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + }, + title = { Text(stringResource(R.string.webdav_add_mount_title)) }, + actions = { + val uriHandler = LocalUriHandler.current + IconButton( + onClick = { + uriHandler.openUri(webdavMountsHelpUrl().toString()) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.help) + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Assistant( + nextLabel = stringResource(R.string.webdav_add_mount_add), + nextEnabled = canContinue && !isLoading, + isLoading = isLoading, + onNext = onAddMount + ) { + Column( + modifier = Modifier + .padding(paddingValues) + .padding(8.dp) + ) { + val focusRequester = remember { FocusRequester() } + + Text( + text = stringResource(R.string.webdav_add_mount_mountpoint_displayname), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + OutlinedTextField( + label = { Text(stringResource(R.string.webdav_add_mount_url)) }, + leadingIcon = { Icon(Icons.Default.Cloud, contentDescription = null) }, + placeholder = { Text("dav.example.com") }, + value = url, + onValueChange = onSetUrl, + singleLine = true, + enabled = !isLoading, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Uri + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + label = { Text(stringResource(R.string.webdav_add_mount_display_name)) }, + value = displayName, + onValueChange = onSetDisplayName, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Sell, null) + }, + enabled = !isLoading, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + + Text( + text = stringResource(R.string.webdav_add_mount_authentication), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + OutlinedTextField( + label = { Text(stringResource(R.string.login_user_name_optional)) }, + value = username, + onValueChange = onSetUsername, + singleLine = true, + leadingIcon = { + Icon(Icons.Default.AccountCircle, null) + }, + enabled = !isLoading, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Email + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + PasswordTextField( + password = password, + labelText = stringResource(R.string.login_password_optional), + enabled = !isLoading, + leadingIcon = { + Icon(Icons.Default.Password, null) + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + onKeyboardAction = { + // can only be called when not loading + if (canContinue) + onAddMount() + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + SelectClientCertificateCard( + snackbarHostState = snackbarHostState, + enabled = !isLoading, + chosenAlias = certificateAlias, + onAliasChosen = onSetCertificateAlias, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + } + } + } +} + +@Composable +@Preview +fun AddWebDavMountScreen_Preview() { + AppTheme { + AddWebDavMountScreen( + isLoading = true, + error = null, + displayName = "Test", + url = "https://example.com", + username = "user", + password = rememberTextFieldState("password"), + certificateAlias = null, + canContinue = true + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt new file mode 100644 index 0000000..4c47088 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt @@ -0,0 +1,29 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class WebdavMountsActivity: AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + WebdavMountsScreen( + onAddWebdavMount = { + startActivity(Intent(this, AddWebdavMountActivity::class.java)) + }, + onNavUp = { onSupportNavigateUp() } + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt new file mode 100644 index 0000000..dab513c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt @@ -0,0 +1,62 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.webdav.WebDavMountRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WebdavMountsModel @Inject constructor( + private val mountRepository: WebDavMountRepository +): ViewModel() { + + private val mounts = mountRepository.getAllFlow() + + // UI state + val mountInfos = mountRepository.getAllWithRootFlow() + var refreshingQuota by mutableStateOf(false) + private set + + init { + // refresh quota as soon as mounts are available + viewModelScope.launch { + mounts.collect { + refreshQuota() + } + } + } + + /** + * Refreshes quota of all mounts (causes progress bar to be shown during refresh). + */ + fun refreshQuota() { + if (refreshingQuota) + return + refreshingQuota = true + + viewModelScope.launch { + mountRepository.refreshAllQuota() + refreshingQuota = false + } + } + + /** + * Removes the mountpoint locally (= deletes connection information). + */ + fun remove(mount: WebDavMount) { + viewModelScope.launch { + mountRepository.delete(mount) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt new file mode 100644 index 0000000..1a3d94d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt @@ -0,0 +1,423 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.text.format.Formatter +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.ShareCompat +import androidx.core.text.HtmlCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.db.WebDavMountWithQuota +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.ProgressBar +import at.bitfire.davdroid.util.DavUtils +import kotlinx.coroutines.delay +import okhttp3.HttpUrl + +@Composable +fun WebdavMountsScreen( + onAddWebdavMount: () -> Unit, + onNavUp: () -> Unit, + model: WebdavMountsModel = viewModel() +) { + val mountInfos by model.mountInfos.collectAsStateWithLifecycle(emptyList()) + + AppTheme { + WebdavMountsScreen( + mountInfos = mountInfos, + refreshingQuota = model.refreshingQuota, + onRefreshQuota = { + model.refreshQuota() + }, + onAddMount = onAddWebdavMount, + onRemoveMount = { mount -> + model.remove(mount) + }, + onNavUp = onNavUp + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun WebdavMountsScreen( + mountInfos: List, + refreshingQuota: Boolean = false, + onRefreshQuota: () -> Unit = {}, + onAddMount: () -> Unit = {}, + onRemoveMount: (WebDavMount) -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val uriHandler = LocalUriHandler.current + + var isRefreshing by remember { mutableStateOf(false) } + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(300) + isRefreshing = false + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton( + onClick = onNavUp + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + title = { Text(stringResource(R.string.webdav_mounts_title)) }, + actions = { + IconButton( + onClick = { + uriHandler.openUri(webdavMountsHelpUrl().toString()) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.help) + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onAddMount, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.webdav_add_mount_add) + ) + } + } + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { isRefreshing = true; onRefreshQuota() }, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (mountInfos.isEmpty()) + HintText() + else { + Column { + if (refreshingQuota) + ProgressBar( + modifier = Modifier + .fillMaxWidth() + .height(4.dp)) + else + Spacer(Modifier.height(4.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + ) { + items(mountInfos, key = { it.mount.id }, contentType = { "mount" }) { + WebdavMountsItem( + info = it, + onRemoveMount = onRemoveMount + ) + } + } + } + } + } + } +} + +@Composable +fun HintText() { + Column( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(align = Alignment.Center) + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.webdav_mounts_empty), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + + val text = HtmlCompat.fromHtml( + stringResource( + R.string.webdav_add_mount_empty_more_info, + webdavMountsHelpUrl().toString() + ), + 0 + ).toAnnotatedString() + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +fun WebdavMountsItem( + info: WebDavMountWithQuota, + onRemoveMount: (WebDavMount) -> Unit = {}, +) { + var showingDialog by remember { mutableStateOf(false) } + if (showingDialog) { + AlertDialog( + onDismissRequest = { showingDialog = false }, + title = { Text(stringResource(R.string.webdav_remove_mount_title)) }, + text = { Text(stringResource(R.string.webdav_remove_mount_text)) }, + confirmButton = { + Button( + onClick = { + onRemoveMount(info.mount) + } + ) { + Text(stringResource(R.string.dialog_remove)) + } + }, + dismissButton = { + OutlinedButton( + onClick = { showingDialog = false } + ) { + Text(stringResource(R.string.dialog_deny)) + } + } + ) + } + + ElevatedCard( + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = info.mount.name, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = info.mount.url.toString(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + + val quotaUsed = info.quotaUsed + val quotaAvailable = info.quotaAvailable + if (quotaUsed != null && quotaAvailable != null) { + val quotaTotal = quotaUsed + quotaAvailable + val progress = quotaUsed.toFloat() / quotaTotal + ProgressBar( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + val context = LocalContext.current + Text( + text = stringResource( + R.string.webdav_mounts_quota_used_available, + Formatter.formatFileSize(context, quotaUsed), + Formatter.formatFileSize(context, quotaAvailable) + ), + modifier = Modifier.fillMaxWidth() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val context = LocalContext.current + + val browser = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + result.data?.data?.let { uri -> + ShareCompat.IntentBuilder(context) + .setType(DavUtils.MIME_TYPE_ACCEPT_ALL) + .addStream(uri) + .startChooser() + } + } + + Button( + onClick = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + val uri = DocumentsContract.buildRootUri(context.getString(R.string.webdav_authority), info.mount.id.toString()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) + } + browser.launch(intent) + } + ) { + Text( + text = stringResource(R.string.webdav_mounts_share_content) + ) + } + Spacer(Modifier.weight(1f)) + IconButton( + onClick = { showingDialog = true } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.webdav_mounts_unmount) + ) + } + } + } + } +} + +@Composable +@Preview +fun WebdavMountsScreen_Preview_Empty() { + AppTheme { + WebdavMountsScreen( + mountInfos = emptyList(), + refreshingQuota = false + ) + } +} + +@Composable +@Preview +fun WebdavMountsScreen_Preview_TwoMounts() { + AppTheme { + WebdavMountsScreen( + mountInfos = listOf( + WebDavMountWithQuota( + mount = WebDavMount( + id = 0, + name = "Preview Webdav Mount 1", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ), + WebDavMountWithQuota( + mount = WebDavMount( + id = 1, + name = "Preview Webdav Mount 2", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ) + ), + refreshingQuota = true + ) + } +} + +@Composable +@Preview +fun WebdavMountsItem_Preview() { + AppTheme { + WebdavMountsItem( + info = WebDavMountWithQuota( + mount = WebDavMount( + id = 0, + name = "Preview Webdav Mount", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ) + ) + } +} + + +fun webdavMountsHelpUrl(): Uri = ExternalUris.Manual.baseUrl.buildUpon() + .appendPath(ExternalUris.Manual.PATH_WEBDAV_MOUNTS) + .build() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/CalendarColorPickerDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/CalendarColorPickerDialog.kt new file mode 100644 index 0000000..ff0d2ab --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/CalendarColorPickerDialog.kt @@ -0,0 +1,62 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.widget + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import at.bitfire.synctools.icalendar.Css3Color + +@OptIn(ExperimentalLayoutApi::class) +@Composable +@Preview +fun CalendarColorPickerDialog( + onSelectColor: (color: Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val colors = remember { + Css3Color.entries.sortedBy { css3Color -> + Color(css3Color.argb).luminance() + } + } + + Dialog(onDismissRequest = onDismiss) { + Card(Modifier.verticalScroll(rememberScrollState())) { + FlowRow(Modifier.padding(8.dp)) { + for (color in colors) { + Box(Modifier.padding(2.dp)) { + Box(Modifier + .background(color = Color(color.argb), shape = CircleShape) + .clickable { onSelectColor(color.argb) } + .size(32.dp) + .padding(8.dp) + .semantics { + contentDescription = color.name + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/IconSyncButtonWidget.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/IconSyncButtonWidget.kt new file mode 100644 index 0000000..16f283b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/IconSyncButtonWidget.kt @@ -0,0 +1,84 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.widget + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.M3ColorScheme +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * A widget with a "Sync all" button displaying just an icon to indicate the action. + */ +class IconSyncButtonWidget : GlanceAppWidget() { + + // Hilt over @AndroidEntryPoint is not available for widgets + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncButtonWidgetEntryPoint { + fun model(): SyncWidgetModel + } + + + override suspend fun provideGlance(context: Context, id: GlanceId) { + // initial data + val entryPoint = EntryPointAccessors.fromApplication(context) + val model = entryPoint.model() + + // will be called when the widget is updated + provideContent { + WidgetContent(model) + } + } + + @Composable + private fun WidgetContent(model: SyncWidgetModel) { + val context = LocalContext.current + + Box( + modifier = GlanceModifier + .size(50.dp) + .background(ColorProvider(M3ColorScheme.primaryLight)) + .cornerRadius(25.dp) + .clickable { + model.requestSync() + Toast.makeText(context, R.string.sync_started, Toast.LENGTH_SHORT).show() + }, + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(R.drawable.ic_sync), + contentDescription = context.getString(R.string.widget_sync_all_accounts), + modifier = GlanceModifier.fillMaxSize().size(32.dp), + colorFilter = ColorFilter.tint( + ColorProvider(M3ColorScheme.onPrimaryLight) + ) + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/IconSyncButtonWidgetReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/IconSyncButtonWidgetReceiver.kt new file mode 100644 index 0000000..1b5f2b3 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/IconSyncButtonWidgetReceiver.kt @@ -0,0 +1,12 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class IconSyncButtonWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = IconSyncButtonWidget() +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/LabeledSyncButtonWidget.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/LabeledSyncButtonWidget.kt new file mode 100644 index 0000000..859a6a6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/LabeledSyncButtonWidget.kt @@ -0,0 +1,100 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.widget + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.Text +import androidx.glance.text.TextDefaults +import androidx.glance.unit.ColorProvider +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.M3ColorScheme +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * A widget with a "Sync all" button displaying an icon and a label. + */ +class LabeledSyncButtonWidget : GlanceAppWidget() { + + // Hilt over @AndroidEntryPoint is not available for widgets + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncButtonWidgetEntryPoint { + fun model(): SyncWidgetModel + } + + + override suspend fun provideGlance(context: Context, id: GlanceId) { + // initial data + val entryPoint = EntryPointAccessors.fromApplication(context) + val model = entryPoint.model() + + // will be called when the widget is updated + provideContent { + WidgetContent(model) + } + } + + @Composable + private fun WidgetContent(model: SyncWidgetModel) { + val context = LocalContext.current + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .background(ColorProvider(M3ColorScheme.primaryLight)) + .cornerRadius(16.dp) + .padding(4.dp) + .clickable { + model.requestSync() + Toast.makeText(context, R.string.sync_started, Toast.LENGTH_SHORT).show() + }, + verticalAlignment = Alignment.CenterVertically + ) { + val onPrimary = ColorProvider(M3ColorScheme.onPrimaryLight) + Image( + provider = ImageProvider(R.drawable.ic_sync), + contentDescription = context.getString(R.string.widget_sync_all_accounts), + modifier = GlanceModifier + .padding(vertical = 8.dp, horizontal = 8.dp) + .size(32.dp), + colorFilter = ColorFilter.tint(onPrimary) + ) + Text( + text = context.getString(R.string.widget_sync_all), + modifier = GlanceModifier + .defaultWeight() + .padding(end = 8.dp), + style = TextDefaults.defaultTextStyle.copy( + color = onPrimary, + fontSize = 16.sp + ) + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/LabeledSyncButtonWidgetReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/LabeledSyncButtonWidgetReceiver.kt new file mode 100644 index 0000000..a32d7f3 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/LabeledSyncButtonWidgetReceiver.kt @@ -0,0 +1,12 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class LabeledSyncButtonWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = LabeledSyncButtonWidget() +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/SyncWidgetModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/SyncWidgetModel.kt new file mode 100644 index 0000000..376e9b2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/SyncWidgetModel.kt @@ -0,0 +1,28 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.widget + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.sync.worker.SyncWorkerManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SyncWidgetModel @Inject constructor( + private val accountRepository: AccountRepository, + @ApplicationContext val context: Context, + private val syncWorkerManager: SyncWorkerManager +): ViewModel() { + + fun requestSync() = viewModelScope.launch(Dispatchers.Default) { + for (account in accountRepository.getAll()) + syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true) + } + +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/BroadcastReceiverFlow.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/BroadcastReceiverFlow.kt new file mode 100644 index 0000000..1f38c44 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/BroadcastReceiverFlow.kt @@ -0,0 +1,80 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import java.util.logging.Logger + +/** + * Creates a flow that emits the respective [Intent] when a broadcast is received. + * + * @param context the context to register the receiver with + * @param filter specifies which broadcasts shall be received + * @param flags flags to pass to [Context.registerReceiver] (usually [ContextCompat.RECEIVER_EXPORTED] or + * [ContextCompat.RECEIVER_NOT_EXPORTED]; `null` if only system broadcasts are received) + * @param immediate if `true`, send an empty [Intent] as first value + * + * @return cold flow of [Intent]s + */ +@SuppressLint("UnspecifiedRegisterReceiverFlag") +fun broadcastReceiverFlow( + context: Context, + filter: IntentFilter, + flags: Int? = null, + immediate: Boolean +): Flow = callbackFlow { + val logger = Logger.getGlobal() + + val receiver = object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + logger.fine("broadcastReceiverFlow received $intent") + trySend(intent) + } + } + + // register receiver + var filterDump = filter.toString() + filter.dump({ filterDump = it }, "") + logger.fine("Registering broadcast receiver for $filterDump (flags=$flags)") + if (flags != null) + ContextCompat.registerReceiver(context, receiver, filter, null, null, flags) + else + context.registerReceiver(receiver, filter) + + // send empty Intent as first value, if requested + if (immediate) + trySend(Intent()) + + // wait until flow is cancelled, then clean up + awaitClose { + logger.fine("Unregistering broadcast receiver for $filterDump") + context.unregisterReceiver(receiver) + } +} + +/** + * Creates a flow that emits the Intent when a package is added, changed or removed. + * + * @param context the context to register the receiver with + * @param immediate if `true`, send an empty [Intent] as first value + * + * @return cold flow of [Intent]s + */ +fun packageChangedFlow(context: Context, immediate: Boolean = true): Flow { + val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply { + addAction(Intent.ACTION_PACKAGE_CHANGED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + return broadcastReceiverFlow(context = context, filter = filter, immediate = immediate) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt new file mode 100644 index 0000000..818f399 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt @@ -0,0 +1,93 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import java.net.URI +import java.net.URISyntaxException +import java.util.Locale + +/** + * Some WebDAV and HTTP network utility methods. + */ +object DavUtils { + + const val MIME_TYPE_ACCEPT_ALL = "*/*" + + val MEDIA_TYPE_JCARD = "application/vcard+json".toMediaType() + val MEDIA_TYPE_OCTET_STREAM = "application/octet-stream".toMediaType() + val MEDIA_TYPE_VCARD = "text/vcard".toMediaType() + + /** + * Builds an HTTP `Accept` header that accepts anything (*/*), but optionally + * specifies a preference. + * + * @param preferred preferred MIME type (optional) + * + * @return `media-range` for `Accept` header that accepts anything, but prefers [preferred] (if it was specified) + */ + fun acceptAnything(preferred: MediaType?): String = + if (preferred != null) + "$preferred, $MIME_TYPE_ACCEPT_ALL;q=0.8" + else + MIME_TYPE_ACCEPT_ALL + + @Suppress("FunctionName") + fun ARGBtoCalDAVColor(colorWithAlpha: Int): String { + val alpha = (colorWithAlpha shr 24) and 0xFF + val color = colorWithAlpha and 0xFFFFFF + return String.format(Locale.ROOT, "#%06X%02X", color, alpha) + } + + + // extension methods + + val HttpUrl.lastSegment: String + get() = pathSegments.lastOrNull { it.isNotEmpty() } ?: "/" + + /** + * Returns parent URL (parent folder). Always with trailing slash + */ + fun HttpUrl.parent(): HttpUrl { + if (pathSegments.size == 1 && pathSegments[0] == "") + // already root URL + return this + + val builder = newBuilder() + + if (pathSegments[pathSegments.lastIndex] == "") { + // URL ends with a slash ("/some/thing/" -> ["some","thing",""]), remove two segments ("" at lastIndex and "thing" at lastIndex - 1) + builder.removePathSegment(pathSegments.lastIndex) + builder.removePathSegment(pathSegments.lastIndex - 1) + } else + // URL doesn't end with a slash ("/some/thing" -> ["some","thing"]), remove one segment ("thing" at lastIndex) + builder.removePathSegment(pathSegments.lastIndex) + + // append trailing slash + builder.addPathSegment("") + + return builder.build() + } + + /** + * Compares MIME type and subtype of two MediaTypes. Does _not_ compare parameters + * like `charset` or `version`. + * + * @param other MediaType to compare with + * + * @return *true* if type and subtype match; *false* if they don't + */ + fun MediaType.sameTypeAs(other: MediaType) = + type == other.type && subtype == other.subtype + + fun String.toURIorNull(): URI? = try { + URI(this) + } catch (_: URISyntaxException) { + null + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt new file mode 100644 index 0000000..437a703 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt @@ -0,0 +1,158 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.Uri +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.location.LocationManagerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.bitfire.davdroid.BuildConfig +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import java.util.logging.Logger + +object PermissionUtils { + + /** There's an undocumented intent that is sent when the battery optimization whitelist changes. */ + const val ACTION_POWER_SAVE_WHITELIST_CHANGED = "android.os.action.POWER_SAVE_WHITELIST_CHANGED" + + val CONTACT_PERMISSIONS = arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + val CALENDAR_PERMISSIONS = arrayOf( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + ) + + val WIFI_SSID_PERMISSIONS = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + else -> + arrayOf() + } + + /** + * Checks whether all conditions to access the current WiFi's SSID are met: + * + * 1. location permissions ([WIFI_SSID_PERMISSIONS]) granted (Android 8.1+) + * 2. location enabled (Android 9+) + * + * @return *true* if SSID can be obtained; *false* if the SSID will be or something like that + */ + fun canAccessWifiSsid(context: Context): Boolean { + // before Android 8.1, SSIDs are always readable + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) + return true + + val locationAvailable = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + true // Android <9 doesn't require active location services + else + context.getSystemService()?.let { locationManager -> + LocationManagerCompat.isLocationEnabled(locationManager) + } ?: /* location feature not available on this device */ false + + return havePermissions(context, WIFI_SSID_PERMISSIONS) && + locationAvailable + } + + /** + * Returns a live state of whether all conditions to access the current WiFi's SSID are met: + * + * 1. location permissions ([WIFI_SSID_PERMISSIONS]) granted (Android 8.1+) + * 2. location enabled (Android 9+) + * + * @return `true` if SSID can be obtained reliably; `false` otherwise (SSID will be "unknown" or something like that) + */ + @Composable + @OptIn(ExperimentalPermissionsApi::class) + fun rememberCanAccessWifiSsid(): State { + // before Android 8.1, SSIDs are always readable + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) + return remember { mutableStateOf(true) } + + val locationAvailableFlow = + // Android 9+: dynamically check whether Location is enabled + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + locationEnabledFlow(LocalContext.current) + else + // Android <9 doesn't require active Location to read the SSID + flowOf(true) + val locationAvailable by locationAvailableFlow.collectAsStateWithLifecycle(false) + + val permissions = rememberMultiplePermissionsState(WIFI_SSID_PERMISSIONS.toList()) + + return remember { + derivedStateOf { + locationAvailable && permissions.allPermissionsGranted + } + } + } + + private fun locationEnabledFlow(context: Context): Flow = + broadcastReceiverFlow( + context, + IntentFilter(LocationManager.MODE_CHANGED_ACTION), + null, + immediate = true + ).map { + val locationManager = context.getSystemService()!! + LocationManagerCompat.isLocationEnabled(locationManager) + } + + /** + * Checks whether at least one of the given permissions is granted. + * + * @param context context to check + * @param permissions array of permissions to check + * + * @return whether at least one of [permissions] is granted + */ + fun haveAnyPermission(context: Context, permissions: Array) = + permissions.any { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + + /** + * Checks whether all given permissions are granted. + * + * @param context context to check + * @param permissions array of permissions to check + * + * @return whether all [permissions] are granted + */ + fun havePermissions(context: Context, permissions: Array) = + permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + + fun showAppSettings(context: Context) { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", BuildConfig.APPLICATION_ID, null)) + if (intent.resolveActivity(context.packageManager) != null) + context.startActivity(intent) + else + Logger.getGlobal().warning("App settings Intent not resolvable") + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt new file mode 100644 index 0000000..6912fe6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt @@ -0,0 +1,69 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +/** + * Wrapper for passwords and other sensitive strings so that they're not directly [String]s, + * so that they're less likely to be used in clear-text unintentionally, like being printed in logs + * by [Any.toString]. + * + * This class does not address the issue that clear-text passwords are stored in memory. This problem + * could only be reduced if we would consequently store and process only encrypted passwords, with the + * exception of some "providePassword" method that provides the clear-text password for a lambda function as + * [CharArray] and wipes out the array values after usage. + * + * See also: + * + * - https://stackoverflow.com/a/8889285 + * - https://javaee.github.io/security-api/apidocs/javax/security/enterprise/credential/Password.html and + * https://javaee.github.io/security-api/apidocs/javax/security/enterprise/credential/UsernamePasswordCredential.html + */ +class SensitiveString private constructor( + private val data: String +) { + + /** + * Returns the sensitive string as a [CharArray]. + * + * _Be careful when using it (for instance, don't print its content unintentionally)._ + */ + fun asCharArray() = data.toCharArray() + + /** + * Returns the sensitive string as an immutable [String]. + * + * _Be careful when using it (for instance, don't print it unintentionally)._ + */ + fun asString() = data + + + // make comparable by data + + override fun equals(other: Any?) = + if (other is SensitiveString) + data == other.data + else + false + + override fun hashCode() = data.hashCode() + + + /** + * Overrides [toString] so that it doesn't expose the clear-text string (password). + */ + override fun toString() = "*****" + + + companion object { + + fun CharArray.toSensitiveString() = + SensitiveString(this.concatToString()) + + fun CharSequence.toSensitiveString() = + SensitiveString(this.toString()) + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt new file mode 100644 index 0000000..90626e2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt @@ -0,0 +1,15 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import com.google.common.base.Strings + +fun CharSequence?.trimToNull() = Strings.emptyToNull(this?.trim()?.toString()) + +fun String.withTrailingSlash() = + if (this.endsWith('/')) + this + else + "$this/" \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt new file mode 100644 index 0000000..4114548 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt @@ -0,0 +1,75 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.content.Context +import androidx.annotation.StringDef +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class CredentialsStore @Inject constructor( + @ApplicationContext context: Context +) { + + @Retention(AnnotationRetention.SOURCE) + @StringDef( + HAS_CREDENTIALS, + USER_NAME, + PASSWORD, + CERTIFICATE_ALIAS + ) + annotation class KeyName + + companion object { + const val HAS_CREDENTIALS = "has_credentials" + const val USER_NAME = "user_name" + const val PASSWORD = "password" + const val CERTIFICATE_ALIAS = "certificate_alias" + } + + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + private val prefs = EncryptedSharedPreferences.create(context, "webdav_credentials", masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) + + + fun getCredentials(mountId: Long): Credentials? { + if (!prefs.getBoolean(keyName(mountId, HAS_CREDENTIALS), false)) + return null + + return Credentials( + prefs.getString(keyName(mountId, USER_NAME), null), + prefs.getString(keyName(mountId, PASSWORD), null)?.toSensitiveString(), + prefs.getString(keyName(mountId, CERTIFICATE_ALIAS), null) + ) + } + + fun setCredentials(mountId: Long, credentials: Credentials?) { + prefs.edit { + if (credentials != null) + putBoolean(keyName(mountId, HAS_CREDENTIALS), true) + .putString(keyName(mountId, USER_NAME), credentials.username) + .putString(keyName(mountId, PASSWORD), credentials.password?.asString()) + .putString(keyName(mountId, CERTIFICATE_ALIAS), credentials.certificateAlias) + else + remove(keyName(mountId, HAS_CREDENTIALS)) + .remove(keyName(mountId, USER_NAME)) + .remove(keyName(mountId, PASSWORD)) + .remove(keyName(mountId, CERTIFICATE_ALIAS)) + } + } + + + private fun keyName(mountId: Long, @KeyName name: String) = + "$mountId.$name" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt new file mode 100644 index 0000000..b2b80a8 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt @@ -0,0 +1,106 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.graphics.Point +import android.os.CancellationSignal +import android.provider.DocumentsProvider +import at.bitfire.davdroid.webdav.operation.CopyDocumentOperation +import at.bitfire.davdroid.webdav.operation.CreateDocumentOperation +import at.bitfire.davdroid.webdav.operation.DeleteDocumentOperation +import at.bitfire.davdroid.webdav.operation.IsChildDocumentOperation +import at.bitfire.davdroid.webdav.operation.MoveDocumentOperation +import at.bitfire.davdroid.webdav.operation.OpenDocumentOperation +import at.bitfire.davdroid.webdav.operation.OpenDocumentThumbnailOperation +import at.bitfire.davdroid.webdav.operation.QueryChildDocumentsOperation +import at.bitfire.davdroid.webdav.operation.QueryDocumentOperation +import at.bitfire.davdroid.webdav.operation.QueryRootsOperation +import at.bitfire.davdroid.webdav.operation.RenameDocumentOperation +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * Provides functionality on WebDav documents. + * + * Hilt constructor injection can't be used for content providers because SingletonComponent + * may not ready yet when the content provider is created. So we use an explicit EntryPoint. + * + * Note: A DocumentsProvider is a ContentProvider and thus has no well-defined lifecycle. It + * is created by Android when it's first accessed and then stays in memory until the process + * is killed. + */ +class DavDocumentsProvider: DocumentsProvider() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface DavDocumentsProviderEntryPoint { + fun copyDocumentOperation(): CopyDocumentOperation + fun createDocumentOperation(): CreateDocumentOperation + fun deleteDocumentOperation(): DeleteDocumentOperation + fun isChildDocumentOperation(): IsChildDocumentOperation + fun moveDocumentOperation(): MoveDocumentOperation + fun openDocumentOperation(): OpenDocumentOperation + fun openDocumentThumbnailOperation(): OpenDocumentThumbnailOperation + fun queryChildDocumentsOperation(): QueryChildDocumentsOperation + fun queryDocumentOperation(): QueryDocumentOperation + fun queryRootsOperation(): QueryRootsOperation + fun renameDocumentOperation(): RenameDocumentOperation + } + + private val entryPoint: DavDocumentsProviderEntryPoint by lazy { + EntryPointAccessors.fromApplication(context!!) + } + + + override fun onCreate() = true + + /* Note: shutdown() is NOT called automatically by Android; a content provider lives until + the process is killed. */ + + + /*** query ***/ + + override fun queryRoots(projection: Array?) = + entryPoint.queryRootsOperation().invoke(projection) + + override fun queryDocument(documentId: String, projection: Array?) = + entryPoint.queryDocumentOperation().invoke(documentId, projection) + + override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?) = + entryPoint.queryChildDocumentsOperation().invoke(parentDocumentId, projection, sortOrder) + + override fun isChildDocument(parentDocumentId: String, documentId: String) = + entryPoint.isChildDocumentOperation().invoke(parentDocumentId, documentId) + + + /*** copy/create/delete/move/rename ***/ + + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String) = + entryPoint.copyDocumentOperation().invoke(sourceDocumentId, targetParentDocumentId) + + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? = + entryPoint.createDocumentOperation().invoke(parentDocumentId, mimeType, displayName) + + override fun deleteDocument(documentId: String) = + entryPoint.deleteDocumentOperation().invoke(documentId) + + override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String) = + entryPoint.moveDocumentOperation().invoke(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId) + + override fun renameDocument(documentId: String, displayName: String): String? = + entryPoint.renameDocumentOperation().invoke(documentId, displayName) + + + /*** read/write ***/ + + override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?) = + entryPoint.openDocumentOperation().invoke(documentId, mode, signal) + + override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?) = + entryPoint.openDocumentThumbnailOperation().invoke(documentId, sizeHint, signal) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt new file mode 100644 index 0000000..9f6e8d0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt @@ -0,0 +1,49 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.network.MemoryCookieStore +import okhttp3.CookieJar +import okhttp3.logging.HttpLoggingInterceptor +import javax.inject.Inject +import javax.inject.Provider + +class DavHttpClientBuilder @Inject constructor( + private val credentialsStore: CredentialsStore, + private val httpClientBuilder: Provider, +) { + + /** + * Creates an HTTP client that can be used to access resources in the given mount. + * + * @param mountId ID of the mount to access + * @param logBody whether to log the body of HTTP requests (disable for potentially large files) + */ + fun build(mountId: Long, logBody: Boolean = true): HttpClient { + val cookieStore = cookieStores.getOrPut(mountId) { + MemoryCookieStore() + } + val builder = httpClientBuilder.get() + .loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS) + .setCookieStore(cookieStore) + + credentialsStore.getCredentials(mountId)?.let { credentials -> + builder.authenticate(host = null, getCredentials = { credentials }) + } + + return builder.build() + } + + + companion object { + + /** in-memory cookie stores (one per mount ID) that are available until the content + * provider (= process) is terminated */ + private val cookieStores = mutableMapOf() + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt new file mode 100644 index 0000000..19cb461 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt @@ -0,0 +1,90 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.app.AuthenticationRequiredException +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.DocumentsContract.buildChildDocumentsUri +import android.provider.DocumentsContract.buildRootsUri +import android.webkit.MimeTypeMap +import androidx.core.app.TaskStackBuilder +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity +import java.io.FileNotFoundException + +object DocumentProviderUtils { + + const val MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS = 5 + + internal fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String { + val safeName = displayName.filterNot { it.isISOControl() } + + if (appendNumber != 0) { + val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName) + if (extension != null) { + val baseName = safeName.removeSuffix(".$extension") + return "${baseName}_$appendNumber.$extension" + } else + return "${safeName}_$appendNumber" + } else + return safeName + } + + internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) { + if (parentDocumentId != null) + context.contentResolver.notifyChange( + buildChildDocumentsUri( + context.getString(R.string.webdav_authority), + parentDocumentId.toString() + ), + null + ) + } + + internal fun notifyFolderChanged(context: Context, parentDocumentId: String) { + context.contentResolver.notifyChange( + buildChildDocumentsUri( + context.getString(R.string.webdav_authority), + parentDocumentId + ), + null + ) + } + + internal fun notifyMountsChanged(context: Context) { + context.contentResolver.notifyChange( + buildRootsUri(context.getString(R.string.webdav_authority)), + null) + } + +} + +internal fun HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) { + when (statusCode) { + 401 -> { + if (Build.VERSION.SDK_INT >= 26) { + val intent = Intent(context, WebdavMountsActivity::class.java) + throw AuthenticationRequiredException( + this, + TaskStackBuilder.create(context) + .addNextIntentWithParentStack(intent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + ) + } + } + 404 -> + throw FileNotFoundException() + 412 -> + if (ignorePreconditionFailed) + return + } + + // re-throw + throw this +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentSortByMapper.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentSortByMapper.kt new file mode 100644 index 0000000..a873521 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentSortByMapper.kt @@ -0,0 +1,81 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.provider.DocumentsContract.Document +import at.bitfire.davdroid.db.WebDavDocument +import java.util.logging.Logger +import javax.inject.Inject + +class DocumentSortByMapper @Inject constructor( + private val logger: Logger +) { + + /** + * Contains a map that maps the column names from the documents provider to [WebDavDocument]. + */ + private val columnsMap = mapOf( + Document.COLUMN_DOCUMENT_ID to "id", + Document.COLUMN_DISPLAY_NAME to "displayName", + Document.COLUMN_MIME_TYPE to "mimeType", + Document.COLUMN_SIZE to "size", + Document.COLUMN_LAST_MODIFIED to "lastModified" + ) + + /** + * Maps an incoming `orderBy` column from a [android.content.ContentProvider] query to + * a validated SQL ORDER BY-clause for [WebDavDocument]s. + * + * @param orderBy orderBy of content provider documents + * + * @return value of the ORDER BY-clause, like "name ASC" + */ + fun mapContentProviderToSql(orderBy: String): String { + val requestedFields = orderBy + // Split by commas to divide each order column + .split(',') + // Trim any leading or trailing spaces + .map { it.trim() } + + // Map incoming orderBy fields to a list of pairs of column and direction (true for ASC), like + // [ Pair("displayName", Boolean), … ] + val requestedCriteria = mutableListOf>() + for (field in requestedFields) { + val idx = field.indexOfFirst { it == ' ' } + if (idx == -1) + // no whitespace, only name → use ASC as default, like in SQL + requestedCriteria += field to true + else { + // whitespace, name and sort order + val name = field.substring(0, idx) + val directionStr = field.substring(idx).trim() + val ascending = directionStr.equals("ASC", true) + requestedCriteria += name to ascending + } + } + + // If displayName doesn't appear in sort order, append it in case that the other criteria generate + // the same order. For instance, if files are ordered by ascending size and all have 0 bytes, then + // another order by name is useful. + if (!requestedCriteria.any { it.first == Document.COLUMN_DISPLAY_NAME }) + requestedCriteria += Document.COLUMN_DISPLAY_NAME to true + + // Generate SQL + val sqlSortBy = mutableListOf() // list of valid SQL ORDER BY elements like "displayName ASC" + for ((requestedColumn, ascending) in requestedCriteria) { + // Only take columns that are registered in the columns map + val sqlFieldName = columnsMap[requestedColumn] + if (sqlFieldName == null) { + logger.warning("Ignoring unknown column in sortOrder: $requestedColumn") + continue + } + + // Finally, convert the column name from document to room, including the sort direction. + sqlSortBy += "$sqlFieldName ${if (ascending) "ASC" else "DESC"}" + } + return sqlSortBy.joinToString(", ") + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentState.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentState.kt new file mode 100644 index 0000000..0a8d0cf --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentState.kt @@ -0,0 +1,25 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import java.time.Instant + +data class DocumentState( + val eTag: String? = null, + val lastModified: Instant? = null +) { + + init { + if (eTag == null && lastModified == null) + throw IllegalArgumentException("Either ETag or Last-Modified is required") + } + + override fun toString() = + if (eTag != null) + "eTag=$eTag" + else + "lastModified=$lastModified" + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentsCursor.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentsCursor.kt new file mode 100644 index 0000000..06cc25e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentsCursor.kt @@ -0,0 +1,40 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.database.MatrixCursor +import android.os.Bundle +import android.provider.DocumentsContract + +class DocumentsCursor(columns: Array): MatrixCursor(columns) { + + private val documentsExtras = Bundle(1) + + override fun getExtras() = documentsExtras + + + var error: String? + get() = documentsExtras.getString(DocumentsContract.EXTRA_ERROR) + set(value) = documentsExtras.putString(DocumentsContract.EXTRA_ERROR, value) + + var info: String? + get() = documentsExtras.getString(DocumentsContract.EXTRA_INFO) + set(value) = documentsExtras.putString(DocumentsContract.EXTRA_INFO, value) + + var loading: Boolean + get() = documentsExtras.getBoolean(DocumentsContract.EXTRA_LOADING, false) + set(value) = documentsExtras.putBoolean(DocumentsContract.EXTRA_LOADING, value) + + + fun addRow(bundle: Bundle) { + newRow().also { row -> + for (entry in bundle.keySet()) { + val value = bundle.get(entry) + row.add(entry, value) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt new file mode 100644 index 0000000..db221dc --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt @@ -0,0 +1,67 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import androidx.annotation.WorkerThread +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.davdroid.network.HttpClient +import okhttp3.HttpUrl +import java.time.Instant + +/** + * Represents the information that was retrieved via a HEAD request before + * accessing the file. + */ +data class HeadResponse( + val size: Long? = null, + val eTag: String? = null, + val lastModified: Instant? = null, + + val supportsPartial: Boolean? = null +) { + + companion object { + + @WorkerThread + fun fromUrl(client: HttpClient, url: HttpUrl): HeadResponse { + var size: Long? = null + var eTag: String? = null + var lastModified: Instant? = null + var supportsPartial: Boolean? = null + + DavResource(client.okHttpClient, url).head { response -> + response.header("ETag", null)?.let { + val getETag = GetETag(it) + if (!getETag.weak) + eTag = getETag.eTag + } + response.header("Last-Modified", null)?.let { + lastModified = HttpUtils.parseDate(it) + } + response.headers["Content-Length"]?.let { + size = it.toLong() + } + response.headers["Accept-Ranges"]?.let { acceptRangesStr -> + val acceptRanges = acceptRangesStr.split(',').map { it.trim().lowercase() } + when { + acceptRanges.contains("none") -> supportsPartial = false + acceptRanges.contains("bytes") -> supportsPartial = true + } + } + } + return HeadResponse(size, eTag, lastModified, supportsPartial) + } + + } + + fun toDocumentState(): DocumentState? = + if (eTag != null || lastModified != null) + DocumentState(eTag, lastModified) + else + null + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/PagingReader.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/PagingReader.kt new file mode 100644 index 0000000..800522f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/PagingReader.kt @@ -0,0 +1,131 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import androidx.annotation.RequiresApi +import com.google.common.cache.LoadingCache +import java.io.IOException +import java.util.logging.Logger +import kotlin.math.min + +/** + * Splits a resource into pages (segments) so that read accesses can be cached per page. + * + * For instance, if [fileSize] is 3 MB and [pageSize] is 2 MB, multiple read requests within the + * first 2 MB will cause only the first page (0 – 2 MB) to be loaded once and then fulfilled + * from within the cache. For requests between 2 MB and 3 MB, the second page (and in this case last) + * is loaded and used. + * + * @param fileSize file size (must not change between read operations) + * @param pageSize page size (big enough to cache efficiently, small enough to avoid unnecessary traffic and spare memory) + * @param pageCache [LoadingCache] that loads page content from the actual data source + */ +@RequiresApi(26) +class PagingReader( + private val fileSize: Long, + private val pageSize: Int, + private val pageCache: LoadingCache +) { + + val logger: Logger = Logger.getLogger(javaClass.name) + + + /** + * Represents a loaded page (meta information + data). + */ + class CachedPage( + val idx: Long, + val start: Long, + val end: Long, + val data: ByteArray + ) + + /** currently loaded page */ + private var currentPage: CachedPage? = null + + /** + * Reads a given number of bytes from a given position. + * + * Will split the request into multiple page access operations, if necessary. + * + * @param offset starting position + * @param size number of bytes to read + * @param dst destination where data are read into + * + * @return number of bytes read (may be smaller than [size] if the file is not that big) + */ + fun read(offset: Long, size: Int, dst: ByteArray): Int { + // input validation + if (offset > fileSize) + throw IndexOutOfBoundsException() + var remaining = min(size.toLong(), fileSize - offset).toInt() + + var transferred = 0 + while (remaining > 0) { + val nrBytes = readPage(offset + transferred, remaining, dst, transferred) + if (nrBytes == 0) // EOF + break + transferred += nrBytes + remaining -= nrBytes + } + + return transferred + } + + /** + * Tries to read a given number of bytes from a given position, but stays + * within one page – it will not read across two pages. + * + * This method will determine the page that contains [position] and read only + * from this page. + * + * This method is synchronized so that no concurrent modifications of [currentPage] + * and no concurrent calls to [pageCache] will be made. + * + * @param position starting position + * @param size number of bytes requested + * @param dst destination where data are read into + * @param dstOffset starting offset within destination array + * + * @return number of bytes read (may be less than [size] when the page ends before); + * 0 guarantees that there are no more bytes (EOF) + */ + @Synchronized + fun readPage(position: Long, size: Int, dst: ByteArray, dstOffset: Int): Int { + logger.fine("read(position=$position, size=$size, dstOffset=$dstOffset)") + + // read max. 1 page + val pgIdx = position / pageSize + val page = currentPage?.takeIf { it.idx == pgIdx } ?: run { + val pgStart = pgIdx * pageSize + val pgEnd = min((pgIdx + 1) * pageSize, fileSize) + val pgSize = (pgEnd - pgStart).toInt() + + val pageData = + if (pgSize == 0) + ByteArray(0) // don't load 0-byte pages + else + pageCache.get(RandomAccessCallback.PageIdentifier(offset = pgStart, size = pgSize)) + if (pageData.size != pgSize) + throw IOException("Couldn't fetch whole file segment (expected $pgSize bytes, got ${pageData.size} bytes)") + + val newPage = CachedPage(pgIdx, pgStart, pgEnd, pageData) + currentPage = newPage + newPage + } + + val pgSize = (page.end - page.start).toInt() + logger.fine("pgIdx=${page.idx}, pgStart=${page.start}, pgEnd=${page.end}, pgSize=$pgSize") + + val inPageStart = (position - page.start).toInt() + val len = min(pgSize - inPageStart, size) // use the remaining number of bytes in the page, or less if less were requested + logger.fine("inPageStart=$inPageStart, len=$len") + + System.arraycopy(page.data, inPageStart, dst, dstOffset, len) + + return len + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt new file mode 100644 index 0000000..a4c391b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt @@ -0,0 +1,229 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import android.os.ParcelFileDescriptor +import android.os.ProxyFileDescriptorCallback +import android.os.storage.StorageManager +import android.system.ErrnoException +import android.system.OsConstants +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.util.DavUtils +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.google.common.cache.LoadingCache +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType +import java.io.InterruptedIOException +import java.util.logging.Logger +import javax.annotation.WillClose + +@RequiresApi(26) +class RandomAccessCallback @AssistedInject constructor( + @Assisted @WillClose private val httpClient: HttpClient, + @Assisted private val url: HttpUrl, + @Assisted private val mimeType: MediaType?, + @Assisted headResponse: HeadResponse, + @Assisted private val externalScope: CoroutineScope, + @ApplicationContext private val context: Context, + private val logger: Logger +): ProxyFileDescriptorCallback() { + + companion object { + + /** + * WebDAV resources will be read in chunks of this size (or less at the end of the file). + */ + const val MAX_PAGE_SIZE = 2 * 1024*1024 // 2 MB + + } + + @AssistedFactory + interface Factory { + fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallback + } + + data class PageIdentifier( + val offset: Long, + val size: Int + ) + + private val dav = DavResource(httpClient.okHttpClient, url) + + private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size") + private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified") + + private val pageLoader = PageLoader(externalScope) + private val pageCache: LoadingCache = CacheBuilder.newBuilder() + .maximumSize(10) // don't cache more than 10 entries (MAX_PAGE_SIZE each) + .softValues() // use SoftReference for the page contents so they will be garbage-collected if memory is needed + .build(pageLoader) // fetch actual content using pageLoader + + /** This thread will be used for I/O operations like [onRead]. Using the main looper would cause ANRs. */ + private val ioThread = HandlerThread("WebDAV I/O").apply { + start() + } + + private val pagingReader = PagingReader(fileSize, MAX_PAGE_SIZE, pageCache) + + + // file descriptor + + /** + * Returns a random-access file descriptor that can be used in a DocumentsProvider. + */ + fun fileDescriptor(): ParcelFileDescriptor { + val storageManager = context.getSystemService()!! + val ioHandler = Handler(ioThread.looper) + return storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, this, ioHandler) + } + + + // implementation + + override fun onFsync() { /* not used */ } + + override fun onGetSize(): Long = runBlockingFd("onGetFileSize") { + logger.fine("onGetFileSize $url") + fileSize + } + + override fun onRead(offset: Long, size: Int, data: ByteArray) = runBlockingFd("onRead") { + logger.fine("onRead $url $offset $size") + pagingReader.read(offset, size, data) + } + + override fun onWrite(offset: Long, size: Int, data: ByteArray): Int { + logger.fine("onWrite $url $offset $size") + // ranged write requests not supported by WebDAV (yet) + throw ErrnoException("onWrite", OsConstants.EROFS) + } + + override fun onRelease() { + logger.fine("onRelease") + + // free resources + ioThread.quitSafely() + httpClient.close() + } + + + // scope / cancellation + + /** + * Runs blocking in [externalScope]. + * + * Exceptions (including [CancellationException]) are wrapped in an [ErrnoException], as expected by the file + * descriptor / Storage Access Framework. + * + * @param functionName name of the operation, passed to [ErrnoException] in case of cancellation + */ + private fun runBlockingFd(functionName: String, block: () -> T): T = + runBlocking { + try { + externalScope.async { + block() + }.await() + } catch (e: CancellationException) { + logger.warning("Random file access cancelled in $functionName, throwing ErrnoException(EINTR)") + throw ErrnoException(functionName, OsConstants.EINTR, e) + } catch (e: Throwable) { + throw e.toErrNoException("onRead") + } + } + + private fun Throwable.toErrNoException(functionName: String) = + ErrnoException( + functionName, + when (this) { + is HttpException -> + when (statusCode) { + 403 -> OsConstants.EPERM + 404 -> OsConstants.ENOENT + else -> OsConstants.EIO + } + is IndexOutOfBoundsException -> OsConstants.ENXIO // no such [device or] address, see man lseek (2) + is InterruptedIOException -> OsConstants.EINTR + is PartialContentNotSupportedException -> OsConstants.EOPNOTSUPP + else -> OsConstants.EIO + }, + this + ) + + + /** + * Responsible for loading (= downloading) a single page from the WebDAV resource. + * + * @param scope cancellable scope the loader runs in (loader cancels I/O) when this scope is cancelled + */ + inner class PageLoader( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ): CacheLoader() { + + override fun load(key: PageIdentifier) = runBlocking { + scope.async(ioDispatcher) { + loadAsync(key) + }.await() + } + + private suspend fun loadAsync(key: PageIdentifier): ByteArray { + val offset = key.offset + val size = key.size + logger.fine("Loading page $url $offset/$size") + + val ifMatch: Headers = + documentState.eTag?.let { eTag -> + Headers.headersOf("If-Match", "\"$eTag\"") + } ?: documentState.lastModified?.let { lastModified -> + Headers.headersOf("If-Unmodified-Since", HttpUtils.formatDate(lastModified)) + } ?: throw DavException("ETag/Last-Modified required for random access") + + return runInterruptible { // network I/O that should be cancelled by Thread interruption + var result: ByteArray? = null + dav.getRange( + DavUtils.acceptAnything(preferred = mimeType), + offset, + size, + ifMatch + ) { response -> + if (response.code == 200) // server doesn't support ranged requests + throw PartialContentNotSupportedException() + else if (response.code != 206) + throw HttpException(response) + + result = response.body.bytes() + } + result ?: throw DavException("No response body") + } + } + + } + + + class PartialContentNotSupportedException: Exception() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt new file mode 100644 index 0000000..2d2ab9e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt @@ -0,0 +1,88 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.os.ProxyFileDescriptorCallback +import android.system.ErrnoException +import android.system.OsConstants +import androidx.annotation.RequiresApi +import at.bitfire.davdroid.network.HttpClient +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import okhttp3.HttpUrl +import okhttp3.MediaType + +/** + * Use this wrapper to ensure that all memory is released as soon as [onRelease] is called. + * + * - (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak: + * the given callback is registered in `com.android.internal.os.AppFuseMount` (which adds it to + * a [Map]), but is not unregistered anymore. So it stays in the memory until the whole mount + * is unloaded. See https://issuetracker.google.com/issues/208788568. + * - (2024/08/24) [Fixed in Android.](https://android.googlesource.com/platform/frameworks/base/+/e7dbf78143ba083af7a8ecadd839a9dbf6f01655%5E%21/#F0) + * + * **All fields of objects of this class must be set to `null` when [onRelease] is called!** + * Otherwise they will leak memory. + * + * @param httpClient HTTP client ([RandomAccessCallbackWrapper] is responsible to close it) + */ +@RequiresApi(26) +class RandomAccessCallbackWrapper @AssistedInject constructor( + @Assisted httpClient: HttpClient, + @Assisted url: HttpUrl, + @Assisted mimeType: MediaType?, + @Assisted headResponse: HeadResponse, + @Assisted externalScope: CoroutineScope, + callbackFactory: RandomAccessCallback.Factory +): ProxyFileDescriptorCallback() { + + @AssistedFactory + interface Factory { + fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallbackWrapper + } + + + // callback reference + + /** + * This field is initialized with a strong reference to the callback. It is cleared when + * [onRelease] is called so that the garbage collector can remove the actual [RandomAccessCallback]. + */ + private var callbackRef: RandomAccessCallback? = + callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope) + + private fun requireCallback(functionName: String): RandomAccessCallback = + callbackRef ?: throw ErrnoException(functionName, OsConstants.EBADF) + + + // non-interface delegates + + fun fileDescriptor() = + requireCallback("fileDescriptor").fileDescriptor() + + + // delegating implementation of ProxyFileDescriptorCallback + + override fun onFsync() { /* not used */ } + + override fun onGetSize() = + requireCallback("onGetSize").onGetSize() + + override fun onRead(offset: Long, size: Int, data: ByteArray) = + requireCallback("onRead").onRead(offset, size, data) + + override fun onWrite(offset: Long, size: Int, data: ByteArray) = + requireCallback("onWrite").onWrite(offset, size, data) + + override fun onRelease() { + requireCallback("onRelease").onRelease() + + // remove reference to allow garbage collection + callbackRef = null + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt new file mode 100644 index 0000000..50cb1f4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -0,0 +1,137 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.os.ParcelFileDescriptor +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.util.DavUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger +import javax.annotation.WillClose + +/** + * @param client HTTP client ([StreamingFileDescriptor] is responsible to close it) + */ +class StreamingFileDescriptor @AssistedInject constructor( + @Assisted @WillClose private val client: HttpClient, + @Assisted private val url: HttpUrl, + @Assisted private val mimeType: MediaType?, + @Assisted private val externalScope: CoroutineScope, + @Assisted private val finishedCallback: OnSuccessCallback, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + @AssistedFactory + interface Factory { + fun create(client: HttpClient, url: HttpUrl, mimeType: MediaType?, externalScope: CoroutineScope, finishedCallback: OnSuccessCallback): StreamingFileDescriptor + } + + val dav = DavResource(client.okHttpClient, url) + var transferred: Long = 0 + + fun download() = doStreaming(false) + fun upload() = doStreaming(true) + + private fun doStreaming(upload: Boolean): ParcelFileDescriptor { + val (readFd, writeFd) = ParcelFileDescriptor.createReliablePipe() + + var success = false + externalScope.launch { + try { + if (upload) + uploadNow(readFd) + else + downloadNow(writeFd) + + success = true + } catch (e: HttpException) { + logger.log(Level.WARNING, "HTTP error when opening remote file", e) + writeFd.closeWithError("${e.statusCode} ${e.message}") + } catch (e: Exception) { + logger.log(Level.INFO, "Couldn't serve file (not necessarily an error)", e) + writeFd.closeWithError(e.message) + } finally { + // close pipe + try { + readFd.close() + writeFd.close() + } catch (_: IOException) {} + + client.close() + finishedCallback.onFinished(transferred, success) + } + } + + return if (upload) + writeFd + else + readFd + } + + /** + * Downloads a WebDAV resource. + * + * @param writeFd destination file descriptor (could for instance represent a local file) + */ + private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) { + dav.get(DavUtils.acceptAnything(preferred = mimeType), null) { response -> + response.body.use { body -> + if (response.isSuccessful) { + ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { destination -> + body.byteStream().use { source -> + transferred += source.copyTo(destination) + } + logger.finer("Downloaded $transferred byte(s) from $url") + } + + } else + writeFd.closeWithError("${response.code} ${response.message}") + } + } + } + + /** + * Uploads a WebDAV resource. + * + * @param readFd source file descriptor (could for instance represent a local file) + */ + private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) { + val body = object: RequestBody() { + override fun contentType(): MediaType? = mimeType + override fun isOneShot() = true + override fun writeTo(sink: BufferedSink) { + ParcelFileDescriptor.AutoCloseInputStream(readFd).use { input -> + transferred += input.copyTo(sink.outputStream()) + logger.finer("Uploaded $transferred byte(s) to $url") + } + } + } + DavResource(client.okHttpClient, url).put(body) { + // upload successful + } + } + + + fun interface OnSuccessCallback { + fun onFinished(transferred: Long, success: Boolean) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt new file mode 100644 index 0000000..def00da --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -0,0 +1,151 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.content.Context +import android.provider.DocumentsContract +import androidx.annotation.VisibleForTesting +import at.bitfire.dav4jvm.DavResource +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.settings.Credentials +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import javax.inject.Inject +import javax.inject.Provider + +class WebDavMountRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val httpClientBuilder: Provider +) { + + private val mountDao = db.webDavMountDao() + private val documentDao = db.webDavDocumentDao() + + /** authority of our WebDAV document provider ([DavDocumentsProvider]) */ + private val authority = context.getString(R.string.webdav_authority) + + /** + * Checks whether an HTTP endpoint supports WebDAV and if it does, adds it as a new WebDAV mount. + * + * @param url URL of the HTTP endpoint + * @param displayName display name of the mount + * @param credentials credentials to use for the mount + * + * @return `true` if the mount was added successfully, `false` if the endpoint doesn't support WebDAV + */ + suspend fun addMount( + url: HttpUrl, + displayName: String, + credentials: Credentials? + ): Boolean { + val webdavUrl = hasWebDav(url, credentials) + if (webdavUrl == null) + return false + + // create in database + val mount = WebDavMount( + url = webdavUrl, + name = displayName + ) + val id = db.webDavMountDao().insert(mount) + + // store credentials + val credentialsStore = CredentialsStore(context) + credentialsStore.setCredentials(id, credentials) + + // notify content URI listeners + DocumentProviderUtils.notifyMountsChanged(context) + + return true + } + + suspend fun delete(mount: WebDavMount) { + // remove mount from database + mountDao.deleteAsync(mount) + + // remove credentials, too + CredentialsStore(context).setCredentials(mount.id, null) + + // notify content URI listeners + DocumentProviderUtils.notifyMountsChanged(context) + } + + fun getAllFlow() = mountDao.getAllFlow() + + fun getAllWithRootFlow() = mountDao.getAllWithQuotaFlow() + + suspend fun refreshAllQuota() { + val resolver = context.contentResolver + + withContext(ioDispatcher) { + // query root document of each mount to refresh quota + mountDao.getAll().forEach { mount -> + documentDao.getOrCreateRoot(mount).let { root -> + var loading = true + while (loading) { + val rootDocumentUri = DocumentsContract.buildChildDocumentsUri(authority, root.id.toString()) + resolver.query(rootDocumentUri, null, null, null, null)?.use { cursor -> + loading = cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING) + } + + if (loading) // still loading, wait a bit + delay(100) + } + } + } + } + } + + + // helpers + + /** + * Checks whether WebDAV is supported at given URL with given credentials + * and returns the resulting if following a few redirects. + * + * @param url The URL to check + * @param credentials The credentials to use for the request + * @return The URL at which WebDAV support was found + */ + @VisibleForTesting + internal suspend fun hasWebDav( + url: HttpUrl, + credentials: Credentials? + ): HttpUrl? = withContext(ioDispatcher) { + val validVersions = arrayOf("1", "2", "3") + + val builder = httpClientBuilder.get() + + if (credentials != null) + builder.authenticate( + host = null, + getCredentials = { credentials } + ) + + var webdavUrl: HttpUrl? = null + builder.build().use { httpClient -> + val dav = DavResource(httpClient.okHttpClient, url) + runInterruptible { + dav.options(followRedirects = true) { davCapabilities, response -> + if (davCapabilities.any { it in validVersions }) + webdavUrl = dav.location + } + } + } + + webdavUrl + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/cache/DiskCache.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/cache/DiskCache.kt new file mode 100644 index 0000000..8bb2f34 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/cache/DiskCache.kt @@ -0,0 +1,106 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.cache + +import java.io.File +import java.util.logging.Logger + +/** + * Disk-based cache that maps [String]s to [ByteArray]s. + * + * @param cacheDir directory where to put cache files + * @param maxSize max. total cache size (approximately, may be exceeded for some time) + */ +class DiskCache( + val cacheDir: File, + val maxSize: Long +) { + + companion object { + /** + * after how many cache writes [trim] is called + */ + const val CLEANUP_RATE = 15 + } + + private val logger = Logger.getGlobal() + private var writeCounter: Int = 0 + + init { + if (!cacheDir.isDirectory) + if (!cacheDir.mkdirs()) + throw IllegalArgumentException("Couldn't create cache in $cacheDir") + } + + /** + * Gets the file that contains the given key. If the key is not in the cache, the value is being generated from the + * callback, stored in the cache and the backing file is returned. + * + * It's not guaranteed that the file still exists when you're using it! For instance, it may have already + * been removed to keep the cache in size. + * + * @param key key of the cached entry + * @param generate callback that generates the value + * + * @return the file that contains the value + */ + fun getFileOrPut(key: String, generate: () -> ByteArray?): File? { + synchronized(this) { + val file = File(cacheDir, key) + if (file.exists()) { + logger.fine("Cache hit: $key") + return file + } else { + logger.fine("Cache miss: $key → generating") + val result = generate() ?: return null + + file.outputStream().use { output -> + output.write(result) + } + + if (writeCounter++.mod(CLEANUP_RATE) == 0) + trim() + + return file + } + } + } + + + @Synchronized + fun clear() { + cacheDir.listFiles()?.forEach { entry -> + entry.delete() + } + } + + @Synchronized + fun entries(): Int { + return cacheDir.listFiles()!!.size + } + + fun keys(): Array = cacheDir.list()!! + + /** + * Trims the cache to keep it smaller than [maxSize]. + */ + @Synchronized + fun trim(): Int { + var removed = 0 + logger.fine("Trimming disk cache to $maxSize bytes") + + val files = cacheDir.listFiles()!!.toMutableList() + files.sortBy { file -> file.lastModified() } // sort by modification time (ascending) + + while (files.sumOf { file -> file.length() } > maxSize) { + val file = files.removeAt(0) // take first (= oldest) file + logger.finer("Removing $file") + file.delete() + removed++ + } + return removed + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/cache/ThumbnailCache.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/cache/ThumbnailCache.kt new file mode 100644 index 0000000..db79d41 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/cache/ThumbnailCache.kt @@ -0,0 +1,72 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.cache + +import android.content.Context +import android.graphics.Point +import android.os.Build +import android.os.storage.StorageManager +import android.text.format.Formatter +import androidx.core.content.getSystemService +import at.bitfire.davdroid.db.WebDavDocument +import com.google.common.hash.Hashing +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.util.logging.Logger +import javax.inject.Inject + +/** + * Simple disk cache for image thumbnails. + */ +class ThumbnailCache @Inject constructor( + @ApplicationContext context: Context, + logger: Logger +) { + + val storage: DiskCache + + init { + val storageManager = context.getSystemService()!! + val cacheDir = File(context.cacheDir, "webdav/thumbnail") + val maxBytes = if (Build.VERSION.SDK_INT >= 26) + storageManager.getCacheQuotaBytes(storageManager.getUuidForPath(cacheDir)) / 2 + else + 50 * 1024*1024 // 50 MB + logger.info("Initializing WebDAV thumbnail cache with ${Formatter.formatFileSize(context, maxBytes)}") + + storage = DiskCache(cacheDir, maxBytes) + } + + + fun get(docKey: WebDavDocument.CacheKey, sizeHint: Point, generate: () -> ByteArray?): File? { + val key = Key(docKey, sizeHint) + return storage.getFileOrPut(key.asString(), generate) + } + + @Suppress("UnstableApiUsage") + data class Key( + val document: WebDavDocument.CacheKey, + val size: Point + ) { + + fun asString(): String { + val hf = Hashing.sha256().newHasher() + + hf.putLong(document.docId) + document.documentState.eTag?.let { + hf.putString(it, Charsets.UTF_8) + } + document.documentState.lastModified?.let { + hf.putLong(it.toEpochMilli()) + } + hf.putInt(size.x) + hf.putInt(size.y) + + return hf.hash().toString() + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt new file mode 100644 index 0000000..a031edd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt @@ -0,0 +1,77 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavDocument +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentProviderUtils +import at.bitfire.davdroid.webdav.throwForDocumentProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class CopyDocumentOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking { + logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId") + val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException() + val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException() + val name = srcDoc.name + + if (srcDoc.mountId != dstFolder.mountId) + throw UnsupportedOperationException("Can't COPY between WebDAV servers") + + httpClientBuilder.build(srcDoc.mountId).use { client -> + val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db)) + val dstUrl = dstFolder.toHttpUrl(db).newBuilder() + .addPathSegment(name) + .build() + + try { + runInterruptible(ioDispatcher) { + dav.copy(dstUrl, false) { + // successfully copied + } + } + } catch (e: HttpException) { + e.throwForDocumentProvider(context) + } + + val dstDocId = documentDao.insertOrReplace( + WebDavDocument( + mountId = dstFolder.mountId, + parentId = dstFolder.id, + name = name, + isDirectory = srcDoc.isDirectory, + displayName = srcDoc.displayName, + mimeType = srcDoc.mimeType, + size = srcDoc.size + ) + ).toString() + + DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId) + + /* return */ dstDocId + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt new file mode 100644 index 0000000..bd5e3b3 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt @@ -0,0 +1,89 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import android.provider.DocumentsContract.Document +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavDocument +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentProviderUtils +import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName +import at.bitfire.davdroid.webdav.throwForDocumentProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class CreateDocumentOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking { + logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName") + val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException() + val createDirectory = mimeType == Document.MIME_TYPE_DIR + + var docId: Long? + httpClientBuilder.build(parent.mountId).use { client -> + for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) { + val newName = displayNameToMemberName(displayName, attempt) + val parentUrl = parent.toHttpUrl(db) + val newLocation = parentUrl.newBuilder() + .addPathSegment(newName) + .build() + val doc = DavResource(client.okHttpClient, newLocation) + try { + runInterruptible(ioDispatcher) { + if (createDirectory) + doc.mkCol(null) { + // directory successfully created + } + else + doc.put(RequestBody.EMPTY, ifNoneMatch = true) { + // document successfully created + } + } + + docId = documentDao.insertOrReplace( + WebDavDocument( + mountId = parent.mountId, + parentId = parent.id, + name = newName, + isDirectory = createDirectory, + mimeType = mimeType.toMediaTypeOrNull(), + eTag = null, + lastModified = null, + size = if (createDirectory) null else 0 + ) + ) + + DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId) + + return@runBlocking docId.toString() + } catch (e: HttpException) { + e.throwForDocumentProvider(context, ignorePreconditionFailed = true) + } + } + } + + null + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt new file mode 100644 index 0000000..9736e90 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt @@ -0,0 +1,55 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentProviderUtils +import at.bitfire.davdroid.webdav.throwForDocumentProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class DeleteDocumentOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(documentId: String) = runBlocking { + logger.fine("WebDAV removeDocument $documentId") + val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() + + httpClientBuilder.build(doc.mountId).use { client -> + val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) + try { + runInterruptible(ioDispatcher) { + dav.delete { + // successfully deleted + } + } + logger.fine("Successfully removed") + documentDao.delete(doc) + + DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) + } catch (e: HttpException) { + e.throwForDocumentProvider(context) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/IsChildDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/IsChildDocumentOperation.kt new file mode 100644 index 0000000..e1df2ac --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/IsChildDocumentOperation.kt @@ -0,0 +1,38 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavDocument +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class IsChildDocumentOperation @Inject constructor( + db: AppDatabase, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(parentDocumentId: String, documentId: String): Boolean { + logger.fine("WebDAV isChildDocument $parentDocumentId $documentId") + val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException() + + var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() + while (iter != null) { + val currentParentId = iter.parentId + if (currentParentId == parent.id) + return true + + iter = if (currentParentId != null) + documentDao.get(currentParentId) + else + null + } + return false + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt new file mode 100644 index 0000000..c9faa3f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt @@ -0,0 +1,66 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentProviderUtils +import at.bitfire.davdroid.webdav.throwForDocumentProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class MoveDocumentOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking { + logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId") + val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException() + val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException() + + if (doc.mountId != dstParent.mountId) + throw UnsupportedOperationException("Can't MOVE between WebDAV servers") + + val newLocation = dstParent.toHttpUrl(db).newBuilder() + .addPathSegment(doc.name) + .build() + + httpClientBuilder.build(doc.mountId).use { client -> + val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) + try { + runInterruptible(ioDispatcher) { + dav.move(newLocation, false) { + // successfully moved + } + } + + documentDao.update(doc.copy(parentId = dstParent.id)) + + DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId) + DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId) + } catch (e: HttpException) { + e.throwForDocumentProvider(context) + } + } + + doc.id.toString() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt new file mode 100644 index 0000000..ad0f014 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt @@ -0,0 +1,115 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentProviderUtils +import at.bitfire.davdroid.webdav.HeadResponse +import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper +import at.bitfire.davdroid.webdav.StreamingFileDescriptor +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import okhttp3.HttpUrl +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class OpenDocumentOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger, + private val randomAccessCallbackWrapperFactory: RandomAccessCallbackWrapper.Factory, + private val streamingFileDescriptorFactory: StreamingFileDescriptor.Factory +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking { + logger.fine("WebDAV openDocument $documentId $mode $signal") + + val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() + val url = doc.toHttpUrl(db) + val client = httpClientBuilder.build(doc.mountId, logBody = false) + + val readOnlyMode = when (mode) { + "r" -> true + "w", "wt" -> false + else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV") + } + + val accessScope = CoroutineScope(SupervisorJob()) + signal?.setOnCancelListener { + logger.fine("Cancelling WebDAV access to $url") + accessScope.cancel() + } + + val fileInfo = accessScope.async { + headRequest(client, url) + }.await() + logger.fine("Received file info: $fileInfo") + + // RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient + return@runBlocking if ( + androidSupportsRandomAccess && + readOnlyMode && // WebDAV doesn't support random write access (natively) + fileInfo.size != null && // file descriptor must return a useful value on getFileSize() + (fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine when the document changes during access + fileInfo.supportsPartial == true // WebDAV server must advertise random access + ) { + logger.fine("Creating RandomAccessCallback for $url") + val accessor = randomAccessCallbackWrapperFactory.create(client, url, doc.mimeType, fileInfo, accessScope) + accessor.fileDescriptor() + + } else { + logger.fine("Creating StreamingFileDescriptor for $url") + val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred, success -> + // called when transfer is finished + if (!success) + return@create + + val now = System.currentTimeMillis() + if (!readOnlyMode /* write access */) { + // write access, update file size + documentDao.update(doc.copy(size = transferred, lastModified = now)) + } + + DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) + } + + if (readOnlyMode) + fd.download() + else + fd.upload() + } + } + + private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) { + HeadResponse.fromUrl(client, url) + } + + + companion object { + + /** openProxyFileDescriptor (required for random access) exists since Android 8.0 */ + val androidSupportsRandomAccess = Build.VERSION.SDK_INT >= 26 + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt new file mode 100644 index 0000000..d267ed4 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt @@ -0,0 +1,126 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import android.content.res.AssetFileDescriptor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Point +import android.media.ThumbnailUtils +import android.net.ConnectivityManager +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import androidx.core.content.getSystemService +import at.bitfire.dav4jvm.DavResource +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.cache.ThumbnailCache +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeout +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import kotlin.use + +class OpenDocumentThumbnailOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger, + private val thumbnailCache: ThumbnailCache +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? { + logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal") + + // don't download the large images just to create a thumbnail on metered networks + val connectivityManager = context.getSystemService()!! + if (connectivityManager.isActiveNetworkMetered) + return null + + if (signal == null) { + logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app") + return null + } + val accessScope = CoroutineScope(SupervisorJob()) + signal.setOnCancelListener { + logger.fine("Cancelling thumbnail generation for $documentId") + accessScope.cancel() + } + + val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() + + val docCacheKey = doc.cacheKey() + if (docCacheKey == null) { + logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown") + return null + } + + val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) { + // create thumbnail + val job = accessScope.async { + withTimeout(THUMBNAIL_TIMEOUT_MS) { + httpClientBuilder.build(doc.mountId, logBody = false).use { client -> + val url = doc.toHttpUrl(db) + val dav = DavResource(client.okHttpClient, url) + var result: ByteArray? = null + runInterruptible(ioDispatcher) { + dav.get("image/*", null) { response -> + response.body.byteStream().use { data -> + BitmapFactory.decodeStream(data)?.let { bitmap -> + val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y) + val baos = ByteArrayOutputStream() + thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos) + result = baos.toByteArray() + } + } + } + } + result + } + } + } + + try { + runBlocking { + job.await() + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't generate thumbnail", e) + null + } + } + + if (thumbFile != null) + return AssetFileDescriptor( + ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY), + 0, thumbFile.length() + ) + + return null + } + + + companion object { + + const val THUMBNAIL_TIMEOUT_MS = 15000L + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt new file mode 100644 index 0000000..74157e6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt @@ -0,0 +1,214 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import android.provider.DocumentsContract.Document +import android.provider.DocumentsContract.buildChildDocumentsUri +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.webdav.DisplayName +import at.bitfire.dav4jvm.property.webdav.GetContentLength +import at.bitfire.dav4jvm.property.webdav.GetContentType +import at.bitfire.dav4jvm.property.webdav.GetETag +import at.bitfire.dav4jvm.property.webdav.GetLastModified +import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes +import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes +import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.WebDavDocument +import at.bitfire.davdroid.db.WebDavDocumentDao +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentSortByMapper +import at.bitfire.davdroid.webdav.DocumentsCursor +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import java.io.FileNotFoundException +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class QueryChildDocumentsOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val documentSortByMapper: Lazy, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + private val authority = context.getString(R.string.webdav_authority) + private val documentDao = db.webDavDocumentDao() + + private val backgroundScope = CoroutineScope(SupervisorJob()) + + operator fun invoke(parentDocumentId: String, projection: Array?, sortOrder: String?) = + synchronized(QueryChildDocumentsOperation::class.java) { + queryChildDocuments(parentDocumentId, projection, sortOrder) + } + + private fun queryChildDocuments( + parentDocumentId: String, + projection: Array?, + sortOrder: String? + ): DocumentsCursor { + logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder") + val parentId = parentDocumentId.toLong() + val parent = documentDao.get(parentId) ?: throw FileNotFoundException() + + val columns = projection ?: arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED + ) + + // Register watcher + val result = DocumentsCursor(columns) + val notificationUri = buildChildDocumentsUri(authority, parentDocumentId) + result.setNotificationUri(context.contentResolver, notificationUri) + + // Dispatch worker querying for the children and keep track of it + val running = runningQueryChildren.getOrPut(parentId) { + backgroundScope.launch { + queryChildren(parent) + // Once the query is done, set query as finished (not running) + runningQueryChildren[parentId] = false + // .. and notify - effectively calling this method again + context.contentResolver.notifyChange(notificationUri, null) + } + true + } + + if (running) // worker still running + result.loading = true + else // remove worker from list if done + runningQueryChildren.remove(parentId) + + // Prepare SORT BY clause + val mapper = documentSortByMapper.get() + val sqlSortBy = if (sortOrder != null) + mapper.mapContentProviderToSql(sortOrder) + else + WebDavDocumentDao.DEFAULT_ORDER + + // Regardless of whether the worker is done, return the children we already have + val children = documentDao.getChildren(parentId, sqlSortBy) + for (child in children) { + val bundle = child.toBundle(parent) + result.addRow(bundle) + } + + return result + } + + /** + * Finds children of given parent [WebDavDocument]. After querying, it + * updates existing children, adds new ones or removes deleted ones. + * + * There must never be more than one running instance per [parent]! + * + * @param parent folder to search for children + */ + internal suspend fun queryChildren(parent: WebDavDocument) { + val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique + val newChildrenList = hashMapOf() + + val parentUrl = parent.toHttpUrl(db) + httpClientBuilder.build(parent.mountId).use { client -> + val folder = DavCollection(client.okHttpClient, parentUrl) + + try { + runInterruptible(ioDispatcher) { + folder.propfind(1, *DAV_FILE_FIELDS) { response, relation -> + logger.fine("$relation $response") + + val resource: WebDavDocument = + when (relation) { + Response.HrefRelation.SELF -> // it's about the parent + parent + + Response.HrefRelation.MEMBER -> // it's about a member + WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName()) + + else -> { + // we didn't request this; log a warning and ignore it + logger.warning("Ignoring unexpected $response $relation in $parentUrl") + return@propfind + } + } + + val updatedResource = resource.copy( + isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) + ?: resource.isDirectory, + displayName = response[DisplayName::class.java]?.displayName, + mimeType = response[GetContentType::class.java]?.type, + eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.eTag, + lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(), + size = response[GetContentLength::class.java]?.contentLength, + mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind, + mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind, + mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent, + quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes, + quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes, + ) + + if (resource == parent) + documentDao.update(updatedResource) + else { + documentDao.insertOrUpdate(updatedResource) + newChildrenList[resource.name] = updatedResource + } + + // remove resource from known child nodes, because not found on server + oldChildren.remove(resource.name) + } + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't query children", e) + } + } + + // Delete child nodes which were not rediscovered (deleted serverside) + for ((_, oldChild) in oldChildren) + documentDao.delete(oldChild) + } + + + companion object { + + val DAV_FILE_FIELDS = arrayOf( + ResourceType.NAME, + CurrentUserPrivilegeSet.NAME, + DisplayName.NAME, + GetETag.NAME, + GetContentType.NAME, + GetContentLength.NAME, + GetLastModified.NAME, + QuotaAvailableBytes.NAME, + QuotaUsedBytes.NAME, + ) + + /** List of currently active [queryChildDocuments] runners. + * + * Key: document ID (directory) for which children are listed. + * Value: whether the runner is still running (*true*) or has already finished (*false*). + */ + private val runningQueryChildren = ConcurrentHashMap() + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryDocumentOperation.kt new file mode 100644 index 0000000..ad3e104 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryDocumentOperation.kt @@ -0,0 +1,55 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.database.Cursor +import android.provider.DocumentsContract.Document +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.webdav.DocumentsCursor +import kotlinx.coroutines.runBlocking +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class QueryDocumentOperation @Inject constructor( + db: AppDatabase, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + private val mountDao = db.webDavMountDao() + + operator fun invoke(documentId: String, projection: Array?): Cursor { + logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}") + + val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() + val parent = doc.parentId?.let { parentId -> + documentDao.get(parentId) + } + + return DocumentsCursor(projection ?: arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_ICON, + Document.COLUMN_SUMMARY + )).apply { + val bundle = doc.toBundle(parent) + logger.fine("queryDocument($documentId) = $bundle") + + // override display names of root documents + if (parent == null) { + val mount = runBlocking { mountDao.getById(doc.mountId) } + bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name) + } + + addRow(bundle) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryRootsOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryRootsOperation.kt new file mode 100644 index 0000000..da2b937 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryRootsOperation.kt @@ -0,0 +1,65 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.provider.DocumentsContract.Root +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.runBlocking +import java.util.logging.Logger +import javax.inject.Inject + +class QueryRootsOperation @Inject constructor( + @ApplicationContext private val context: Context, + db: AppDatabase, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + private val mountDao = db.webDavMountDao() + + operator fun invoke(projection: Array?): Cursor { + logger.fine("WebDAV queryRoots") + val roots = MatrixCursor(projection ?: arrayOf( + Root.COLUMN_ROOT_ID, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_FLAGS, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_SUMMARY + )) + + runBlocking { + for (mount in mountDao.getAll()) { + val rootDocument = documentDao.getOrCreateRoot(mount) + logger.info("Root ID: $rootDocument") + + roots.newRow().apply { + add(Root.COLUMN_ROOT_ID, mount.id) + add(Root.COLUMN_ICON, R.mipmap.ic_launcher) + add(Root.COLUMN_TITLE, context.getString(R.string.webdav_provider_root_title)) + add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString()) + add(Root.COLUMN_SUMMARY, mount.name) + add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD) + + val quotaAvailable = rootDocument.quotaAvailable + if (quotaAvailable != null) + add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable) + + val quotaUsed = rootDocument.quotaUsed + if (quotaAvailable != null && quotaUsed != null) + add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed) + } + } + } + + return roots + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt new file mode 100644 index 0000000..26dd385 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt @@ -0,0 +1,67 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav.operation + +import android.content.Context +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.webdav.DavHttpClientBuilder +import at.bitfire.davdroid.webdav.DocumentProviderUtils +import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName +import at.bitfire.davdroid.webdav.throwForDocumentProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import java.io.FileNotFoundException +import java.util.logging.Logger +import javax.inject.Inject + +class RenameDocumentOperation @Inject constructor( + @ApplicationContext private val context: Context, + private val db: AppDatabase, + private val httpClientBuilder: DavHttpClientBuilder, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val logger: Logger +) { + + private val documentDao = db.webDavDocumentDao() + + operator fun invoke(documentId: String, displayName: String): String? = runBlocking { + logger.fine("WebDAV renameDocument $documentId $displayName") + val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() + + httpClientBuilder.build(doc.mountId).use { client -> + for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) { + val newName = displayNameToMemberName(displayName, attempt) + val oldUrl = doc.toHttpUrl(db) + val newLocation = oldUrl.newBuilder() + .removePathSegment(oldUrl.pathSegments.lastIndex) + .addPathSegment(newName) + .build() + try { + val dav = DavResource(client.okHttpClient, oldUrl) + runInterruptible(ioDispatcher) { + dav.move(newLocation, false) { + // successfully renamed + } + } + documentDao.update(doc.copy(name = newName)) + + DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) + + return@runBlocking doc.id.toString() + } catch (e: HttpException) { + e.throwForDocumentProvider(context, true) + } + } + } + + null + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_storage_notify.xml b/app/src/main/res/drawable-anydpi/ic_storage_notify.xml new file mode 100644 index 0000000..12f67f0 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_storage_notify.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_storage_notify.png b/app/src/main/res/drawable-hdpi/ic_storage_notify.png new file mode 100644 index 0000000..12bbcb3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_storage_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_storage_notify.png b/app/src/main/res/drawable-mdpi/ic_storage_notify.png new file mode 100644 index 0000000..1a8b1bd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_storage_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_storage_notify.png b/app/src/main/res/drawable-xhdpi/ic_storage_notify.png new file mode 100644 index 0000000..d57b3b4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_storage_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_storage_notify.png b/app/src/main/res/drawable-xxhdpi/ic_storage_notify.png new file mode 100644 index 0000000..cf3a537 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_storage_notify.png differ diff --git a/app/src/main/res/drawable/accounts_background.xml b/app/src/main/res/drawable/accounts_background.xml new file mode 100644 index 0000000..889f73e --- /dev/null +++ b/app/src/main/res/drawable/accounts_background.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/google_g_logo.xml b/app/src/main/res/drawable/google_g_logo.xml new file mode 100644 index 0000000..993a5e7 --- /dev/null +++ b/app/src/main/res/drawable/google_g_logo.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_database_off.xml b/app/src/main/res/drawable/ic_database_off.xml new file mode 100644 index 0000000..ba5a0b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_database_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_foreground_notify.xml b/app/src/main/res/drawable/ic_foreground_notify.xml new file mode 100644 index 0000000..7423be8 --- /dev/null +++ b/app/src/main/res/drawable/ic_foreground_notify.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0cae730 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sd_card_notify.xml b/app/src/main/res/drawable/ic_sd_card_notify.xml new file mode 100644 index 0000000..f284d1a --- /dev/null +++ b/app/src/main/res/drawable/ic_sd_card_notify.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..0bc3e11 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..d0def11 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..aa3d8e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sync_problem_notify.xml b/app/src/main/res/drawable/ic_sync_problem_notify.xml new file mode 100644 index 0000000..ed44b85 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync_problem_notify.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_sync_shortcut.xml b/app/src/main/res/drawable/ic_sync_shortcut.xml new file mode 100644 index 0000000..f9efb87 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync_shortcut.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_warning_notify.xml b/app/src/main/res/drawable/ic_warning_notify.xml new file mode 100644 index 0000000..ed23531 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_notify.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/intro_open_source.xml b/app/src/main/res/drawable/intro_open_source.xml new file mode 100644 index 0000000..b383085 --- /dev/null +++ b/app/src/main/res/drawable/intro_open_source.xml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/intro_permissions.xml b/app/src/main/res/drawable/intro_permissions.xml new file mode 100644 index 0000000..21279cb --- /dev/null +++ b/app/src/main/res/drawable/intro_permissions.xml @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/intro_tasks.xml b/app/src/main/res/drawable/intro_tasks.xml new file mode 100644 index 0000000..79a38eb --- /dev/null +++ b/app/src/main/res/drawable/intro_tasks.xml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/mastodon.xml b/app/src/main/res/drawable/mastodon.xml new file mode 100644 index 0000000..b2d15a6 --- /dev/null +++ b/app/src/main/res/drawable/mastodon.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/product_logomark_cloud_messaging_full_color.xml b/app/src/main/res/drawable/product_logomark_cloud_messaging_full_color.xml new file mode 100644 index 0000000..1353c33 --- /dev/null +++ b/app/src/main/res/drawable/product_logomark_cloud_messaging_full_color.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/shape_rounded_primary.xml b/app/src/main/res/drawable/shape_rounded_primary.xml new file mode 100644 index 0000000..c2f71da --- /dev/null +++ b/app/src/main/res/drawable/shape_rounded_primary.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/undraw_server_down.xml b/app/src/main/res/drawable/undraw_server_down.xml new file mode 100644 index 0000000..a2659ed --- /dev/null +++ b/app/src/main/res/drawable/undraw_server_down.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_icon_sync_button.xml b/app/src/main/res/layout/widget_preview_icon_sync_button.xml new file mode 100644 index 0000000..f5996b6 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_icon_sync_button.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_preview_labeled_sync_button.xml b/app/src/main/res/layout/widget_preview_labeled_sync_button.xml new file mode 100644 index 0000000..24a31be --- /dev/null +++ b/app/src/main/res/layout/widget_preview_labeled_sync_button.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f14eb87 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c402e3f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..77e9d2f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2aa7185 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..01b53f2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f50fed5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..4c0e06b --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1,2 @@ +# Set default locale +unqualifiedResLocale=en-US diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..e98d899 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,212 @@ + + + + هذا الحساب ليس مسجلاً (لم يعد مسجلاً) + DAVx⁵ دفتر عناوين + أزل + ألغِ + تفعيل + يجب ملء هذا الحقل + مساعدة + العودة + شارك + عطبت قاعدة البيانات + أزيلت الحسابات المحلية كلها + تصحيح العلل + رسائل هامة أخرى + الرسائل غير المهمة + مزامنة + أخطاء المزامنة + الأخطاء الهامة التي توقف المزامنة ، نحو ردود الخادم غير المتوقعة + تحذيرات المزامنة + المشاكل غير الفادحة في المزامنة ، مثل بعض الملفات غير الصالحة + أخطاء الشبكة و عمليات الإدخال/الإخراج + أوقات المهل، مشاكل الاتصال ، ...الخ (مؤقتة في العادة) + + بياناتك. قرارك. + تحكَّم + الفواصل البينية للمزامنة + يجب السماح لـ %s بالعمل في الخلفية لتنجح المزامنة المجدولة. وإلا فإن آندرويد يمكنه إيقاف المزامنة في أية وقت. + لا أحتاج إلى جدولة المزامنة.* + توافق %s + أنهيت الإعدادت الإجبارية. توقف عن تذكيري أبداً.* + * اتركها مفرغة لتذكيرك لاحقا. يمكن إعادة ضبطها في إعدادات التطبيق / %s. + المزيد من المعلومات + jtx Board + دعم المهمات + إذا كان خادمك داعما للمهمات، يمكن مزامنتها مع تطبيق مهمات معتمد: + OpenTasks + يبدو أن تطويره متوقف منذ مدة +- غير منصوح به + Tasks.org + لا متجر تطبيقات متاحا + لا أحتاج دعم المهمات.* + برمجيات حرة المصدر + نحن سعداء باستخدامك تطبيقنا حر المصدر %s. التطوير والعناية والدعم كلها مهام صعبة. نرجوا أن تضع المساهمة معنا أو التبرع لنا في عين الاعتبار. سنكون ممتنين لمساهمتك أو تبرعك. + كيفية المساهمة أو التبرع + + الأذونات + %sيتطلب بعض الأذونات للعمل بكفء. + + + المكتبات + النسخة %1$s (%2$d) + يقدَّم هذا البرنامج دون أدنى مسؤولية. إنه برنامج حر، وندعوك لإعادة توزيعه حسب أحكام محددة. + + لا يمكن إنشاء ملف سجل + + مهايئ مزامنة CalDAV/CardDAV + حول / الترخيص + انطباعات المستخدمين عن البيتا + الإعدادات + أخبار وتحديثات + روابط خارجية + موقع الويب + دليل الاستخدام + الأسئلة الشائعة + + + فشل اكتشاف الخدمة + لم يتمكن التطبيق من تجديد قائمة المجموعة + + + الإعدادات + تصحيح العلل + عرض معلومات التصحيح + التسجيل المفصّل + التسجيل معطَّل + الاتصال + الأمن + عدم الثقة في شهادات النظام + هيئات توثيق النظام و تلك التي أضافها المستخدم لن تكون محل ثقة + هيئات توثيق النظام و تلك التي أضافها المستخدم ستكون محل ثقة (نوصي بهذا) + إعادة ضبط الشهادات (غير)الموثوقة + إعادة ضبط حالة الثقة لجميع الشهادات المخصصة + تمت إزالة جميع الشهادات المخصصة + واجهة المستخدم + إعدادات الإشعار + إدارة وسائل الإشعار وإعداداتها + إعادة ضبط التلميحات + إعادة تفعيل التلميحات التي أُبعِدت سابقاً + كل التلميحات ستظهر مرة أخرى + + CardDAV + CalDAV + Webcal + زامن الآن + إعدادات الحساب + إعادة تسمية الحساب + إعادة تسمية + اسم الحساب مأخوذ بالفعل + حذف الحساب + هل تريد حذف الحساب فعلاً ؟ + سيتم حذف كل النسخ المحليّة من دفاتر العناوين والتقاويم وقوائم المهام. + مزامنة هذه المجموعة + للقراءة فقط + تقويم + لم نجِد تطبيقاً قادراً على استخدام Webcal + تثبيت ICSx⁵ + + إضافة حساب + تسجيل الدخول + تسجيل الدخول بعنوان البريد + عنوان البريد الإلكتروني + مطلوب عنوان بريد إلكتروني صالح + كلمة المرور + تسجيل الدخول بعنوان URL واسم مستخدم + اسم المستخدم + URL الأساس + اختيار الشهادة + إضافة حساب + اسم الحساب + استخدم عنوان بريدك الإلكتروني اسماً للحساب لأن آندرويد يستخدم اسم الحساب في حقل المنظّم ORGANIZER للأحداث التي تنشئها. لايمكن أن تمتلك حسابين بالاسم نفسه. + طريقة مجموعة جهة الاتصال: + اسم الحساب مطلوب + اسم الحساب مأخوذ بالفعل + اكتشاف الضبط + يجري استعلام الخادم … يرجى الانتظار + لم نجِد خدمة CalDAV أو CardDAV. + + المزامنة + مدة مزامنة جهات الاتصال + يدوية فقط + كل %dدقائق + فور حدوث التغييرات المحليّة + مدة مزامنة التقاويم + مدة مزامنة المهام + + يدوياً فقط + كل 15 دقيقة + كل 30 دقيقة + كل ساعة + كل ساعتين + كل 4 ساعات + مرة في اليوم + + المزامنة فقط عبر WiFi + المزامنة مقصورة على اتصالات WiFi + نوع الاتصال غير مأخوذ في الاعتبار + تقييد SSID لـ WiFi + ستتم المزامنة فقط عبر %s + سيتم استخدام جميع اتصالات WiFi + أسماء (SSIDs) مفصولة بفواصل لشبكات WiFi المسموح الاتصال عبرها (اتركه فارغاً للسماح للكل) + المصادقة + اسم المستخدم + حدّث كلمة المرور المعمول بها في خادمك. + CalDAV + الحد الزمني للأحداث الماضية + ستتم مزامنة جميع الأحداث + + سيتم تجاهل الأحداث الأقدم من %d أيام + سيتم تجاهل الأحداث الأقدم من يوم واحد + سيتم تجاهل الأحداث الأقدم من %d أيام + سيتم تجاهل الأحداث الأقدم من %d أيام + سيتم تجاهل الأحداث الأقدم من %d أيام + سيتم تجاهل الأحداث الأقدم من %d أيام + + الأحداث التي جرت بعد عدد الأيام هذا في الماضي سيتم تجاهلها (يمكن أن يكون 0). أتركه فارغاً لمزامنة جميع الأحداث. + إدارة ألوان التقاويم + دعم ألوان الأحداث + CardDAV + طريقة مجموعة عنوان الاتصال + + إنشاء دفتر عناوين + إنشاء تقويم + المدخلات المحتملة للتقويم + أحداث + مهام + ملاحظات/يوميات + اللون + العنوان + مكان التخزين + إنشاء + + حذف المجموعة + المزامنة + العنوان + الوصف + + معلومات تصحيح العلل + نسخ عنوان URL + + حدث خطأ. + حدث خطأ HTTP. + حدث خطأ في الإدخال/الإخراج. + عرض التفاصيل + + المصادقة + اسم المستخدم + كلمة المرور + + أذونات DAVx⁵ + مطلوب أذونات إضافية + فشلت المصادقة (تحقق من بيانات تسجيل الدخول) + خطأ شبكة أو الإدخال/الإخراج - %s + خطأ خادم HTTP - %s + خطأ تخزين محلي - %s + استلام جهة اتصال غير صالحة من الخادم + استلام حدث غير صالح من الخادم + استلام مهمة غير صالحة من الخادم + جرى تجاهل مورد غير صالح واحد أو أكثر + + + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..c75c466 --- /dev/null +++ b/app/src/main/res/values-bg/strings.xml @@ -0,0 +1,481 @@ + + + + Регистрацията (вече) не съществува + Адресник на DAVx⁵ + Не променяйте профила оттук! Вместо това използвайте приложението, за да управлявате профили. + Премахване + Премахване + Отказ + Включване + Задължително поле + Помощ + Придвижване нагоре + Меню настройки + Споделяне + Синхронизирането е започнало/изчаква + Повредено хранилище + Всички местни профили са премахнати. + Отстраняване на дефекти + Други важни съобщения + Съобщения за състоянието с нисък приоритет + Синхронизиране + Грешки при синхронизиране + Важни грешки, спиращи синхронизирането, като неочакван отговор от сървъра + Предупреждения от синхронизиране + Нефатални проблеми със синхронизацията като някои невалидни файлове + Грешки с входа/изхода и мрежата + Забавяния, прекъсвания и т.н (често са временни) + + Вашите данни. Вашият избор + Поемете контрол + Периодично синхронизиране + За да синхронизира периодично, %s се нуждае от разрешение да работи във фонов режим. В противен случай Android може да спре синхронизацията по всяко време. + Не желая периодично синхронизиране.* + %s съвместимост + Софтуера на телефона може да спре синхронизирането. Ако това се случва при вас, можете да решите проблема само ръчно. + Необходимите промени са направени. Без повторно напомняне.* + * Оставете без отметка за повторно напомняне. Може да бъде нулирано от настройките на приложението / %s. + Допълнителна информация + jtx Board + + Поддръжка на задачи + Ако сървърът поддържа задачи, те могат да бъдат синхронизирани с приложение за задачи: + OpenTasks + Изглежда не се разработва вече, не се препоръчва. + Tasks.org + не се поддържат.]]> + Няма достъпен магазин за приложения + Не се нуждая от поддръжка на задачи.* + Приложение с отворен код + Радваме се, че използвате %s – приложение с отворен код. Разработката, издръжката и поддръжката му са тежка работа. Молим ви да допринесете (има много начини) или да дарите. Вашият жест ще бъде високо оценен. + Как да допринеса или даря + Без напомняне за + + %d месец + %d месеца + + Напред + + Разрешения + За да работи нормално %s се нуждае от разрешения. + Всички от изброените по-долу + За да включите вички възможности, изберете това (препоръчано) + Всички разрешения са получени + Разрешения за контактите + Контактите не могат да бъдат синхронизирани (не се препоръчва) + Контактите могат да бъдат синхронизирани + Разрешения за календара + Календарите не могат да бъдат синхронизирани (не се препоръчва) + Календарите могат да бъдат синхронизирани + Разрешение за известия + Известията са изключени (непрепоръчително) + Известията са еключени + Разрешения за jtx Board + Разрешения за OpenTasks + Разрешения за Tasks + Няма синхронизация на задачи + Задачите могат да бъдат синхронизирани + Предпазване от нулиране на разрешенията + Разрешенията могат да бъдат нулирани автоматично (не се препоръчва) + Разрешенията не могат да бъдат нулирани автоматично + Докоснете Разрешения и махнете отметката от „Премахване на разрешенията, ако приложението не се използва“ + Ако някой превключвател не работи, използвайте настройките на приложението / Разрешения. + Настройки на приложението + + Разрешения за SSID на Wi-Fi + За достъп до името на текущата мрежа на Wi-Fi (SSID) трябва да бъдат изпълнени следните условия: + Разрешение за достъп до точното месоположение + Има разрешение за местоположението + Липсва разрешение за местоположението + Достъп до местоположението във фонов режим + Разрешаване във всички случаи + Разрешението за местоположението е зададено на: %s + Разрешението за местоположението не е зададено на: %s + %s използва разрешението за местоположение (само идентификатора на безжичната мрежа) единствено, за да ограничи синхронизирането само до безжична мрежа с определен идентификатор. Това се случва дори и когато приложението се изпълнява във фонов режим. + Данните за местоположението (само идентификатора на безжичната мрежа) се използват само на устройството без да бъдат изпращани никъде. + Винаги включено местоположение + Услугата за местоположението е включена + Услугата за местоположението е изключена + + Преводи + Библиотеки + Издание %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) и сътрудници + Тази програма се предлага с АБСОЛЮТНО НИКАКВА ГАРАНЦИЯ. Тя е свободен софтуер и можете да я разпространявате при определени условия. + + Не може да бъде създаден дневник + Действията на %s се записват в дневник + Преглед/споделяне + Изключване + + Адаптор за синхронизиране на CalDAV/CardDAV + Относно / лиценз + Обратна връзка към бета + Инсталиране на мрежов четец + Настройки + Новини и издания + Инструменти + Външни препратки + Страница + Ръководство + Често задавани въпроси + За организации + Общност + Подкрепа за проекта + Как да допринеса + Лични данни + Добре дошли при DAVx⁵! + Свържете се със сървър и поддържайте календарите и контактите си синхронизирани. + Синхронизиране на всички профили + + Известията са изключени. Няма да бъдете известявани за грешки при синхронизиране. + Автоматичното синхронизиране е изключено (липсва потвърдена връзка с интернет) + Управление на връзките + Включена е икономия на данни. Синхронизацията във фонов режим е ограничена. + Икономия на трафик + Включена е икономия на батерия. Синхронизацията може да е ограничена. + Икономия на батерия + Пространството за съхранение е малко. Андроид няма да синхронизира местните промени веднага, а по време на следващата редовна синхронизация. + Управление на хранилището + Липсва доставчик на календар + Да не би да сте изключили системното приложение „Съхранение в календар“? + Липсва доставчик на контакти + Да не би да сте изключили системното приложение „Хранилище на контакти“? + Управление на приложения + + Грешка при откриване на услугите + Грешка при презареждане + + Работи на преден план + На някои устройства това е необходимо, за да работи автоматичното синхронизиране. + + Настройки + Отстраняване на дефекти + Информация за отстраняване на дефекти + Преглед/споделяне на настройките и дневника + Подробен дневник + Дневникът е включен. Можете да го прегледате като част от информацията за отстраняване на дефекти. + Дневникът е изключен + Оптимизиране на батерията + Приложението е в белия списък (препоръчително) + Има ограничения за батерията (не препоръчително) + Връзка + Вид на сървъра на прокси + + Според системата + Без сървър на прокси + HTTP + SOCKS (за Orbot) + + Име на хоста на сървъра на прокси + Порт на сървъра на прокси + Защита + Разрешения за приложението + Преглед на необходимите за синхронизиране разрешения + Оттегляне на доверието в системните сертификати + Издатели на сертификати познати на системата или добавени от потребителя няма да бъдат доверени + Издателите на сертификатите познати на системата или добавени от потребителя ще бъдат доверени (препоръчително) + Когато е отметнато доверието от системните сертификати е оттеглено. Това означава, че вие ръчно трябва да приемете всеки сертификат (включително и при обновяване на сертификата на сървъра) в противен случай настройката на профил и синхронизирането няма да работят. + Нулиране на (не)доверените сертификати + Нулиране на доверието към всички потребителски сертификати + Всички потребителски сертификати са премахнати + Потребителски интерфейс + Настройки за съобщенията + Управлявление на каналите за съобщения и техните настройки + Избиране на тема + + Според системата + Светла + Тъмна + + Нулиране на подсказки + Всички затворени подсказки, ще бъдат показани отново + Всички подсказки ще бъдат показани отново + Интеграция + Приложението Tasks + Не е инсталирана съвместимо приложение за задачи. + UnifiedPush (експериментално) + Липсва (изключено) + Изберете дистрибутор + Не е инсталиран дистрибутор + Не е настроен сървър + В готовност за получаване на съобщения през %s + FCM (Google Play) + Отдалечено подадените съобщения са винаги шифровани. + + Профилът е премахнат + CardDAV + CalDAV + Webcal + За да бъдат синхронизирани тези списъци са необходими допълнителни разрешения. + Управление на разрешенията + Синхронизиране + Настройки на регистрацията + Преименуване на регистрация + Незапазените местни данни могат да бъдат отхвърлени. След преименуването е нужно синхронизиране. + Ново име на регистрацията + Преименуване + Това име на регистрация вече се използва + Грешка при преименуване + Премахване на регистрация + Да бъде ли премахната регистрацията? + Всички местни копия на адресници, календари и списъци със задачи ще бъдат премахнати. + синхронизиране на списък + само за четене + календар + контакти + дневник + задачи + Само лични + Презареждане на списъка + Абонаментите на Webcal могат да бъдат синхронизирани посредством друго приложение. + Липса приложение, поддържащо Webcal + Инсталиране на ICSx⁵ + + Нова регистрация + политиката за поверителност.]]> + Вход, независим от доставчик + Вход, специфичен за доставчика + Напред + Напред + Вход с електронна поща + Електронна поща + Необходим е действителен електронен адрес + Откриването на услугите става чрез записи в DNS и добре познати адреси.]]> + Парола + Скриване на паролата + Показване на паролата + Парола (по желание) + Вход с адрес и потребителско име + Потребителско име + Потребителско име (по желание) + Основен адрес + някои услуги могат да бъдат открити и чрез записи в DNS, и добре познати адреси.]]> + Избор на сертификат + Нова регистрация + Име на регистрация + Използването на апострофи (\') изглежда води до проблеми при някои устройства. + Използвайте адрес за електронна поща вместо име, защото Android ще го използва като адрес на организатора на събитията, които създавате. Не може да има две регистрации с еднакво име. + Метод за съхранение на групи от контакти: + Изисква се име на регистрацията + Това име на регистрация вече се използва + Профилът не може да бъде добавен + Готово + Вход за напреднали + Без клиентск сертификат (по желание) + Клиентски сертификат: %s + Не е намерен сертификат + Инсталиране на сертификат + Fastmail + Профил във Fastmail + Вход с Fastmail + Google Contacts / Calendar + Профил в Гугъл + Вход с Гугъл + Идентификатор на клиент (по желание) + Политика за поверителност.]]> + Политиката за потребителски данни на услугите на Google API, включително на Изискванията за ограничена употреба.]]> + Не може да бъде получен код за упълномощаване + Nextcloud + Вход с Nextcloud + По този начин ще се стартират стъпките за влизане в Nextcloud чрез мрежов четец. + Адрес на сървъра на Nextcloud + Вход + Адресът за вход не може да бъде получен + Данните за вход не могат да бъдат получени + Откриване на настройки + Изчакайте, запитване на сървъра… + Не са открити услуги на CalDAV или CardDAV. + Основата на адресът изглежда недостъпен за CalDAV/CardDAV адрес и откриването на услуги е неуспешно. + нашия списък с изпробвани услуги и техните основи на адреса.]]> + Също така отново проверете удостоверяването (обикновено потребителско име и парола). + Повече техническа информация има в дневника. + Преглед + + Синхронизиране + Интервал на синхронизиране на контактите + Само ръчно + На всеки %d минути и веднага при местна промяна + Интервал на синхронизиране на календарите + Интервал на синхронизиране на задачите + + Само ръчно + Всеки 15 минути + На 30 минути + Всеки час + На 2 часа + На 4 часа + Веднъж дневно + + Синхронизиране само през Wi-Fi + Синхронизацията е ограничена само при връзки през Wi-Fi + Видът на връзката не е от значение + Ограничения на Wi-Fi по SSID + Ще синхронизира само през %s + Ще бъдат използвани всички връзки по Wi-Fi + Разделени със запетая имена (SSID) на разрешените мрежи по Wi-Fi (празно за всички мрежи) + Ограниченията на Wi-Fi по SSID изискват допълнителни настройки + Управление + Използването на ВЧМ изисква връзка с интернет + VPN без работеща и проверена връзка с интернет не е достатъчен, за да се извършва синхронизация (препоръчително) + VPN без работеща и проверена връзка с интернет е достатъчен, за да се извършва синхронизация + Удостоверяване + Потребителско име + Парола или парола на приложение + парола на приложението.]]> + Нова парола + Променете паролата съгласно сървъра. + Повторно удостоверяване (OAuth) + При отменен достъп + Удостоверени сте + Клиентски сертификат + Не е избран или няма наличен сертификат + Инсталиране на сертификат + CalDAV + Ограничения за събития от миналото + Всички събития ще се синхронизират + + Събития от преди повече от един ден ще бъдат пренебрегнати + Събития от преди повече от %dдена ще бъдат пренебрегнати + + Събитията преди повече от зададения брой дни в миналото ще бъдат пренебрегнати (може да бъде 0). За да бъдат синхронизирани всички събития оставете празно. + Подразбирано напомняне + + Подразбирано напомняне една минута преди събитието + Подразбирано напомняне %d минути преди събитието + + Няма създадени подразбирани напомняния + Към събитията без напомняне ще бъде добавяно подразбирано напомняне: желания брой минути преди събитието. Оставете празно, за да изключите подразбираните напомняния. + Управление на цветовете на календара + Цветовете на календарите се нулират при всяко синхронизиране. + Цветовете на календарите могат да бъдат зададени от други приложения. + Поддръжка на цветове за събития + Цветовете на събитията се синхронизират. + Цветовете на събитията не се синхронизират. + CardDAV + Метод за групиране на контакти + + Групите са отделни vCards + Групите са категории във всеки контакт + + + Създаване на адресник + Създаване на адресник през CardDAV може да не е поддържано от сървъра. + Създаване на календар + Стандартен часови пояс (по желание) + + Възможни елементи на календара + Събития + Задачи + Бележки / дневник + Създаване на календар през CalDAV може да не е поддържано от сървъра. + Цвят + Заглавие + Местоположение на хранилището + Описание (по желание) + Създаване + + контакти + събития + задачи + Премахване + Списъкът (%s) и цялата му информация ще бъде безвъзвратно премахната, както от устройството, така и от сървъра. + Синхронизиране + Синхронизирането е включено + Синхронизирането е изключено + Само за четене + Само за четене (според сървъра) + Само за четене (според политиката) + Само за четене (на устройството) + Четене/писане + Заглавие + Описание + Собственик + Поддържане на Push + Сървърът обявява поддръжка на Push + Начало на абонамента %1$s, изтича на %2$s + Последно синхронизиране (%s) + Адрес (URL) + + Информация за отстраняване на дефекти + Архив на ZIP + Съдържа информацията за отстраняване на дефекти и дневниците + Споделете архива, за да го пренесете на компютър, да го изпратите по електронна поща или да го прикачите към билет за поддръжка. + Споделяне на архива + Информацията за отстраняване на дефекти е приложена към това съобщение (получаващото приложение трябва да поддръжа прикачени файлове). + Грешка на HTTP + Грешка на сървъра + Грешка на WebDAV + Грешка на входа/изхода + Заявката е отказана от сървъра. + Заявеният ресурс (вече) не съществува. + Сървърът не позволява този вид заявено действие. + Грешка на сървъра. Свържете се с поддръжката на сървъра. + Неочаквана грешка. Прегледайте дневника за отстраняване на грешки за повече подробности. + Подробности + Информацията за отстраняване на дефекта е събрана + Ресурси, имащи отношение + Свързани с проблема + Отдалечен ресурс: + Местен ресурс: + Дневници + Налични са подробни дневници + Преглед + Копиране на адреса + Съобщение за защита на личните данни + Дневниците и информацията за отстраняване на грешки могат да съдържат лична информация. Имайте го предвид, когато ги споделяте публично. + + Възникна грешка. + Възникна грешка на HTTP. + Възникна грешка с входа/изхода. + Подробности + + Дялове на WebDAV + Използвана квота: %1$s / налична: %2$s + Споделяне на съдържание + Демонтиране + Добавяне на дял на WebDAV + Получете директен достъп до файловете си в облака като монтирате дял на WebDAV. + как работят дяловете на WebDAV.]]> + Видимо име + Адрес на WebDAV + Недействителен адрес + Точка на монтиране и показвано име + Удостоверяване + Потребителско име + Парола + Потребителско име (по желание) + Парола (по желание) + Монтиране + Няма услуга на WebDAV на адреса + Премахване на точката на монтиране + Ще бъдат изгубени подробности за връзката, но файлове няма да бъдат премахвани. + Достъпва се файл на WebDAV + Изтегля се файл на WebDAV + Изпраща се файл на WebDAV + Дял на WebDAV + + Разрешения на DAVx⁵ + Необходими са допълнителни разрешения + Приложението %s е твърде старо + Минимално необходимо издание: %1$s + Грешка при удостоверяване (проверете данните за вход) + Грешка с входа/изхода или мрежата – %s + Грешка в сървъра на HTTP – %s + Грешка в местното хранилище – %s + Грешка (достигнат максимален брой опити) + Получен е недействителен контакт от сървъра + Получено е недействително събитие от сървъра + Получен е недействителен файл от сървъра + Пренебрегване на сбъркани данни + Чакаща синхронизация + Отдалечените данни са променени + + Синхронизирането всичко + Синхронизиране на всички профили + Бутон за синхронизиране с етикет + Бутон за синхронизиране с пиктограма + Докоснете, за да бъде извършено ръчно синхронизиране. + + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..63d4bf5 --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,479 @@ + + + + El compte no existeix (eliminat) + Llibreta d’adreces del DAVx⁵ + No canvieu aquí el compte! Utilitzeu directament l\'aplicació per a gestionar els comptes. + Eliminar + Elimina + Cancel·la + Activa + Cal aquest camp + Ajuda + Navega cap amunt + Menú d\'opcions + Comparteix + La sincronització s\'ha iniciat/posat a la cua + Base de dades malmesa + S\'han eliminat localment tots els comptes. + Depurador + Altres missatges importants + Missatges d\'estat de baixa prioritat + Sincronització + Errors de sincronització + Errors importants que no permeten la sincronització, com respostes del servidor inesperades + Advertències de sincronització + Problemes de sincronització no fatals, com alguns fitxers invàlids + Xarxa i errors E/S + Temps d\'espera esgotats, errors de connexió, etc. (sovint temporals) + + Les teves dades. La teva elecció. + Pren el control. + Intervals de sincronització regulars + Per la sincronització en intervals regulars, cal que %s tingui permís per a executar-se en segon pla. En cas contrari, Android podria aturar la sincronització en qualsevol moment. + No necessito intervals de sincronització regulars.* + Compatibilitat amb %s + El microprogramari específic dels proveïdors pot bloquejar la sincronització. Si us afecta, només podeu resoldre-ho manualment. + He fet els ajustaments requerits. No m\'ho recordis més.* + * Deixar desmarcat per un recordatori més tard. Pot ser reinicialitzat a ajustaments /%s. + Més informació + jtx Board + + Suport de tasques + Si el vostre servidor permet les tasques, es poden sincronitzar amb una aplicació que admeti tasques: + OpenTasks + Sembla que ja no es desenvolupa, no es recomana. + Tasks.org + No s\'admeten algunes funcionalitats.]]> + No hi ha cap mercat d\'aplicacions disponible + No necessito suport per les tasques.* + Programari de codi obert + Ens alegra saber que utilitzes %s, que és programari de codi obert. El desenvolupament, manteniment i suport requereixen un gran treball. Si us plau, considera contribuir (hi ha moltes formes) o realitzar una donació. Seria molt apreciat! + Com contribuir/donar + No m\'ho recordis durant + + %d mes + %d mesos + + Següent + + Permisos + %s requereix permisos per a funcionar correctament. + Tots els següents + Utilitza això per a activar totes les funcions (recomanat) + Tots els permisos concedits + Permisos de contactes + Sense sincronització de contactes (no recomanat) + Sincronització de contactes possible + Permisos de calendari + Sense sincronització del calendari (no recomanat) + Sincronització del calendari possible + Permís de notificació + Notificacions desactivades (no recomanat) + Notificacions activades + Permisos de jtx Board + Permisos d\'OpenTasks + Permisos de Tasks + Sense sincronització de les tasques + Sincronització de les tasques possible + Mantenir els permisos + Els permisos podrien ser revocats automàticament (no recomanat) + Els permisos no seran revocats automàticament + Feu clic a Permisos > desmarqueu «Elimina els permisos si no s\'usa l\'aplicació» + Si un interruptor no funciona, utilitza l\'apartat de permisos de l\'aplicació dels ajustaments del telèfon. + Ajustaments de l\'aplicació + + Permisos de l\'SSID del Wi-Fi + Per tal de poder accedir al nom de la xarxa de Wi-Fi connectada (SSID), s\'han de complir els següents requisits: + Permís d\'ubicació precisa + Permís d\'ubicació concedit + Permís d\'ubicació rebutjat + Permís d\'ubicació en segon pla + Permetre tota l\'estona + El permís d\'ubicació s\'ha definit a: %s + El permís d\'ubicació no s\'ha definit a: %s + %s utilitza dades d\'ubicació (només SSID de WiFi) únicament per a restringir la sincronització a un SSID de WiFi específic. Això passarà fins i tot quan la sincronització s\'executi en segon pla. + Totes les dades d\'ubicació (només SSID de WiFi) només s\'utilitzen localment i no s\'envien enlloc. + Localització sempre habilitada + El servei de localització està habilitat + Servei de localització deshabilitat + + Traduccions + Biblioteques + Versió %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) i col·laboradors + Aquest programa és distribuït sense CAP MENA DE GARANTIA. És programari lliure, i està permesa la seva redistribució segons certes condicions. + + No s\'ha pogut crear el fitxer de registre + Iniciant a totes les %s activitats + Veure/compartir + Inhabilita + + Adaptador de sincronització CalDAV / CardDAV + Quant a/llicència + Opina sobre la Beta + Instal·leu un navegador web + Configuració + Novetats i actualitzacions + Eines + Enllaços externs + Pàgina web + Manual + Preguntes més freqüents + Per a organitzacions + Comunitat + Doneu suport al projecte + Com col·laborar + Política de privacitat + Us donem la benvinguda al DAVx⁵! + Connecteu-vos al vostre servidor i mantingueu els calendaris i contactes sincronitzats. + Sincronitza tots els comptes + + Notificacions desactivades. No es notificaran els errors de sincronització. + La sincronització automàtica no està activa (no hi ha cap connexió a Internet verificada). + Gestiona les connexions + S\'ha activat l\'estalviador de dades. La sincronització en segon pla està restringida. + Gestió de l\'estalviador de dades + S\'ha habilitat l\'estalvi de bateria. Es podria restringir la sincronització. + Gestiona l\'estalvi de bateria + Espai d\'emmagatzematge baix. L\'Android no sincronitzarà els canvis locals immediatament, sinó durant la pròxima sincronització normal. + Gestiona l\'emmagatzematge + Manca el proveïdor de calendari + Heu desactivat l\'aplicació del sistema «Emmagatzematge de calendari»? + Manca el proveïdor de contactes + Heu desactivat l\'aplicació del sistema «Emmagatzematge de contactes»? + Gestioneu les aplicacions + + Ha fallat la detecció del servei + No s\'ha pogut actualitzar la llista de col·leccions + + Executant-se en segon pla + En alguns dispositius, això és necessari per a la sincronització automàtica. + + Configuració + Depurador + Informació de depuració + Visualitza/comparteix els detalls de la configuració i els registres + Registre detallat + El registre està actiu. Podeu veure els registres com a part de la informació de depuració. + Registre inactiu + Optimització de la bateria + L\'aplicació està exempta (recomanat) + S\'apliquen restriccions de bateria (no recomanat) + Connexió + Tipus de servidor intermediari + + Predeterminat del sistema + Sense servidor intermediari + HTTP + SOCKS (per a l\'Orbot) + + Nom del servidor intermediari + Port del servidor intermediari + Seguretat + Permisos de l\'aplicació + Revisa els permisos necessaris per a la sincronització + Desconfia dels certificats del sistema + No es confiarà en les CA del sistema ni les afegides per l\'usuari + Es confiarà en les CA del sistema i les afegides per l\'usuari (recomanat) + Si aquest paràmetre està actiu, els certificats del sistema no es consideren fiables. Això vol dir que haureu d\'acceptar manualment cada certificat (també quan el servidor renovi el seu certificat) o la configuració del compte i la sincronització no funcionaran. + Restableix els certificats de (des)confiança + Restableix la confiança de tots els certificats personalitzats + Neteja tots els certificats personalitzats + Interfície d’usuari + Configuració de les notificacions + Gestiona els canals de notificació i la seva configuració + Selecció del tema + + Predeterminat del sistema + Clar + Fosc + + Reinicia els consells + Torna a habilitar els consells que s\'han ignorat anteriorment + Es mostraran de nou tots els consells + Integració + Aplicació de tasques + No s\'ha trobat cap aplicació de tasques compatible + UnifiedPush (experimental) + Sense (desactiva «push») + Trieu un distribuïdor + No s\'ha instal·lat cap distribuïdor de «push» + No s\'ha configurat cap punt final + Preparat per a rebre missatges «push» sobre %s + FCM (Google Play) + Els missatges «push» sempre són xifrats. + + S\'ha eliminat el compte + CardDAV + CalDAV + Webcal + Es requereixen permisos addicionals per a sincronitzar aquestes col·leccions. + Gestió dels permisos + Sincronitza ara + Configuració del compte + Canvia el nom del compte + Les dades locals sense desar es podrien descartar. Es requereix tornar a sincronitzar després de canviar el nom. + Nom nou del compte + Canvia el nom + Nom de compte existent + No s\'ha pogut canviar el nom del compte + Suprimeix el compte + Segur que vols suprimir el compte? + Se suprimiran totes les còpies locals de llibretes d\'adreces, calendaris i llistes de tasques. + Sincronitza aquesta col·lecció + només lectura + calendari + contactes + diari + tasques + Mostra només les personals + Recarregar llista + Les subscripcions Webcal es poden sincronitzar amb aplicacions externes. + No s\'ha trobat cap aplicació compatible amb Webcal + Instal·la ICSx⁵ + + Afegeix un compte + política de privadesa.]]> + Inici de sessió genèric + Inici de sessió específic de proveïdor + Continua + Inici de sessió + Inici de sessió amb una adreça de correu electrònic + Adreça de correu electrònic + Es requereix una adreça vàlida de correu electrònic + serveis es descobreixen utilitzant el registres de DNS i els URL ben coneguts.]]> + Contrasenya + Oculta la contrasenya + Mostra la contrasenya + Contrasenya (opcional) + Inici de sessió amb un URL i un nom d\'usuari/ària + Nom d\'usuari/ària + Nom d\'usuari (opcional) + URL base + serveis també es descobreixen utilitzant els registres de DNS i els URL ben coneguts.]]> + Selecciona el certificat + Afegeix un compte + Nom del compte + L\'ús d\'apòstrofs (\') sembla que provoca problemes en alguns dispositius. + Utilitzeu la vostra adreça de correu electrònic com a nom del compte perquè l\'Android utilitzarà el nom del compte com a camp ORGANITZADOR per als esdeveniments que creeu. No poden haver-hi dos comptes amb el mateix nom. + Mètode dels grups de contactes: + Nom del compte obligatori + Nom de compte existent + No s\'ha pogut afegir el compte + Finalitza + Inici de sessió avançat + Sense certificat del client (opcional) + Certificat del client: %s + No s\'ha trobat cap certificat + Instal·la un certificat + Fastmail + Compte de Fastmail + Inici de sessió amb el Fastmail + Contactes / Calendari de Google + Compte de Google + Inicia la sessió amb Google + ID de Client (opcional) + política de privadesa per als detalls.]]> + Política de dades d\'usuari dels serveis de l\'API de Google, incloent-hi els requisits d\'ús limitat.]]> + No s\'ha pogut obtenir el codi d\'autorització + Nextcloud + Inici de sessió amb Nextcloud + Això iniciarà el flux d\'inici de sessió del Nextcloud en un navegador web. + Adreça del servidor Nextcloud + Inicia la sessió + No s\'ha pogut obtenir l\'URL d\'inici de sessió + No s\'han pogut obtenir les dades d\'inici de sessió + Detecció de la configuració + Espereu, s\'està consultant el servidor… + No s\'ha pogut trobar cap servei CalDAV o CardDAV. + L\'URL base no sembla que sigui un URL accessible de CalDAV/CardDAV i la detecció del servei no ha estat correcta. + la nostra llista de serveis provats i les seves URL base.]]> + Comproveu també l\'autenticació (normalment nom d\'usuari i contrasenya). + Hi ha més informació tècnica disponible als registres. + Visualitza els registres + + Sincronització + Interval de sincronització dels contactes + Només a mà + Cada %d minut/s + immediatament en els canvis locals + Interval de sincronització dels calendaris + Interval de sincronització de les tasques + + Només a mà + Cada 15 minuts + Cada 30 minuts + Cada hora + Cada 2 hores + Cada 4 hores + Una vegada al dia + + Sincronitza només amb Wi-Fi + La sincronització està restringida a les connexions Wi-Fi + No es té en compte el tipus de connexió + Restricció per SSID de la Wi-Fi + Només sincronitzarà des de %s + S\'utilitzaran totes les connexions Wi-Fi + Noms (SSID) separats per comes de les xarxes Wi-Fi permeses (deixeu-ho en blanc per a totes) + La restricció per SSID de la Wi-Fi requereix una configuració addicional + Gestió + La VPN requereix una Internet subjacent + Una VPN sense connexió a Internet validada subjacent no és suficient per a executar la sincronització (recomanat) + Una VPN sense connexió a Internet validada subjacent és suficient per a executar la sincronització + Autentificació + Nom d\'usuari/ària + Contrasenya o contrasenya d\'aplicació + contrasenya d\'aplicació.]]> + Contrasenya nova + Actualitzeu la contrasenya segons el vostre servidor. + Torna a autoritzar (OAuth) + Usa quan s\'hagi revocat l\'accés + Autorització correcta + Certificat del client + No hi ha cap certificat disponible o seleccionat + Instal·la un certificat + CalDAV + Límit de temps dels esdeveniments passats + Tots els esdeveniments se sincronitzaran + + Els esdeveniments de més d\'un dia passat seran ignorats + Els esdeveniments de més de %d dies passats seran ignorats + + Els esdeveniments que superin aquest nombre de dies en el passat seran ignorats (poden ser 0). Deixeu-ho en blanc per a sincronitzar tots els esdeveniments. + Recordatori predeterminat + + Recordatori predeterminat d\'un minut abans de l\'esdeveniment + Recordatori predeterminat de %d minuts abans de l\'esdeveniment + + No es crearan recordatoris predeterminats + Si es creen recordatoris predeterminats per als esdeveniments sense recordatori: el nombre desitjat de minuts abans de l\'esdeveniment. Deixeu-ho en blanc per a desactivar els recordatoris predeterminats. + Gestiona els colors del calendari + Els colors del calendari es reinicialitzen a cada sincronització + Altres aplicacions poden establir els colors del calendari + Funcionament dels colors dels esdeveniments + Se sincronitzen els colors dels esdeveniments + No se sincronitzen els colors dels esdeveniments + CardDAV + Mètode dels grups de contactes + + Els grups són vCards separades + Els grups són categories per contacte + + + Crea una llibreta d\'adreces + La creació de llibretes d\'adreces sobre CardDAV pot no ser suportada pel servidor. + Crea un calendari + Fus horari predeterminat (opcional) + + Possibles entrades de calendari + Esdeveniments + Tasques + Notes/diari + La creació de calendaris sobre CalDAV pot no ser suportada pel servidor. + Color + Títol + Ubicació d’emmagatzematge + Descripció (opcional) + Crea + + contactes + esdeveniments + tasques + Suprimeix la col·lecció + Aquesta col·lecció (%s) i totes les seves dades seran eliminades localment i del servidor. + Sincronització + Sincronització habilitada + Sincronització deshabilitada + Només lectura + Només lectura (pel servidor) + Només de lectura (per política) + Només lectura (localment) + Lectura/escriptura + Títol + Descripció + Propietari + Suport de Push + El servidor té suport per a Push + Subscrit el %1$s, venç el %2$s + Última sincronització (%s) + Adreça (URL) + + Informació de depuració + Arxiu ZIP + Conté informació de depuració i registres + Compartiu l\'arxiu per a transferir-lo a un ordinador, per a enviar-lo per correu electrònic o per a adjuntar-lo a un tiquet de suport. + Comparteix l\'arxiu + Informació de depuració adjunta a aquest missatge (requereix el suport d\'adjunts de l\'aplicació de recepció). + Error HTTP + Error del servidor + Error del WebDAV + Error d\'E/S + El servidor no permet el tipus d\'operació sol·licitat. + S\'ha produït un problema a la banda del servidor. Poseu-vos en contacte amb l\'assistència del servidor. + S\'ha produït un error inesperat. Vegeu els detalls a la informació de depuració. + Vista dels detalls + S\'ha recopilat la informació de depuració + Recursos implicats + Relacionats amb el problema + Recurs remot: + Recurs local: + Registres + Hi ha registres detallats disponibles + Visualitza els registres + Copia l\'URL + Avís de privadesa + Els registres i la informació de depuració poden contenir informació privada. Tingueu en compte això quan ho compartiu públicament. + + S\'ha produït un error. + S\'ha produït un error HTTP. + S\'ha produït un error d\'E/S. + Mostra els detalls + + Muntatges WebDAV + Quota utilitzada: %1$s / disponible: %2$s + Comparteix el contingut + Desmunta + Afegeix un muntatge WebDAV + Accediu directament als fitxers en el núvol afegint un muntatge WebDAV! + com funcionen els muntatges WebDAV.]]> + Nom a mostrar + URL del WebDAV + URL no vàlid + Punt de muntatge i nom de visualització + Autentificació + Nom d\'usuari/ària + Contrasenya + Nom d\'usuari (opcional) + Contrasenya (opcional) + Afegeix un muntatge + No hi ha cap servei WebDAV en aquest URL + Elimina el punt de muntatge + Es perdran els detalls de la connexió, però no se suprimiran els fitxers. + S\'està accedint al fitxer WebDAV + S\'està baixant el fitxer WebDAV + S\'està pujant el fitxer WebDAV + Muntatge WebDAV + + Permisos del DAVx⁵ + Es requereixen permisos addicionals + %s és massa antiga + Versió mínima requerida: %1$s + L\'autenticació ha fallat (verifiqueu les credencials d\'inici de sessió) + Error de xarxa o d\'E/S: %s + Error del servidor HTTP: %s + Error d\'emmagatzematge local: %s + Error de programari (s\'ha arribat al màxim de reintents) + S\'ha rebut un contacte no vàlid del servidor + S\'ha rebut un esdeveniment no vàlid del servidor + S\'ha rebut una tasca no vàlida del servidor + S\'ha ignorat un o més recursos no vàlids + Sincronització pendent + Les dades remotes han canviat + + Sincronitza-ho tot + Sincronitza tots els comptes + Botó etiquetat de sincronització + Botó d\'icona de sincronització + Toqueu per a executar manualment la sincronització. + + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..fff0324 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,414 @@ + + + + Účet (už) neexistuje + DAVx⁵ adresář kontaktů + Odstranit + Odebrat + Storno + Zapnout + Tuto kolonku je třeba vyplnit + Pomoc + Navigovat nahoru + Menu možností + Sdílet + Synchronizace spuštěna/přidána do fronty + Databáze rozbitá + Všechny účty byly lokálně odebrány. + Ladění + Ostatní důležité zprávy + Stavové zprávy nízké priority + Synchronizace + Chyby synchronizace + Důležité chyby, které zastaví synchronizaci, jako např. neočekávané odpovědi ze serveru + Varování ohledně synchronizace + Nefatální problémy při synchronizaci jako například některé neplatné soubory + Chyby sítě a vstupu/výstupu + Překročení časových limitů, problémy se spojením, atd. (často dočasné) + + Vaše data. Vaše volba. + Mějte pod svou kontrolou. + Synchronizace v pravidelných intervalech + Pokud chcete synchronizaci v pravidelných intervalech, je třeba %s povolit být spuštěné na pozadí. V opačném případě může systém Android synchronizaci kdykoli pozastavit. + Nepotřebuji synchronizaci v pravidelných intervalech.* + Kompatibilita %s + Potřebná nastavení provedena, už nepřipomínat.* + * Pokud chcete připomenout později, nezaškrtávejte. Je možné resetovat v nastavení aplikace / %s. + Další informace + jtx Board + + Podpora úkolů + Pokud jsou úkoly podporovány vámi využívaným serverem, je možné je synchronizovat pomocí podporované aplikace pro úkoly: + OpenTasks + Nezdá se už být vyvíjeno – nedoporučeno. + Tasks.org + Není k dispozici žádný obchod s aplikacemi + Nepotřebuji podporu pro úkoly.* + Opensource software + Jsme rádi, že %s používáte. Jde o opensource software. Vývoj, údržba a podpora je ale těžká práce. Prosím zvažte zapojení se (je mnoho způsobů jak) nebo podpoření vývoje darem. Bude to velmi oceněno! + Jak se zapojit / podpořit vývoj darem + Další + + Oprávnění + Aby fungovalo správně, %s vyžaduje oprávnění. + Vše níže uvedené + Pomocí tohoto zapněte všechny funkce (doporučeno) + Všechna oprávnění udělena + Oprávnění pro přístup ke kontaktům + Bez synchronizace kontaktů (nedoporučeno) + Synchronizace kontaktů možná + Oprávnění pro přístup ke kalendáři + Bez synchronizace kalendáře (nedoporučeno) + Synchronizace kalendáře možná + Oprávnění oznámení + Upozornění vypnuta (nedoporučeno) + Upozornění zapnuta + Oprávnění pro jtx Board + Oprávnění pro OpenTasks + Oprávnění pro úkoly + Žádné úlohy ohledně synchronizace + Synchronizace úkolů možná + Ponechat oprávnění + Oprávnění mohou být automaticky resetována (nedoporučeno) + Oprávnění nebudou resetována automaticky + Klikněte na Oprávnění > zrušte zaškrtnutí u „Odebrat oprávnění pokud aplikace dlouhodobě není používána“ + Pokud přepínač nefunguje, použijte nastavení aplikací / Oprávnění + Nastavení aplikace + + Oprávnění k přístupu k názvům (SSID) WiFi sítí + Aby bylo možné přistupovat k názvu (SSID) WiFi sítě, ke které jste připojení, je třeba, aby byly splněny tyto podmínky: + Oprávnění pro přístup k přesné poloze + Oprávnění pro přístup k poloze uděleno + Oprávnění pro přístup k poloze odepřeno + Oprávnění pro přístup k poloze na pozadí + Povolit napořád + Stav oprávnění polohy je: %s + Stav oprávnění polohy není: %s + Určování polohy vždy zapnuté + Služba určování polohy je zapnutá + Služba určování polohy je vypnutá + + Překlady + Knihovny + Verze %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) a přispěvatelé + Na tento program nejsou poskytovány ŽÁDNÉ ZÁRUKY. Jedná se o svobodný software a jeho šíření dál je vítáno, ovšem za podmínky, že stejné svobody zůstanou zachovány i všem dalším příjemcům. + + Nedaří se vytvořit soubor pro záznam událostí + Nyní je zaznamenáváno všech %s aktivit + Zobrazit/sdílet + Vypnout + + Adaptér synchronizace CalDAV/CardDAV + O aplikaci / Licence + Zpětná vazba k vývojové verzi + Nainstalujte si webový prohlížeč + Nastavení + Novinky a aktualizace + Nástroje + Externí odkazy + Webová stránka + Příručka + FAQ + Komunita + Podpořit projekt + Jak přispět + Ochrana soukromí + Synchronizovat všechny účty + + Upozornění vypnuta. Nebudete upozorněni na chyby při synchronizaci. + Spravovat spojení + Spořič dat povolen. Synchronizace na pozadí je omezena. + Spravovat spořič dat + Spořič baterie je povolen. Synchronizace může být omezena. + Spravovat spořič baterie + Málo volného místa v úložišti. Android nebude synchronizovat místní změny okamžitě, ale při další běžné synchronizaci. + Spravovat úložiště + + Vyhledání služby se nezdařilo + Nedaří se znovu načíst seznam sady + + Spuštěné v popředí + Na některých zařízeních je toto nezbytné aby fungovala automatická synchronizace. + + Nastavení + Ladění + Zobrazit ladící informace + Podrobnější zaznamenávání událostí + Zaznamenávání událostí je vypnuté + Optimalizace akumulátoru + Aplikace je vyjmuta (doporučeno) + Omezení baterie platí (nedoporučeno) + Připojení + Typ proxy + + Systémová výchozí + Bez proxy + HTTP + SOCKS (pro Orbot) + + Hostitel proxy + Port proxy + Zabezpečení + Oprávnění aplikace + Zkontrolujte oprávnění potřebná pro synchronizaci + Nedůvěřovat systémovým certifikátům + Systémovým a uživatelem přidaným CA nebude důvěřováno + Systémovým a uživatelem přidaným CA bude důvěřováno (doporučeno) + Resetovat (ne)důvěryhodné certifikáty + Resetovat důvěryhodnost všech uživatelsky určených certifikátů + Všechny vlastní certifikáty byly resetovány + Uživatelské prostředí + Nastavení oznamování + Spravovat kanály oznamování a jejich nastavení + Vybrat vzhled + + Výchozí systémový + Světlý + Tmavý + + Znovu zobrazovat rady + Znovu zobrazí rady, které jste už označili jako přečtené + Všechny rady budou znovu zobrazeny + Napojení + Aplikace pro úkoly + Nenalezena žádná kompatibilní aplikace pro úkoly + + CardDAV + CalDAV + Webcal + Jsou nezbytná další oprávnění pro synchronizaci těchto sad. + Spravovat oprávnění + Synchronizovat nyní + Nastavení účtu + Přejmenovat účet + Neuložená místní data mohou být ztracena. Po přejmenování je vyžadována nová synchronizace. + Název nového účtu + Přejmenovat + Tento název účtu už je používán někým jiným + Účet se nedaří přejmenovat + Smazat účet + Opravdu smazat účet? + Všechny místní kopie adresáře, kalendářů a úkolů budou smazány. + synchronizovat tuto sadu + pouze pro čtení + kalendář + žurnál + Zobrazit pouze osobní + Obnovit seznam + Odběry webcal mohou být synchronizovány externími aplikacemi. + Nenalezena žádná aplikace, která by umožňovala webcal + Nainstalovat ICSx⁵ + + Přidat účet + Obecné přihlášení + Přihlášení specifické pro poskytovatele + Pokračovat + Přihlášení + Přihlášení se e-mailovou adresou + E-mailová adresa + Vyžadován platný e-mail + Služby jsou objevovány pomocí záznamů DNS a dobře známých URL.]]> + Heslo + Skrýt heslo + Zobrazit heslo + Přihlášení s URL a uživatelským jménem + Uživatelské jméno + Základ URL + Vybrat certifikát + Přidat účet + Název účtu + Používání apostrofů (\') může způsobit problémy na některých zařízeních. + Pro jméno účtu použijte svou e-mailovou adresu, protože Android bude brát jméno účtu jako údaj pro ORGANIZÁTORA vytvořených událostí. Nelze mít dva účty stejného jména. + Metoda seskupování kontaktů: + Je třeba zadat název pro účet + Tento název účtu už je používán někým jiným + Pokročilé přihlášení + Certifikát klienta: %s + Nenalezen žádný certifikát + Instalovat certifikát + Google kontakty/kalendář + Google účet + Přihlásit se Googlem + Identif. klienta (volitelné) + Zásady ochrany osobních údajů.]]> + Zásadám dat služeb Google API, zahrnují Požadavky omezeného použití.]]> + Nelze získat autorizační kód + Nextcloud + Přihlásit pomocí Nextcloud + Toto spustí Nextcloud přihlášení ve webovém prohlížeči. + Adresa Nextcloud serveru + Přihlásit se + Nelze získat přihlašovací URL + Nelze získat přihlašovací data + Zjišťování nastavení + Chvíli strpení, probíhá dotazování serveru… + Nedaří se nalézt službu CalDAV nebo CardDAV. + našeho seznamu testovaných služeb a jejich základní URL.]]> + Prosíme též překontrolujte přihlašovací údaje (obvykle jméno a heslo). + Další technické informace jsou k dispozici v logu. + Zobrazit záznamy událostí + + Synchronizace + Interval synchronizace kontaktů + Pouze ručně + Každých %d minut a ihned při lokálních změnách + Interval synchronizace kalendáře + Interval synchronizace úkolů + + Pouze ručně + Každých 15 minut + Každých 30 minut + Každou hodinu + Každé 2 hodiny + Každé 4 hodiny + Jednou za den + + Synchronizovat pouze přes WiFi + Synchronizace omezena na WiFi připojení + Druh připojení není brán v potaz + Omezení na názvy (SSID) WiFi sítí + Bude synchronizovat pouze přes %s + Bude použito libovolné WiFi připojení + Čárkou oddělovaný seznam názvů (SSID) WiFi sítí, přes které synchronizovat (pokud omezovat nechcete, nevyplňujte) + Omezení na názvy (SSID) WiFi sítí vyžaduje další nastavení + Spravovat + VPN vyžaduje nadřazené internetové připojení + VPN bez ověřeného internetového připojení není dostatečné pro synchronizaci (doporučeno) + VPN bez ověřeného internetového připojení je dostatečné pro synchronizaci + Ověření + Uživatelské jméno + Nové heslo + Aktualizovat heslo dle svého serveru. + Certifikát klienta + Certifikát není vybrán nebo k dispozici + Instalovat certifikát + CalDAV + Časový limit pro staré události + Synchronizovat všechny události + + Ignorovat události starší než 1 den + Ignorovat události starší než %d dny + Ignorovat události starší než %d dnů + Ignorovat události starší než %d dny + + Události z minulosti starší, než vyznačený počet dnů, budou ignorovány (lze zadat 0). Pokud chcete synchronizovat všechny události, nevyplňujte. + Výchozí připomínka + + Výchozí připomínka jednu minutu před událostí + Výchozí připomínka %d minuty před událostí + Výchozí připomínka %d minut před událostí + Výchozí připomínka %d minuty před událostí + + Nejsou vytvořené žádné výchozí připomínky + Pokud mají být pro události bez připomínky vytvořeny výchozí připomínky: požadovaný počet minut před událostí. Pokud výchozí připomínky nechcete, nevyplňujte. + Spravovat barvy kalendářů + Barvy kalendáře jsou při každé synchronizaci resetovány + Barvy kalendáře je možné nastavovat ostatními aplikacemi + Podpora pro barvy událostí + Barvy událostí jsou synchronizovány + Barvy událostí nejsou synchronizovány + CardDAV + Metoda seskupování kontaktů + + Skupiny jsou zvlášt vCards vizitky + Skupiny jsou kategorie u jednotlivých kontaktů + + + Vytvořit adresář + Vytváření adresář kontaků přes CardDAV nemusí být serverem podporováno. + Vytvořit kalendář + + Možné položky kalendáře + Události + Úkoly + Poznámky / deník + Vytváření kalendáře přes CalCAV nemusí být serverem podporováno. + Barva + Nadpis + Umístění úložiště + Popis (volitelný) + Vytvořit + + Smazat sadu + Tato kolekce (%s) a její data budou navždy odstraněna, lokálně i na serveru. + Synchronizace + Synchronizace povolena + Synchronizace zakázána + Pouze pro čtení + Jen pro čtení (od serveru) + Jen pro čtení (jen lokálně) + Čtení/zápis + Nadpis + Popis + Vlastník + Podpora Push + Server propaguje podporu Push + Poslední synchronizace (%s) + Adresa (URL) + + Ladící informace + ZIP archiv + Obsahuje ladící informace a záznamy událostí + Nasdílet archiv pro účely přenosu do počítače, ze kterého odeslat e-mailem nebo přiložit k žádosti o technickou podporu. + Nasdílet archiv + Ladící informace připojené k této správě (vyžaduje podporu pro přílohy na straně přijímající aplikace). + Chyba HTTP + Chyba serveru + Chyba WebDAV + Chyba vstupu/výstupu + Zobrazit podrobnosti + Ladící informace byly shromážděny + Prostředky, kterých se týká + Související s problémem + Prostředek na protějšku: + Místní prostředek: + Záznamy událostí + Jsou k dispozici podrobnější záznamy událostí + Zobrazit záznamy událostí + Zkopírovat URL adresu + + Došlo k chybě. + Došlo k HTTP chybě. + Došlo k chybě vstupu/výstupu. + Zobrazit podrobnosti + + WebDAV připojení + Využitá kvóta: %1$s / k dispozici: %2$s + Obsah sdílení + Odpojit + Přidat WebDAV připojení + Přímo přistupujte ke svým souborům v cloudu přidáním WebDAV připojení! + Zobrazovaný název + WebDAV URL + Neplatná URL + Ověření + Uživatelské jméno + Heslo + Přidat připojení (mount) + Na této URL se nenachází žádná WebDAV služba + Odebrat přípojný bod + Podrobnosti o spojení budou ztraceny, ale soubory nebudou smazány. + Přistupuje se k WebDAV souboru + Stahuje se WebDAV soubor + Nahrává se WebDAV soubor + WebDAV připojení + + DAVx⁵ oprávnění + Vyžadována dodatečná oprávnění + Příliš stará verze %s + Nejnižší požadovaná verze: %1$s + Ověření se nezdařilo (zkontrolujte přihlašovací údaje) + Chyba sítě nebo vstupu/výstupu – %s + Chyba HTTP serveru – %s + Chyba místního úložiště – %s + Lehká chyba (maximální počet pokusů dosažen) + Ze serveru obdržen neplaný kontakt + Ze serveru obdržena neplatná událost + Ze serveru obdržen neplatný úkol + Ignoruje se jeden či více neplatný prostředků + + Synchronizovat vše + Synchronizovat všechny účty + + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..8f94e28 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,416 @@ + + + + Konto findes ikke (længere) + DAVx⁵ adressebog + Foretag ikke ændringer i din konto her! Brug i stedet app\'en til direkte at håndtere konti. + Slet + Fjern + Annullér + Aktivér + Feltet er påkrævet + Hjælp + Navigér opad + Indstillinger + Del + Synkroniseringen er startet/sat i kø + Databasen er korrupt + Alle konti er fjernet lokalt. + Fejlfinding + Andre vigtige beskeder + Lav-prioritet statusbeskeder + Synkronisering + Synkroniseringsfejl + Vigtige fejl, såsom uventede serversvar, der stopper synkroniseringen + Synkroniseringsadvarsler + Ikke-kritiske synkroniseringproblemer såsom ugyldige filer + Netværks- og I/O-fejl + Timeouts, forbindelsesproblemer m.m. (ofte midlertidig) + + Dine data. Dit valg. + Tag kontrol + Regelmæssige synkroniseringsintervaller + For regelmæssige synkroniseringsintervaller, skal %s have tilladelse til at køre i baggrunden. Ellers kan Android til enhver tid pause synkronisering. + Jeg behøver ikke regelmæssige synkroniseringsintervaller.* + %s kompatibilitet + Jeg har udført de krævede indstillinger. Mind mig ikke om det mere. * + * Efterlad åben for senere påmindelser. Kan nulstilles i programindstillinger / %s. + Mere information + jtx Board + + Understøttelse af opgaver + Hvis din server understøtter opgaver, kan de blive synkroniseret med en understøttet opgave-app: + OpenTasks + Ser ikke ud til at blive udviklet længere - ikke anbefalet. + Tasks.org + Ingen app-store tilgængelig + Jeg behøver ikke opgaveunderstøttelse.* + Åben kilde program + Vi er glade for, at du bruger %s, som er åben-kilde software. Udvikling, vedligeholdelse og support er hårdt arbejde. Overvej at bidrage (der er mange måner) eller donere. Det ville være meget værdsat! + Sådan bidrager/donerer du + Næste + + Tilladelser + %s kræver tilladelser for at virke rigtig. + Alle de nedenstående + Brug denne for at aktivere alle funktioner (anbefales) + Alle tilladelser er givet + Tilladelser til kontakter + Ingen kontaktsynkronisering (anbefales ikke) + Kontaktsynkronisering mulig + Tilladelser til kalender + Ingen kalendersynkronisering (anbefales ikke) + Kalendersynkronisering mulig + Tilladelse til notifikationer + Notifikationer deaktiveret (anbefales ikke) + Notifikationer slået til + Tilladelser til jtx Board + Tilladelser til OpenTasks + Tilladelser til opgaver + Ingen opgavesynkronisering + Opgavesynkronisering mulig + Behold tilladelser + Tilladelser kan blive nulstillet automatisk (anbefales ikke) + Tilladelser vil ikke blive nulstillet automatisk + Tryk på tilladelser > fravælg \"Fjern tilladelser hvis program ikke bruges\" + Hvis det ikke virker at skifte, brug app-indstillinger / tilladelser. + App-indstillinger + + Tilladelser til WiFi SSID + For at tilgå nuværende trådløs navn (SSID), skal følgende betingelser være opfyldt: + Præcis placerings tilladelse + Placering rettighed tildelt + Placering rettighed afvist + Baggrund placerings-rettighed + Tillad altid + Lokaliseringstilladelse sat til: %s + Lokationstilladelse ikke sat til: %s + Placering altid aktiveret + Placering tjeneste er aktiveret + Placering tjeneste er deaktiveret + + Oversættelser + Biblioteker + Version %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) og bidragydere + Dette program leveres ABSOLUT UDEN GARANTI. Det er fri software, og du er velkommen til at videredistribuere det under visse betingelse. + + Kan ikke oprette log fil + Logger alle %s aktiviteter + Vis/del + Deaktivere + + CalDAV/CardDAV synkroniseringsadapter + Om / licens + Beta tilbagemelding + Installere netlæser + Opsætning + Nyheder & opdateringer + Værktøj + Eksterne henvisninger + Netsted + Manual + OSS + Fællesskab + Støt projektet + Sådan bidrager du + Privatlivs politik + Synkroniser alle konti + + Notifikationer slået fra. Du vil ikke blive gjort opmærksom på synkroniserings-fejl. + Aministrer forbindelser + Gemning af data slået til. Synkronisering i baggrunden er begrænset. + Administrer gemning af data + Batteribesparelse aktiveret. Synkronisering kan være forhindret. + Administrer batteribesparelse + 124 +Lav lagerplads. Android vil ikke synkronisere lokale ændringer med det samme, men under den næste almindelige synkronisering. + Administrer lagerplads + + Registrering af tjeneste kunne ikke foretages + Kunne ikke opdatere samling liste + + Kører i forgrund + På nogle enheder er dette nødvendigt for automatisk synkronisering. + + Indstillinger + Fejlsøgning + Vis fejlsøgnings information + Uddybende logning + Logning er deaktiveret + Batteri optimering + Appen er undtaget (anbefalet) + Batteribesparelse aktiv (anbefales ikke) + Forbindelse + Proxy type + + System standard + Ingen proxy + HTTP + SOCKS (til Orbot) + + Proxy værtsnavn + Proxy port + Sikkerhed + Program tilladelser + Gennemgå tilladelser krævet til synkronisering + Stol ikke på systemcertifikater + System og brugertilføjede CA\'er vil ikke blive betroet + System og brugertilføjede CA\'er vil blive betroet (anbefalet) + Nulstil betroede / ikke betroede certifikater + Nulstiller tilliden til brugerdefinerede certifikater + Alle brugerdefinerede certifikater er blevet rydet + Brugerflade + Notifikations indstillinger + Håndtér notifikationskanaler og deres opsætning + Vælg tema + + System standard + Lys + Mørk + + Nulstil vejledende pop op + Genaktivere tidligere lukket pop op + Al vejledning vil blive vist igen + Integration + Opgaver program + Der er ikke fundet et program der kan håndtere opgaver + + CardDAV + CalDAV + Webcal + Der kræves yderligere tilladelser for at synkronisere disse datasamlinger. + Administrer rettigheder + Synkronisere + Opsætning af konti + Omdøb konto + Ikke gemt lokal data kan blive afvist. Gen-synkronisering er påkrævet efter omdøbning. + Nyt kontonavn + Omdøbe + Konto navn er allerede i brug + Kunne ikke omdøbe konto + Slet konto + Slet konto? + Alle lokale kopier af addessebøger, kalendere og opgavelister vil blive slettet. + synkronisere samling + skrivebeskyttet + kalender + Kontakter + journal + Opgaver + Vis kun personlig + Opdater listen + Webcal-abonnementer kan synkroniseres med eksterne apps. + Der er ikke fundet noget program der kan håndtere Webcal. + Installere ICSx⁵ + + Tilføj konto + Generisk login + Provider-specifikt login + Fortsæt + Log ind + Log ind med e-mail adresse + E-mail adresse + Gyldig e-mail adresse påkrævet + Tjenester findes ved hjælp af DNS-records og velkendte URL\'er]]>. + Adgangskode + Skjul password + Vis kodeord + Log ind med URL og brugernavn + Brugernavn + Basis URL + tjenester opdages også ved hjælp af DNS-records og velkendte URL\'er]]>. + Vælge certifikat + Tilføj konto + Kontonavn + Brug af apostroffer (\') ser ud til at give problemer på nogle enheder. + Brug en e-mail adresse som kontonavn da Android bruger kontonavn til ORGANIZER-felt for oprettede aktiviteter. Man kan ikke have to konti med samme navn. + Gruppering af kontakter: + Kontonavn påkrævet + Konto navn er allerede i brug + Avanceret login + Klientcertifikat: %s + Intet certifikat fundet + Installere certifikat + Google Kontakter / Kalender + Google konto + Log ind med Google + Klient ID (valgfrit) + Privacy policy for detaljer.]]> + Google API Services User Data Policy, herunder kravene til begrænset brug.]]> + Kunne ikke hente autorisationskode + Nextcloud + Log ind med Nextcloud + Starter Nextcloud Login Flow i en webbrowser. + Nextcloud serveradresse + Log ind + Kunne ikke hente login-URL + Kunne ikke hente login-data + Check konfiguration + Vent, forespørger serveren… + Kunne ikke finde CalDAV- eller CardDAV-tjeneste. + Basis-URL\'en ser ikke ud til at være en tilgængelig CalDAV/CardDAV-URL, og tjenestegenkendelsen lykkedes ikke. + vores liste af testede tjenester og deres basis-URL\'er.]]> + Dobbelttjek også autentificeringen (oftest brugernavn og adgangskode). + Yderligere tekniske oplysninger findes i logfilerne. + Vis logfiler + + Synkronisering + Synkroniseringsinterval for kontakter + Kun manuelt + Hver %d minutter + øjeblikkeligt ved lokale ændringer + Synkroniseringsinterval for kalender + Synkroniseringsinterval for opgaver + + Kun manuelt + Hvert 15. minut + Hver halve time + Hver time + Hver 2. time + Hver 4. time + En gang om dagen + + Synkroniser kun over WiFi + Synkronisering er begrænset til WiFi-forbindelser + Forbindelsestypen har ingen betydning + WiFi SSID-begrænsning + Synkroniserer kun over %s + Alle WiFi-forbindelser vil blive anvendt + Kommaseparerede navne (SSID\'er) over tilladte WiFi-netværk (efterlad blank for at bruge alle) + Trådløs SSID begrænsning kræver yderligere opsætning + Håndtere + VPN kræver underliggende internet + VPN uden underliggende valideret internetforbindelse er ikke nok til at køre synkronisering (anbefales) + VPN uden underliggende valideret internetforbindelse er nok til at køre synkronisering + Adgangsgodkendelse + Brugernavn + Ny adgangskode + Opdater adgangskoden, så den svarer til din server. + Klientcertifikat + Intet certifikat tilgængeligt eller udvalgt + Installere certifikat + CalDAV + Tidsafgrænsning for tidligere begivenheder + Alle begivenheder vil blive synkroniseret + + Begivenheder ældre end en dag vil blive ignoreret + Begivenheder, der er mere end %d dage gamle, vil blive ignoreret + + Begivenheder, som er mere end dette antal dage gamle vil blive ignoreret (kan også være 0). Hvis feltet ikke er udfyldt, vil alle begivenheder blive synkroniseret. + Standard påmindelse + + Standard påmindelse 1 minut før hændelse + Standard påmindelse %d minutter før hændelse + + Ingen standard påmindelse oprettet + Hvis standard påmindelse skal oprettes for hændelse uden påmindelse: antal minutter før hændelse. Efterlad tom for at deaktivere standard påmindelse. + Administrer farver for kalender + Kalender farver nulstilles ved hver synkronisering + Kalender farver kan sættes fra andre programmer + Farver for begivenheder + Farver for begivenheder er synkroniseret + Farver for begivenheder er ikke synkroniseret + CardDAV + Gruppering af kontakter + + Grupper er særskilte vCards + Grupper er kategorier per kontakt + + + Opret adressebog + Oprettelse af adressebøger via CardDAV understøttes muligvis ikke af serveren. + Opret kalender + + Mulige kalenderposte + Begivenheder + Opgaver + Notater / journal + Kalenderoprettelse via CalDAV understøttes muligvis ikke af serveren. + Farve + Titel + Lager placering + Beskrivelse (valgfrit) + Opret + + Kontakter + Opgaver + Slet sæt + Denne samling (%s) og al dens data vil blive permanent fjernet, både lokalt og på serveren. + Synkronisering + Synkronisering aktiveret + Synkronisering deaktiveret + Skrivebeskyttet + Skrivebeskyttet (af server) + Skrivebeskyttet (kun lokalt) + Læs/skriv + Titel + Beskrivelse + Ejer + Sidste synkronisering (%s) + Adresse (URL) + + Debug-info + ZIP arkiv + Indeholder fejlsøgnings information og log + Del arkivet for at overføre det til en computer, sende det via e-mail eller vedhæfte til en support sag. + Del arkiv + Fejlsøge informationer vedhæftet denne meddelelse (kræver vedhæftning understøttelse hos modtagende program). + HTTP fejl + Server fejl + WebDAV fejl + Ind/ud fejl + Vis detaljer + Fejlsøgnings information er indsamlet + Involveret ressourcer + Relateret til problemet + Fjern ressource: + Lokal ressource: + Log + Uddybende log er tilgængelig + Vis logfiler + Kopiere URL + + Der er opstået en fejl. + Der er opstået en HTTP-fejl. + Der er opstået en I/O-fejl. + Vis detaljer + + WebDAV monteringspunkter + Mængde brugt: %1$s / tilgængelig: %2$s + Del indhold + Afmontere + Tilføj WebDAV monteringspunkt + Tilføj en WebDAV mount for at tilgå dine filer i skyen + Vis navn + WebDAV URL + Ugyldig URL + Adgangsgodkendelse + Brugernavn + Adgangskode + Tilføj monteringspunkt + Ingen WebDAV tjeneste på URL\'en + Fjern monteringspunkt + Forbindelses informationer vil gå tabt, men ingen filer slettes. + Tilgå WebDAV fil + Hent WebDAV fil + Overfør WebDAV fil + Montere WebDAV + + DAVx⁵-rettigheder + Yderligere adgang påkrævet + %s for gammel + Påkrævet version: %1$s + Login mislykkedes (check loginoplysninger) + Netværks- eller I/O-fejl - %s + HTTP-serverfejl - %s + Lokal lagringsfejl - %s + Blød fejl (maks forsøg nået) + Modtaget ugyldig kontakt fra server + Modtaget ugyldig begivenhed fra server + Modtaget ugyldig opgave fra server + Ignorere en eller flere ugyldige kilder + + Synkroniser alle + Synkroniser alle konti + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..13ae645 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,481 @@ + + + + Konto nicht (mehr) vorhanden + DAVx⁵-Adressbuch + Konto nicht hier ändern! Um Konten zu verwalten, stattdessen direkt die App nutzen. + Löschen + Entfernen + Abbrechen + Aktivieren + Feld wird benötigt + Hilfe + Aufwärts navigieren + Auswahlmenü + Teilen + Synchronisierung gestartet/eingereiht + Datenbank beschädigt + Alle Konten wurden lokal entfernt. + Fehlersuche + Andere wichtige Mitteilungen + Weniger wichtige Statusmitteilungen + Synchronisierung + Synchronisierungsfehler + Fehler, die zum Abbruch der Synchronisierung führen, wie z.B. unerwartete Serverantworten + Synchronisierungswarnungen + Nicht fatale Synchronisierungsprobleme wie bestimmte ungültige Dateien + Netzwerk- und E/A-Fehler + Zeitüberschreitungen, Verbindungsprobleme, usw. (oft vorübergehend) + + Dein Leben. Deine Daten. + Deine Entscheidung. + Regelmäßige Sync-Intervalle + Zur Synchronisierung in regelmäßigen Intervallen muss %s im Hintergrund laufen dürfen; ansonsten kann Android die Synchronisierung jederzeit aussetzen. + Ich brauche keine regelmäßigen Sync-Intervalle.* + %s-Kompatibilität + Herstellerspezifische Firmware blockiert möglicherweise die Synchronisierung. Wenn Sie davon betroffen sind, können Sie dies nur manuell beheben. + Ich habe die Einstellungen gemacht, nicht mehr erinnern.* + * Nicht anwählen, um später erinnert zu werden. Kann unter App-Einstellungen / %s zurückgesetzt werden. + Mehr Infos + jtx Board + + Unterstützung für Aufgaben + Falls der Server Aufgaben unterstützt, können sie mit einer unterstützten App synchronisiert werden: + OpenTasks + Wird anscheinend nicht weiterentwickelt – nicht empfohlen. + Tasks.org + werden nicht unterstützt.]]> + Kein App-Store verfügbar + Ich brauche keine Unterstützung für Aufgaben.* + Open-Source-Software + Wir freuen uns, dass Sie die Open-Source-Software %s verwenden. Entwicklung, Wartung und Support sind viel Arbeit. Ziehen Sie daher bitte in Betracht, mitzuhelfen (dazu gibt es viele Möglichkeiten) oder zu spenden. Vielen Dank! + Infos zum Mithelfen/Spenden + Nicht daran erinnern für + + %d Monat + %d Monate + + Weiter + + Berechtigungsverwaltung + %s benötigt Berechtigungen, um ordnungsgemäß zu funktionieren. + Alles darunter + Hiermit können alle Funktionen aktiviert werden (empfohlen) + Alle Berechtigungen gewährt + Kontakte-Berechtigungen + Keine Kontakte-Synchronisierung (nicht empfohlen) + Kontakte-Synchronisierung möglich + Kalender-Berechtigungen + Keine Kalender-Synchronisierung (nicht empfohlen) + Kalender-Synchronisierung möglich + Benachrichtigungsberechtigung + Benachrichtigungen deaktiviert (nicht empfohlen) + Benachrichtigungen aktiviert + jtx Board-Berechtigungen + OpenTasks-Berechtigungen + Tasks-Berechtigungen + Keine Aufgaben-Synchronisierung + Aufgaben-Synchronisierung möglich + Berechtigungen behalten + Berechtigungen können automatisch entzogen werden (nicht empfohlen) + Berechtigungen werden nicht automatisch entzogen + Berechtigungen > \"Berechtigungen entfernen, wenn die App nicht verwendet wird\" abwählen + Wenn ein Schalter nicht funktioniert, App-Einstellungen / Berechtigungen verwenden. + App-Einstellungen + + WLAN-SSID-Berechtigungen + Um auf den aktuellen WLAN-Namen (SSID) zugreifen zu können, müssen folgende Bedingungen erfüllt werden: + Exakter Standort-Berechtigung + Standort-Zugriff erlaubt + Standort-Zugriff verweigert + Hintergrund-Standort-Berechtigung + Immer zulassen + Standort-Zugriff eingestellt auf: %s + Standort-Zugriff nicht eingestellt auf: %s + %s benutzt Standortdaten (nur WLAN-SSID) ausschließlich, um die Synchronisierung auf ein bestimmtes WLAN zu beschränken. Dies geschieht auch dann, wenn die Synchronisierung im Hintergrund ausgeführt wird. + Alle Standortdaten (nur WLAN-SSID) werden nur lokal verwendet und nicht an Dritte weitergegeben. + Standort-Dienst immer aktiviert + Standort-Dienst aktiv + Standort-Dienst inaktiv + + Übersetzungen + Bibliotheken + Version %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) und Mitwirkende + Dieses Programm wird OHNE JEDE GEWÄHRLEISTUNG bereitgestellt. Es ist freie Software – Sie können es also unter bestimmten Bedingungen weiterverbreiten. + + Protokolldatei konnte nicht angelegt werden + Alle %s-Aktivitäten werden protokolliert + Anzeigen/teilen + Deaktivieren + + CalDAV/CardDAV-Sync-Adapter + Über / Lizenz + Beta-Rückmeldung + Bitte installieren Sie einen Web-Browser + Einstellungen + Aktuelles + Werkzeuge + Externe Links + Homepage + Handbuch + FAQ + Für Organisationen + Community + Projekt unterstützen + Einen Beitrag leisten + Datenschutzerklärung + Willkommen bei DAVx⁵! + Verbinden Sie sich mit Ihrem Server und synchronisieren Sie Ihre Kalender und Kontakte. + Alle Konten synchronisieren + + Benachrichtigungen deaktiviert. Sie werden nicht über Fehler bei der Synchronisierung informiert. + Automatische Synchronisation nicht aktiv (keine überprüfte Internetverbindung). + Verbindungen steuern + Datensparen aktiviert. Hintergrundsynchronisierung ist eingeschränkt. + Datensparen verwalten + Energiesparmodus aktiviert. Synchronisierung kann eingeschränkt sein. + Energieeinstellungen verwalten + Wenig Speicherplatz. Android wird lokale Änderungen nicht sofort synchronisieren, sondern bei der nächsten regulären Synchronisierung. + Speicherplatz verwalten + Kalender-Provider fehlt + Haben Sie die »Kalender«-System-App deaktiviert? + Kontakte-Provider fehlt + Haben Sie die »Kontakte«-System-App deaktiviert? + Apps verwalten + + Diensterkennung fehlgeschlagen + Ordnerliste konnte nicht aktualisiert werden + + Läuft im Vordergrund + Auf manchen Geräten für die automatische Synchronisierung benötigt + + Einstellungen + Fehlersuche + Informationen zur Fehlersuche + Einstellungsdetails und Logs anzeigen/teilen + Ausführliche Protokollierung + Logging ist aktiv. Sie können die Logs in den Debuginformationen anzeigen. + Keine Protokollierung + Akku-Optimierung + App ist ausgenommen (empfohlen) + Akku-Einschränkungen gelten (nicht empfohlen) + Verbindung + Proxy-Typ + + System-Standard + Kein Proxy + HTTP + SOCKS (für Orbot) + + Proxy-Rechnername + Proxy-Port + Sicherheit + App-Berechtigungen + Für die Synchronisierung benötigte Berechtigungen prüfen + Systemzertifikaten nicht vertrauen + System- und installierten CAs wird nicht vertraut + System- und installierten CAs wird vertraut (empfohlen) + Wenn diese Einstellung aktiv ist, werden Systemzertifikate als nicht vertrauenswürdig erachtet. Das bedeutet, dass Sie jedes Zertifikat (auch wenn der Server sein Zertifikat auffrischt) von Hand akzeptieren müssen; sonst werden Kontoeinrichtung und Synchronisierung nicht funktionieren. + Zertifikat-Vertrauen zurücksetzen + Setzt angenommene/abgelehnte Zertifikate zurück + Angenommene/abgelehnte Zertifikate zurückgesetzt + Oberfläche + Benachrichtigungseinstellungen + Benachrichtigungskanäle und -einstellungen verwalten + Aussehen wählen + + wie System + heller Stil + dunkler Stil + + Hinweise zurücksetzen + Hinweise, die deaktiviert wurden, wieder anzeigen + Alle Hinweise werden wieder angezeigt + Integration + Aufgaben-App + Keine kompatible Aufgaben-App gefunden + UnifiedPush (experimentell) + Keiner (Push deaktivieren) + Anbieter auswählen + Kein Push-Anbieter installiert + Kein Endpunkt konfiguriert + Bereit, Push-Mitteilungen über %s zu empfangen + FCM (Google Play) + Push-Nachrichten sind immer verschlüsselt. + + Konto wurde entfernt + CardDAV + CalDAV + Webcal + Für die Synchronisierung dieser Ordner sind zusätzliche Berechtigungen erforderlich. + Berechtigungen verwalten + Jetzt synchronisieren + Konto-Einstellungen + Konto umbenennen + Nicht gespeicherte lokale Daten können verloren gehen. Nach dem Umbenennen ist eine erneute Synchronisierung erforderlich. + Neuer Kontoname + Umbenennen + Kontoname bereits verwendet + Konto konnte nicht umbenannt werden + Konto löschen + Konto wirklich löschen? + Alle Adressbücher, Kalender und Aufgabenlisten werden vom Gerät (nicht am Server) gelöscht. + Diesen Ordner synchronisieren + schreibgeschützt + Kalender + Kontakte + Journal + Aufgaben + Nur eigene anzeigen + Liste aktualisieren + Webcal-Abonnements können mit externen Apps synchronisiert werden. + Keine Webcal-App gefunden + ICSx⁵ installieren + + Konto hinzufügen + Datenschutzbestimmungen.]]> + Allgemeine Anmeldung + Provider-spezifische Anmeldung + Fortfahren + Anmelden + Mit E-Mail-Adresse anmelden + E-Mail-Adresse + Gültige E-Mail-Adresse benötigt + Dienst-Erkennung erfolgt über DNS-Einträge und well-known-URLs.]]> + Passwort + Passwort ausblenden + Passwort anzeigen + Passwort (optional) + Mit URL und Benutzername anmelden + Benutzername + Benutzername (optional) + Basis-URL + Dienst-Erkennung über DNS-Einträge und well-known-URLs.]]> + Zertifikat auswählen + Konto hinzufügen + Kontoname + Das Verwenden von Apostrophen (\') scheint auf einigen Geräten Probleme zu verursachen. + Verwenden Sie Ihre E-Mail-Adresse als Kontonamen, da Android den Kontonamen als ORGANIZER einsetzt. Es kann allerdings keine zwei Konten mit dem gleichen Namen geben. + Kontaktgruppen-Methode: + Kontoname wird benötigt + Kontoname bereits verwendet + Konto konnte nicht hinzugefügt werden + Abschließen + Erweiterte Anmeldung + Kein Client-Zertifikat (optional) + Client-Zertifikat: %s + Kein Zertifikat gefunden + Zertifikat installieren + Fastmail + Fastmail-Konto + Mit Fastmail anmelden + Google-Kontakte / -Kalender + Google-Konto + Mit Google anmelden + Client-ID (optional) + Datenschutzrichtlinie für mehr Informationen.]]> + Google API Services Nutzerdaten-Richtlinie, inklusive der eingeschränkten Nutzungsbedingungen.]]> + Authentifizierungscode konnte nicht abgerufen werden + Nextcloud + Mit Nextcloud anmelden + Dadurch wird der Nextcloud-Anmeldevorgang in einem Webbrowser gestartet. + Nextcloud-Serveradresse + Anmeldung + Login-URL konnte nicht abgerufen werden + Anmeldedaten konnten nicht abgerufen werden + Ressourcen-Erkennung + Server wird abgefragt. Bitte warten … + Es konnte weder ein CalDAV- noch ein CardDAV-Dienst gefunden werden. + Die Basis-URL scheint keine erreichbare CalDAV/CardDAV-URL zu sein und die Diensterkennung war nicht erfolgreich. + unsere Liste der getesteten Dienste und deren Basis-URLs.]]> + Bitte überprüfen Sie auch die Authentifizierung (normalerweise Benutzername und Passwort). + Weitere technische Informationen sind in den Protokollen verfügbar. + Protokoll anzeigen + + Synchronisierung + Häufigkeit der Kontakte-Synchronisierung + Nur manuell + Alle %d Minuten + sofort bei lokalen Änderungen + Häufigkeit der Kalender-Synchronisierung + Häufigkeit der Aufgaben-Synchronisierung + + Nur manuell + Alle 15 Minuten + Alle 30 Minuten + Jede Stunde + Alle 2 Stunden + Alle 4 Stunden + Einmal am Tag + + Nur über WLAN synchronisieren + Synchronisierung nur bei aktiver WLAN-Verbindung + Verbindungstyp wird nicht beachtet + WLAN-SSID-Beschränkung + Synchronisierung nur über %s + Alle WLAN-Verbindungen werden verwendet + Erlaubte WLAN-Namen (SSIDs), mit Komma getrennt (leer lassen für alle) + WLAN-SSID-Einschränkung benötigt weitere Einstellungen + Verwalten + VPN erfordert zugrundeliegendes Internet + VPN ohne zugrundeliegende überprüfte Internetverbindung reicht für Synchronisierung nicht aus (empfohlen) + VPN ohne zugrundeliegende überprüfte Internetverbindung reicht für Synchronisierung aus + Anmeldeinformationen + Benutzername + Passwort oder App-Passwort + App-Passwort verwenden.]]> + Neues Passwort + Aktualisieren Sie Ihr Passwort gemäß den Server-Einstellungen. + Erneut authentifizieren (OAuth) + Verwenden, wenn der Zugriff widerrufen wurde + Authentifizierung erfolgreich + Client-Zertifikat + Kein Zertifikat verfügbar oder ausgewählt + Zertifikat installieren + CalDAV + Abrufbeschränkung vergangener Termine + Alle Termine werden synchronisiert + + Termine, die mehr als einen Tag in der Vergangenheit liegen, werden ignoriert + Termine, die mehr als %d Tage in der Vergangenheit liegen, werden ignoriert + + Termine, die mehr als diese Anzahl von Tagen in der Vergangenheit liegen, werden ignoriert (kann 0 sein). Feld leer lassen, um alle Termine zu synchronisieren. + Standard-Erinnerung + + Standard-Erinnerung eine Minute vor dem Ereignis + Standard-Erinnerung %d Minuten vor dem Ereignis + + Keine Standard-Erinnerungen + Wenn Standard-Erinnerungen für Termine ohne Erinnerung erzeugt werden sollen: gewünschte Anzahl der Minuten vor dem Ereignis. Leer lassen, um Standard-Erinnerungen zu deaktivieren. + Kalenderfarben verwalten + Kalenderfarben werden bei jeder Synchronisierung neu gesetzt + Kalenderfarben können von anderen Apps festgesetzt werden + Unterstützung für Terminfarben + Terminfarben werden synchronisiert + Terminfarben werden nicht synchronisiert + CardDAV + Kontaktgruppen-Methode + + Gruppen sind eigene vCards + Gruppen sind Kategorien der Kontakte + + + Adressbuch erstellen + Das Erstellen von Adressbüchern über CardDAV wird vom Server möglicherweise nicht unterstützt. + Kalender anlegen + Standard-Zeitzone (optional) + + Mögliche Kalendereinträge + Termine + Aufgaben + Notizen / Journal + Das Erstellen von Kalendern über CardDAV wird vom Server möglicherweise nicht unterstützt. + Farbe + Titel + Speicherort + Beschreibung (optional) + Erstellen + + Kontakte + Termine + Aufgaben + Ordner löschen + Dieser Ordner (%s) und alle enthaltenen Daten werden dauerhaft entfernt, sowohl lokal als auch auf dem Server. + Synchronisierung + Synchronisierung aktiviert + Synchronisierung deaktiviert + Schreibgeschützt + Schreibgeschützt (durch Server) + Schreibgeschützt (laut Richtlinie) + Schreibgeschützt (nur lokal) + Lesen/Schreiben + Titel + Beschreibung + Besitzer:in + Push-Unterstützung + Server bietet Push-Unterstützung + Um %1$s angemeldet, läuft ab %2$s + Letzte Synchronisierung (%s) + Adresse (URL) + + Informationen zur Fehlersuche + ZIP-Archiv + Beinhaltet Debug-Info und Logs + Das Archiv teilen, um es zu einem Rechner zu übertragen, per Email zu verschicken oder an ein Support-Ticket anzuhängen. + Archiv teilen + Debug-Informationen sind dieser Nachricht beigelegt (benötigt Unterstützung für Anhänge in der empfangenden App). + HTTP-Fehler + Serverfehler + WebDAV-Fehler + E/A-Fehler + Die Anfrage wurde vom Server abgelehnt. + Die angeforderte Ressource existiert nicht (mehr). + Der Server erlaubt die angeforderte Art der Operation nicht. + Es trat ein serverseitiges Problem auf. Wenden Sie sich bitte an den Server-Support. + Es trat ein unerwarteter Fehler auf. Einzelheiten dazu finden Sie in der Debug-Info. + Details anzeigen + Debug-Informationen wurden gesammelt + Beteiligte Ressourcen + Im Zusammenhang mit dem Problem + Entfernte Ressource: + Lokale Ressource: + Protokoll + Ausführliches Protokoll verfügbar + Logs anzeigen + URL kopieren + Datenschutzhinweis + Protokolle und Debug-Informationen können private Daten enthalten. Seien Sie sich dessen bewusst, wenn Sie diese öffentlich weitergeben. + + Ein Fehler ist aufgetreten. + Ein HTTP-Fehler ist aufgetreten. + Ein E/A-Fehler ist aufgetreten. + Details anzeigen + + WebDAV-Zugänge + Speicher belegt: %1$s / verfügbar: %2$s + Inhalt teilen + Aushängen + WebDAV-Zugang hinzufügen + Greifen Sie mit einem WebDAV-Zugang direkt auf Ihre Cloud-Dateien zu! + wie WebDAV-Zugänge funktionieren.]]> + Anzeigename + WebDAV-Adresse + Ungültige Adresse + Einhängepunkt und Anzeigename + Anmeldeinformationen + Anmeldename + Passwort + Benutzername (optional) + Passwort (optional) + Einhängen + Kein WebDAV-Dienst unter dieser Adresse + Einhängepunkt entfernen + Verbindungsdetails werden verloren gehen, es werden aber keine Dateien gelöscht. + WebDAV-Dateizugriff + WebDAV-Download + WebDAV-Upload + WebDAV-Zugang + + DAVx⁵-Berechtigungen + Zusätzliche Berechtigungen benötigt + %s zu alt + Benötigte Mindestversion: %1$s + Anmeldungsfehler (Login-Daten überprüfen) + Netzwerk- oder E/A-Fehler – %s + HTTP-Serverfehler – %s + Lokaler Speicherfehler – %s + Weicher Fehler (maximale Anzahl an Wiederholungen erreicht) + Ungültigen Kontakt vom Server erhalten + Ungültigen Termin vom Server erhalten + Ungültige Aufgabe vom Server erhalten + Eine/mehrere ungültige Ressourcen ignoriert + Synchronisierung ausstehend + Daten auf dem Server haben sich geändert + + Alles synchronisieren + Alle Konten synchronisieren + Beschriftete Sync-Taste + Sync-Taste-Symbol + Antippen, um die Synchronisierung manuell durchzuführen. + + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..c90ce7c --- /dev/null +++ b/app/src/main/res/values-el/strings.xml @@ -0,0 +1,414 @@ + + + + Ο λογαριασμός δεν υπάρχει (πια) + Βιβλίο διευθύνσεων DAVx⁵ + Διαγραφή + Αφαίρεση + Ακύρωση + Ενεργοποίηση + Αυτό το πεδίο είναι απαραίτητο + Βοήθεια + Πλοήγηση προς τα πάνω + Μενού επιλογών + Διαμοιρασμός + Ο συγχρονισμός ξεκίνησε/αναμονή στην ουρά + Υπάρχει σφάλμα στην βάση δεδομένων + Όλοι οι λογαριασμοί έχουν διαγραφεί τοπικά + Αποσφαλμάτωση + Άλλα σημαντικά μηνύματα + Μηνύματα κατάστασης χαμηλής προτεραιότητας + Συγχρονισμός + Σφάλματα συγχρονισμού + Σημαντικά σφάλματα τα οποία σταματούν τον συγχρονισμό, όπως οι απροσδόκητες απαντήσεις του διακομιστή + Προειδοποιήσεις συγχρονισμού + Μη μοιραία προβλήματα συγχρονισμού όπως ορισμένα μη έγκυρα αρχεία + Σφάλματα δικτύου και I/O + Χρονικά όρια, προβλήματα σύνδεσης, κλπ. (συχνά προσωρινά) + + Τα δεδομένα σου. Οι επιλογές σου. + Πάρε τον έλεγχο. + Τακτά διαστήματα συγχρονισμού + Για συγχρονισμό σε τακτικά διαστήματα, πρέπει να επιτραπεί στο %s να λειτουργεί στο παρασκήνιο. Αλλιώς, το Android μπορεί να παύση τον συγχρονισμό ανά πάσα στιγμή. + Δεν χρειάζομαι τακτικά διαστήματα συγχρονισμού.* + %s Συμβατότητα + Έχω κάνει τις απαιτούμενες ρυθμίσεις. Δεν θέλω άλλες υπενθυμίσεις.* + Άφησε απενεργοποιημένες τις υπενθυμίσεις. Μπορούν να επαναφερθούν στις ρυθμίσεις εφαρμογών / %s. + Περισσότερες πληροφορίες + Πίνακας jtx + + Υποστήριξη εργασιών + Εάν οι εργασίες υποστηρίζονται από τον server σας, μπορούν να συγχρονιστούν με μια υποστηριζόμενη εφαρμογή εργασιών: + OpenTasks + Δεν φαίνεται να αναπτύσσεται πλέον - δεν συνιστάται. + Tasks.org + Δεν υπάρχει διαθέσιμο κατάστημα εφαρμογών + Δεν χρειάζομαι υποστήριξη εργασιών.* + Λογισμικό ανοικτού κώδικα + Χαιρόμαστε που χρησιμοποιείτε το %s, το οποίο είναι λογισμικό ανοικτού κώδικα. Η ανάπτυξη, η συντήρηση και η υποστήριξη είναι σκληρή δουλειά. Παρακαλούμε σκεφτείτε να συνεισφέρετε (υπάρχουν πολλοί τρόποι) ή να κάνετε μια δωρεά. Θα το εκτιμούσαμε ιδιαίτερα! + Πώς να συνεισφέρετε/δωρήσετε + + Δικαιώματα + Το %sχρειάζεται δικαιώματα για να λειτουργήσει σωστά. + Όλα τα παρακάτω + Χρησιμοποίησε αυτό για να ενεργοποιήσεις όλες τις λειτουργίες (προτείνεται) + Χορηγήθηκαν όλα τα δικαιώματα + Δικαιώματα επαφών + Μη συγχρονισμός επαφών (δεν προτείνεται) + Ο Συγχρονισμός επαφών είναι δυνατός + Δικαιώματα ημερολογίου + Μη συγχρονισμός ημερολογίου (δεν προτείνεται) + Ο Συγχρονισμός ημερολογίου είναι δυνατός + Άδεια ειδοποίησης + Οι ειδοποιήσεις είναι απενεργοποιημένες (δεν συνιστάται) + Ενεργοποιημένες ειδοποιήσεις + Πίνακας αδειών jtx + Δικαιώματα OpenTasks + Δικαιώματα εργασιών + Κανένας συγχρονισμός εργασιών + Ο Συγχρονισμός εργασιών είναι διαθέσιμος + Διατήρηση δικαιωμάτων + Τα δικαιώματα μπορούν να επαναφερθούν αυτόματα (δεν προτείνεται) + Τα δικαιώματα δεν θα επαναφερθούν αυτόματα + Κάντε κλικ στην επιλογή Άδειες > απενεργοποιήστε την επιλογή «Κατάργηση αδειών αν η εφαρμογή δεν χρησιμοποιείται» + Αν ο διακόπτης δεν λειτουργεί, χρησιμοποιήστε τις ρυθμίσεις της εφαρμογής / Άδειες. + Ρυθμίσεις εφαρμογής + + Δικαιώματα WiFi SSID + Για να είναι δυνατή η πρόσβαση σε αυτό το WiFi (SSID), χρειάζεται να πληρούνται οι παρακάτω προϋποθέσεις: + Δικαίωμα ακριβούς εντοπισμού + Ενεργοποιήθηκε το δικαίωμα εντοπισμού + Απενεργοποιήθηκε το δικαίωμα εντοπισμού + Δικαίωμα εντοπισμού στο παρασκήνιο + Να επιτρέπεται συνέχεια + Η άδεια τοποθεσίας έχει οριστεί σε: %s + Η άδεια τοποθεσίας δεν έχει οριστεί σε: %s + Η υπηρεσία εντοπισμού είναι πάντα ενεργοποιημένη + Η υπηρεσία εντοπισμού είναι ενεργοποιημένη + Η υπηρεσία εντοπισμού είναι απενεργοποιημένη + + Μεταφράσεις + Βιβλιοθήκες + Έκδοση %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) και συνεργάτες + Αυτό το πρόγραμμα συνοδεύεται χωρις ΚΑΜΙΑ ΕΓΓΥΗΣΗ. Είναι ελεύθερο λογισμικό και είστε ευπρόσδεκτοι να το αναδιανείμετε υπό ορισμένες προϋποθέσεις. + + Αδυναμία δημιουργίας αρχείου ιστορικού + Τώρα γίνεται η καταγραφή όλων των δραστηριοτήτων του %s + Προβολή/διαμοιρασμός + Απενεργοποίηση + + Προσαρμογέας συγχρονισμού CalDAV/CardDAV + Περί / άδεια χρήσης + Ανατροφοδότηση beta + Παρακαλούμε εγκαταστήστε έναν περιηγητή ιστού + Ρυθμίσεις + Νέα & ενημερώσεις + Εργαλεία + Εξωτερικοί σύνδεσμοι + Ιστότοπος + Χειροκίνητα + FAQ + Κοινότητα + Υποστηρίξτε το έργο + Πώς να συνεισφέρετε + Πολιτική απορρήτου + Συγχρονισμός όλων των λογαριασμών + + Οι ειδοποιήσεις είναι απενεργοποιημένες. Δεν θα λαμβάνετε ειδοποιήσεις για σφάλματα συγχρονισμού. + Διαχείριση συνδέσεων + Ενεργοποίηση της εξοικονόμησης δεδομένων. Ο συγχρονισμός στο παρασκήνιο είναι περιορισμένος. + Διαχείριση εξοικονόμησης δεδομένων + Ενεργοποιημένη εξοικονόμηση μπαταρίας. Ο συγχρονισμός μπορεί να είναι περιορισμένος. + Διαχείριση εξοικονόμησης μπαταρίας + Χαμηλός αποθηκευτικός χώρος. Το Android δεν θα συγχρονίσει τις τοπικές αλλαγές αμέσως, αλλά κατά τη διάρκεια του επόμενου τακτικού συγχρονισμού. + Διαχείριση αποθηκευτικού χώρου + + Αποτυχία ανίχνευσης υπηρεσίας + Αδυναμία ανανέωσης της λίστας συλλογής + + Εκτέλεση στο προσκήνιο + Σε κάποιες συσκευές, αυτό είναι απαραίτητο για τον αυτόματο συγχρονισμό + + Ρυθμίσεις + Αποσφαλμάτωση + Προβολή πληροφοριών αποσφαλμάτωσης + Λεπτομερής καταγραφή + Η καταγραφή είναι απενεργοποιημένη + Βελτιστοποίηση μπαταρίας + Η εφαρμογή εξαιρείται (συνιστάται) + Ισχύουν περιορισμοί για την μπαταρία (δεν συνιστάται) + Σύνδεση + Τύπος Proxy + + Προεπιλογή συστήματος + Χωρίς διαμεσολαβητή + HTTP + SOCKS (για το Orbot) + + Όνομα διακομιστή διαμεσολάβησης + Θύρα Proxy + Ασφάλεια + Δικαιώματα εφαρμογής + Αναθεώρηση των δικαιωμάτων που χρειάζονται για τον συγχρονισμό + Ακύρωση αξιοπιστίας πιστοποιητικών συστήματος + Τα πιστοποιητικά που προστέθηκαν από το σύστημα και τον χρήστη δεν θα είναι αξιόπιστα + Τα πιστοποιητικά που προστέθηκαν από το σύστημα και τον χρήστη θα είναι αξιόπιστα (συνιστάται) + Επαναφορά (μη)αξιόπιστων πιστοποιητικών + Επαναφέρει την εμπιστοσύνη όλων των προσαρμοσμένων πιστοποιητικών + Έχουν απαλειφθεί όλα τα προσαρμοσμένα πιστοποιητικά + Διεπαφή χρήστη + Ρυθμίσεις ειδοποιήσεων + Διαχείριση καναλιών ειδοποιήσεων και των ρυθμίσεών τους + Επιλογή θέματος + + Προεπιλογή Συστήματος + Φωτεινό + Σκοτεινό + + Επαναφορά συμβουλών + Ενεργοποιεί ξανά τις συμβουλές που έχουν απορριφθεί προηγουμένως + Όλες οι συμβουλές θα εμφανιστούν ξανά + Ενσωμάτωση + Εφαρμογή εργασιών + Δεν βρέθηκε συμβατή εφαρμογή εργασιών + + CardDAV + CalDAV + Webcal + Για το συγχρονισμό αυτών των συλλογών απαιτούνται πρόσθετα δικαιώματα. + Διαχείριση δικαιωμάτων + Συγχρονισμός τώρα + Ρυθμίσεις λογαριασμού + Μετονομασία λογαριασμού + Τα μη αποθηκευμένα τοπικά δεδομένα μπορούν να απορριφθούν. Μετά τη μετονομασία απαιτείται εκ νέου συγχρονισμός. + Νέο όνομα λογαριασμού + Μετονομασία + Το όνομα λογαριασμού έχει ήδη ληφθεί + Αδυναμία μετονομασίας λογαριασμού + Διαγραφή λογαριασμού + Θέλετε να διαγράψετε τον λογαριασμό; + Όλα τα τοπικά αντίγραφα των βιβλίων διευθύνσεων, ημερολογίων και λιστών εργασιών θα διαγραφούν. + συγχρονισμός αυτής της συλλογής + μόνο για ανάγνωση + ημερολόγιο + επαφές + ημερολόγιο + εργασίες + Εμφάνιση μόνο προσωπικών + Ανανέωση λίστας + Οι συνδρομές Webcal μπορούν να συγχρονιστούν με εξωτερικές εφαρμογές. + Δεν βρέθηκε εφαρμογή με δυνατότητα Webcal + Εγκατάσταση ICSx⁵ + + Προσθήκη λογαριασμού + Γενική είσοδος + Είσοδος για συγκεκριμένο πάροχο + Συνέχεια + Είσοδος + Είσοδος με την διεύθυνση email + Διεύθυνση email + Απαιτείται έγκυρη διεύθυνση email + Οι υπηρεσίες εντοπίζονται χρησιμοποιώντας εγγραφές DNS και γνωστές διευθύνσεις URL.]]> + Συνθηματικό + Απόκρυψη συνθηματικού + Εμφάνιση συνθηματικού + Είσοδος με την URL και το όνομα χρήστη + Όνομα χρήστη + Βασική URL + οι υπηρεσίες εντοπίζονται επίσης χρησιμοποιώντας εγγραφές DNS και γνωστές διευθύνσεις URL.]]> + Επιλογή πιστοποιητικού + Προσθήκη λογαριασμού + Όνομα λογαριασμού + Η χρήση των αποσιωπητικών (\') φαίνεται να προκαλεί προβλήματα σε ορισμένες συσκευές. + Χρησιμοποιήστε τη διεύθυνση ηλεκτρονικού ταχυδρομείου ως όνομα λογαριασμού, επειδή το Android θα χρησιμοποιεί το όνομα του λογαριασμού ως πεδίο ORGANIZER για τα συμβάντα που δημιουργείτε. Δεν μπορείτε να έχετε δύο λογαριασμούς με το ίδιο όνομα. + Μέθοδος ομάδας επαφών: + Απαιτείται όνομα λογαριασμού + Το όνομα λογαριασμού έχει ήδη ληφθεί + Σύνδεση για προχωρημένους + Πιστοποιητικό πελάτη: %s + Δεν βρέθηκε πιστοποιητικό + Εγκατάσταση πιστοποιητικού + Επαφές / Ημερολόγιο Google + Λογαριασμός Google + Συνδεθείτε με την Google + Αναγνωριστικό πελάτη (προαιρετικό) + πολιτική απορρήτου μας για λεπτομέρειες.]]> + Πολιτική Δεδομένων Χρήστη των Υπηρεσιών API της Google, συμπεριλαμβανομένων των απαιτήσεων περιορισμένης χρήσης.]]> + Αδυναμία λήψης κωδικού εξουσιοδότησης + Nextcloud + Συνδεθείτε με το Nextcloud + Αυτό θα ξεκινήσει τη ροή σύνδεσης στο Nextcloud σε ένα πρόγραμμα περιήγησης στο διαδίκτυο. + Διεύθυνση διακομιστή Nextcloud + Συνδεθείτε + Δεν ήταν δυνατή η λήψη του URL σύνδεσης + Δεν ήταν δυνατή η λήψη δεδομένων σύνδεσης + Ανίχνευση ρυθμίσεων + Περιμένετε, γίνεται ερώτημα στο διακομιστή... + Αδυναμία εύρεσης υπηρεσίας CalDAV ή CardDAV. + Η βασική διεύθυνση URL δεν φαίνεται να είναι προσβάσιμη διεύθυνση URL CalDAV/CardDAV και η ανίχνευση της υπηρεσίας δεν ήταν επιτυχής. + στον κατάλογο των δοκιμασμένων υπηρεσιών και των βασικών διευθύνσεων URL τους.]]> + Παρακαλούμε ελέγξτε επίσης δύο φορές τον έλεγχο ταυτότητας (συνήθως όνομα χρήστη και συνθηματικό). + Περαιτέρω τεχνικές πληροφορίες είναι διαθέσιμες στα αρχεία καταγραφής. + Προβολή ιστορικού + + Συγχρονισμός + Μεσοδιάστημα συγχρονισμού επαφών + Μόνο χειροκίνητα + Κάθε %d λεπτά + αμέσως στις τοπικές αλλαγές + Μεσοδιάστημα συγχρονισμού ημερολογίων + Μεσοδιάστημα συγχρονισμού εργασιών + + Μόνο χειροκίνητα + Κάθε 15 λεπτά + Κάθε 30 λεπτά + Κάθε ώρα + Κάθε 2 ώρες + Κάθε 4 ώρες + Μια φορά την ημέρα + + Συγχρονισμός μόνο μέσω WiFi + Ο συγχρονισμός περιορίζεται στις συνδέσεις WiFi + Ο τύπος σύνδεσης δεν λαμβάνεται υπόψη + Περιορισμός WiFi SSID + Θα γίνεται συγχρονισμός μόνο μέσω %s + Θα χρησιμοποιηθούν όλες οι συνδέσεις WiFi + Ονόματα που χωρίζονται με κόμμα (SSIDs) των επιτρεπόμενων δικτύων WiFi (αφήστε κενό για όλους) + Ο περιορισμός WiFi SSID χρειάζεται επιπλέον ρυθμίσεις + Διαχείριση + Το VPN απαιτεί υποκείμενο διαδίκτυο + Το VPN χωρίς υποκείμενη επικυρωμένη σύνδεση στο διαδίκτυο δεν αρκεί για την εκτέλεση του συγχρονισμού (συνιστάται) + Το VPN χωρίς υποκείμενη επικυρωμένη σύνδεση στο διαδίκτυο είναι αρκετό για την εκτέλεση του συγχρονισμού + Πιστοποίηση + Όνομα χρήστη + Νέο συνθηματικό + Ενημερώστε το συνθηματικό σύμφωνα με τον διακομιστή σας. + Πιστοποιητικό πελάτη + Δεν υπάρχει διαθέσιμο ή επιλεγμένο πιστοποιητικό + Εγκατάσταση πιστοποιητικού + CalDAV + Προθεσμία παρελθόντος συμβάντος + Όλα τα συμβάντα θα συγχρονίσουν + + Τα συμβάντα πέραν μιας ημέρας θα αγνοηθούν + Τα συμβάντα πέραν των %d ημερών θα αγνοηθούν + + Τα συμβάντα που υπερβαίνουν αυτόν τον αριθμό ημερών στο παρελθόν θα αγνοηθούν (μπορεί να είναι 0). Αφήστε κενό για συγχρονισμό όλων των συμβάντων. + Προεπιλεγμένη υπενθύμιση + + Προεπιλεγμένη υπενθύμιση ένα λεπτό πριν το συμβάν + Προεπιλεγμένη υπενθύμιση %dλεπτά πριν το συμβάν. + + Δεν δημιουργούνται προεπιλεγμένες υπενθυμίσεις + Εάν δημιουργούνται προεπιλεγμένες υπενθυμίσεις για συμβάντα χωρίς υπενθύμιση: επιθυμητός αριθμός λεπτών πριν από το συμβάν. Αφήστε κενό για να απενεργοποιήσετε τις προεπιλεγμένες υπενθυμίσεις. + Διαχείριση χρωμάτων ημερολογίου + Τα χρώματα των ημερολογίων επανέρχονται σε κάθε συγχρονισμό + Τα χρώματα ημερολογίων μπορούν να δηλωθούν από άλλες εφαρμογές + Υποστήριξη χρώματος στα συμβάντα + Τα χρώματα συμβάντων έχουν συγχρονιστεί + Τα χρώματα συμβάντων δεν έχουν συγχρονιστεί + CardDAV + Αλλαγή μεθόδου ομάδας + + Οι ομάδες είναι ξεχωριστές vCards + Οι ομάδες είναι κατηγορίες ανά επαφή + + + Δημιουργία βιβλίου διευθύνσεων + Η δημιουργία βιβλίου διευθύνσεων μέσω CardDAV ενδέχεται να μην υποστηρίζεται από το διακομιστή. + Δημιουργία ημερολογίου + + Πιθανές καταχωρήσεις ημερολογίου + Συμβάντα + Εργασίες + Σημειώσεις / ημερολόγιο + Η δημιουργία ημερολογίου μέσω CalDAV ενδέχεται να μην υποστηρίζεται από τον διακομιστή. + Χρώμα + Τίτλος + Τοποθεσία αποθήκευσης + Δημιουργία + + επαφές + εργασίες + Διαγραφή συλλογής + Αυτή η συλλογή (%s) και όλα τα δεδομένα της θα διαγραφούν μόνιμα, τόσο τοπικά όσο και από τον διακομιστή. + Συγχρονισμός + Ενεργοποιημένος συγχρονισμός + Απενεργοποιημένος συγχρονισμός + Μόνο για ανάγνωση + Μόνο για ανάγνωση (από τον διακομιστή) + Μόνο για ανάγνωση (μόνο τοπικά) + Ανάγνωση/εγγραφή + Τίτλος + Περιγραφή + Ιδιοκτήτης + Υποστήριξη Push + Ο διακομιστής διαφημίζει την υποστήριξη Push + Τελευταίος συγχρονισμός (%s) + Διεύθυνση (URL) + + Πληροφορίες αποσφαλμάτωσης + ZIP αρχείο αρχειοθέτησης + Περιέχει πληροφορίες εντοπισμού σφαλμάτων και αρχεία καταγραφής συστήματος + Μοιραστείτε το αρχείο για να το μεταφέρετε σε έναν υπολογιστή, να το στείλετε με email ή να το επισυνάψετε σε ένα εισιτήριο υποστήριξης. + Διαμοίρασε το αρχείο αρχειοθέτησης + Πληροφορίες εντοπισμού σφαλμάτων που επισυνάπτονται σε αυτό το μήνυμα (απαιτείται υποστήριξη συνημμένων από την εφαρμογή λήψης). + Σφάλμα HTTP + Σφάλμα διακομιστή + Σφάλμα WebDAV + Σφάλμα I/O + Προβολή λεπτομερειών + Έχουν συλλεχθεί πληροφορίες εντοπισμού σφαλμάτων + Εμπλεκόμενοι πόροι + Σχετικά με το πρόβλημα + Απομακρυσμένος πόρος: + Τοπικός πόρος: + Ιστορικό + Διατίθενται αναλυτικά αρχεία καταγραφής + Προβολή ιστορικού + Αντιγραφή URL + + Παρουσιάστηκε σφάλμα. + Παρουσιάστηκε σφάλμα HTTP. + Παρουσιάστηκε σφάλμα I/O. + Εμφάνιση λεπτομερειών + + Βάσεις WebDAV + Χώρος αποθήκευσης: %1$s / διαθέσιμα: %2$s + Κοινόχρηστο περιεχόμενο + Αποπροσάρτηση + Προσθήκη βάσης WebDAV + Απευθείας πρόσβαση στα αρχεία σας στο cloud δημιουργώντας μία προσάρτηση WebDAV + Εμφανιζόμενο όνομα + WebDAV URL + Μη έγκυρο URL + Πιστοποίηση + Όνομα χρήστη + Κωδικός πρόσβασης + Προσθήκη βάσης + Δεν υπάρχει υπηρεσία WebDAV σε αυτό το URL + Αφαίρεση σημείου προσάρτησης + Τα στοιχεία της σύνδεσης θα χαθούν, αλλά δεν θα διαγραφούν αρχεία. + Πρόσβαση σε αρχείο WebDAV + Kαταφόρτωση αρχείου WebDAV + Ανέβασμα αρχείου WebDAV + Bάση WebDAV + + Δικαιώματα DAVx⁵ + Απαιτούνται πρόσθετα δικαιώματα + Παλιά έκδοση %s + Ελάχιστη απαιτούμενη έκδοση: %1$s + Ο έλεγχος ταυτότητας απέτυχε (ελέγξτε τα διαπιστευτήρια σύνδεσης) + Σφάλμα δικτύου ή I/O – %s + Σφάλμα διακομιστής HTTP – %s + Σφάλμα τοπικού αποθηκευτικού χώρου – %s + Σφάλμα (φτάσατε στον μέγιστο αριθμό επαναλήψεων) + Ελήφθη μη έγκυρη επαφή από το διακομιστή + Ελήφθη μη έγκυρο συμβάν από το διακομιστή + Έλαβε μη έγκυρη εργασία από το διακομιστή + Αγνόηση ενός ή περισσοτέρων μη έγκυρων πόρων + + Συγχρονισμός όλων + Συγχρονισμός όλων των λογαριασμών + + diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..3ce147b --- /dev/null +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,359 @@ + + + + Account does not exist (anymore) + DAVx⁵ Address book + Remove + Cancel + Enable + This field is required + Help + Share + Database corrupted + All accounts have been removed locally. + Debugging + Other important messages + Low-priority status messages + Synchronisation + Synchronisation errors + Important errors which stop synchronisation like unexpected server replies + Synchronisation warnings + Non-fatal synchronisation problems like certain invalid files + Network and I/O errors + Timeouts, connection problems, etc. (often temporary) + + Your data. Your choice. + Take control. + Regular sync intervals + For synchronisation at regular intervals, %s must be allowed to run in the background. Otherwise, Android may pause synchronisation at any time. + I don\'t need regular sync intervals.* + %s compatibility + I have done the required settings. Don\'t remind me anymore.* + * Leave unchecked to be reminded later. Can be reset in app settings / %s. + More information + jtx Board + + Tasks support + If tasks are supported by your server, they can be synchronised with a supported tasks app: + OpenTasks + Doesn\'t seem to be developed anymore – not recommended. + Tasks.org + No app store available + I don\'t need tasks support.* + Open-source software + We\'re happy that you use %s, which is open-source software. Development, maintenance and support are hard work. Please consider contributing (there are many ways) or a donation. It would be highly appreciated! + How to contribute/donate + + Permissions + %s requires permissions to work properly. + All of the below + Use this to enable all features (recommended) + All permissions granted + Contacts permissions + No contact sync (not recommended) + Contact sync possible + Calendar permissions + No calendar sync (not recommended) + Calendar sync possible + Notification permission + Notifications disabled (not recommended) + Notifications enabled + jtx Board permissions + OpenTasks permissions + Tasks permissions + No task sync + Task sync possible + Keep permissions + Permissions may be reset automatically (not recommended) + Permissions won\'t be reset automatically + Click Permissions > uncheck \"Remove permissions if app isn\'t used\" + If a switch doesn\'t work, use app settings / Permissions. + App settings + + WiFi SSID permissions + To be able to access the current WiFi name (SSID), these conditions must be met: + Precise location permission + Location permission granted + Location permission denied + Background location permission + Allow all the time + Location always enabled + Location service is enabled + Location service is disabled + + Translations + Libraries + Version %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) and contributors + This program comes with ABSOLUTELY NO WARRANTY. It is free software, and you are welcome to redistribute it under certain conditions. + + Couldn\'t create log file + Now logging all %s activities + View/share + Disable + + CalDAV/CardDAV Sync Adapter + About / License + Beta feedback + Please install a Web browser + Settings + News & updates + Tools + External links + Web site + Manual + FAQ + Community + Privacy policy + Sync all accounts + + Notifications disabled. You won\'t be notified about sync errors. + Manage connections + Data saver enabled. Background synchronisation is restricted. + Manage data saver + Storage space low. Android will not sync local changes immediately, but during the next regular sync. + Manage storage + + Service detection failed + Couldn\'t refresh collection list + + Running in foreground + On some devices, this is necessary for automatic synchronisation. + + Settings + Debugging + Show debug info + Verbose logging + Logging is disabled + Battery optimisation + Connection + Proxy type + + System default + No proxy + HTTP + SOCKS (for Orbot) + + Proxy host name + Proxy port + Security + App permissions + Review permissions required for synchronisation + Distrust system certificates + System and user-added CAs won\'t be trusted + System and user-added CAs will be trusted (recommended) + Reset (un)trusted certificates + Resets trust of all custom certificates + All custom certificates have been cleared + User interface + Notification settings + Manage notification channels and their settings + Select theme + + System default + Light + Dark + + Reset hints + Re-enables hints which have been dismissed previously + All hints will be shown again + Integration + Tasks app + No compatible tasks app found + + CardDAV + CalDAV + Webcal + Synchronise now + Account settings + Rename account + Rename + Account name already taken + Couldn\'t rename account + Delete account + Really delete account? + All local copies of address books, calendars and task lists will be deleted. + synchronise this collection + read-only + calendar + journal + Show only personal + No Webcal-capable app found + Install ICSx⁵ + + Add account + Login + Login with email address + Email address + Valid email address required + Password + Login with URL and user name + User name + Base URL + Select certificate + Add account + Account name + Use your email address as account name because Android will use the account name as ORGANISER field for events you create. You can\'t have two accounts with the same name. + Contact group method: + Account name required + Account name already taken + No certificate found + Install certificate + Google Contacts / Calendar + Google account + Sign in with Google + Client ID (optional) + Privacy policy for details.]]> + Google API Services User Data Policy, including the Limited Use requirements.]]> + Couldn\'t obtain authorisation code + Nextcloud + Login with Nextcloud + This will start the Nextcloud Login Flow in a Web browser. + Nextcloud server address + Sign in + Couldn\'t obtain login URL + Couldn\'t obtain login data + Configuration detection + Please wait, querying server… + Couldn\'t find CalDAV or CardDAV service. + View logs + + Synchronisation + Contacts sync. interval + Only manually + Every %d minutes + immediately on local changes + Calendars sync. interval + Tasks sync. interval + + Only manually + Every 15 minutes + Every 30 minutes + Every hour + Every 2 hours + Every 4 hours + Once a day + + Sync over WiFi only + Synchronisation is restricted to WiFi connections + Connection type is not taken into consideration + WiFi SSID restriction + Will only sync over %s + All WiFi connections will be used + Comma-separated names (SSIDs) of allowed WiFi networks (leave blank for all) + WiFi SSID restriction requires further settings + Manage + VPN requires underlying Internet + VPN without underlying validated Internet connection is not enough to run synchronisation (recommended) + VPN without underlying validated Internet connection is enough to run synchronisation + Authentication + User name + Update the password according to your server. + Install certificate + CalDAV + Past event time limit + All events will be synchronised + + Events more than one day in the past will be ignored + Events more than %d days in the past will be ignored + + Events which are more than this number of days in the past will be ignored (may be 0). Leave blank to synchronise all events. + Default reminder + + Default reminder one minute before event + Default reminder %d minutes before event + + No default reminders are created + If default reminders shall be created for events without reminder: the desired number of minutes before the event. Leave blank to disable default reminders. + Manage calendar colours + Calendar colours are reset at each sync + Calendar colours can be set by other apps + Event colour support + Event colours are synced + Event colours are not synced + CardDAV + Contact group method + + Groups are separate vCards + Groups are per-contact categories + + + Create address book + Create calendar + Possible calendar entries + Events + Tasks + Notes / journal + Colour + Title + Storage location + Create + + Delete collection + Synchronisation + Title + Description + + Debug info + ZIP archive + Contains debug info and logs + Share the archive to transfer it to a computer, to send it by email or to attach it to a support ticket. + Share archive + Debug info attached to this message (requires attachment support of the receiving app). + HTTP Error + Server Error + WebDAV Error + I/O Error + View details + Debug info have been collected + Involved resources + Related to the problem + Remote resource: + Local resource: + Logs + Verbose logs are available + View logs + Copy URL + + An error has occurred. + An HTTP error has occurred. + An I/O error has occurred. + Show details + + WebDAV mounts + Quota used: %1$s / available: %2$s + Share content + Unmount + Add WebDAV mount + Directly access your cloud files by adding a WebDAV mount! + Display name + WebDAV URL + Invalid URL + Authentication + User name + Password + Add mount + No WebDAV service at this URL + Remove mount point + Connection details will be lost, but no files will be deleted. + Accessing WebDAV file + Downloading WebDAV file + Uploading WebDAV file + WebDAV mount + + DAVx⁵ permissions + Additional permissions required + %s too old + Minimum required version: %1$s + Authentication failed (check login credentials) + Network or I/O error – %s + HTTP server error – %s + Local storage error – %s + Soft error (max retries reached) + Received invalid contact from server + Received invalid event from server + Received invalid task from server + Ignoring one or more invalid resources + + Sync all accounts + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..051b2d7 --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,418 @@ + + + + La cuenta (ya) no existe + Agenda DAVx⁵ + Eliminar + Eliminar + Cancelar + Activar + Este campo es requerido + Ayuda + Navegar hacia arriba + Menú de opciones + Compartir + Sincronización iniciada/programada + Base de datos corrompida + Toda las cuentas han sido eliminadas localmente. + Depuración + Otros mensajes importantes + Mensajes de baja prioridad + Sincronización + Errores de sincronización + Errores importantes que detienen la sincronización como respuestas inesperadas del servidor + Advertencias de sincronización + Problemas de sincronización no-fatales como ciertos archivos inválidos + Errores de Red y E/S + Timeouts, problemas de conección, etc. (muchas veces temporal) + + Sus datos. Su elección. + Tome el control. + Sincronización a intervalos regulares + Para sincronizar a intervalos regulares, se tiene que permitir a %sejecutarse como tarea de fondo. En caso contrario, Android podría pausar la sincronización en cualquier instante. + No necesito la sincronización a intervalos regulares.* + Compatibilidad %s + No tengo los ajustes requeridos. No volver a recordar.* + * Déjelo desmarcado para que se le recuerde más tarde. Se puede reconfigurar en los ajustes de la aplicación / %s + Información adicional + Tablero jtx + + Soporte de tareas + Si las tareas son compatibles con tu servidor, pueden sincronizarse con una aplicación de tareas compatible: + OpenTasks + Al parecer ya no tiene soporte – no se recomienda. + Tasks.org + Ninguna tienda de aplicaciones disponible + No necesito soporte para tareas.* + Software open-source + Nos complace que use%s, que es software open-source. Desarrollar, mantener y asistir a usuarios es un trabajo duro. Por favor, considere contribuir (hay muchas maneras) o hacer una donación. ¡Se agradecería mucho! + Cómo contribuir o donar + Siguiente + + Permisos + %s necesita permisos para funcionar correctamente. + Todos los siguientes + Usa esto para activar todas las características (recomendado) + Todos los permisos concedidos + Permisos de contactos + No sincronizar contactos (no recomendado) + Sincronización de contactos permitida + Permisos de calendario + Sin sincronización de calendario (no recomendado) + Sincronización de calendario permitida + Permiso de notificaciones + Notificaciones desactivadas (no recomendado) + Notificaciones habilitadas + Permisos de tablero jtx + Permisos de OpenTasks + Permisos de las tareas + Sin sincronización de tareas + Sincronización de tareas permitidas + Mantener permisos + Los permisos pueden restablecerse automáticamente (no recomendado) + Los permisos no se restablecerán automáticamente + Haga clic en Permisos > desmaque \"Quitar permisos si la aplicación no se utiliza\" + Si un interruptor no funciona usa Configuraciones de la app / Permisos. + Configuraciones de la app + + Permisos del SSID WiFi + Para poder acceder al nombre del WiFi actual (SSID), deben cumplirse estas condiciones: + Permiso de ubicación precisa + Permiso de ubicación concedido + Permiso de ubicación denegado + Permiso de ubicación en segundo plano + Permitir todo el tiemp + Acceso a la ubicación concedido para: %s + Acceso a la ubicación no concedido para: %s + Ubicación siempre activada + El servicio de ubicación está activado + El servicio de ubicación está desactivado + + Traducciones + Bibliotecas + Versión %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) y colaboradores + Este programa viene sin NINGÚN TIPO DE GARANTÍA. Es software libre, y cualquier contribución es bienvenida y redistribuida bajo ciertas condiciones. + + No se puede crear el fichero del registro. + Ahora se registran todas las activdades %s + Ver/compartir + Desactivar + + Adaptador de sincronización CalDAV/CardDAV + Acerca de / Licencia + Retroalimentación Beta + Por favor, instale un navegador web + Ajustes + Noticias y actualizaciones + Herramientas + Enlaces externos + Sitio web + Manual + Preguntas frequentes + Comunidad + Da soporte al proyecto + Cómo contribuir o donar + Reglamento de privacidad + Sincronizar todas las cuentas + + Notificaciones deshabilitadas. No serás notificado de errores de sincronización. + Administrar conexiones + Ahorro de datos habilitado. Sincronización en segundo plano restringida. + Administrar ahorro de datos + Ahorro de batería habilitado. La sincronización puede estar restringida. + Administrar ahorro de batería + Poco espacio de almacenamiento disponible. Android no sincronizará los cambios hechos localmente de manera inmediata, pero sí lo hará en la siguiente sincronización programada. + Administrar almacenamiento + + Falló la detección del servicio + No se pudo refrescar lista de colección + + Funcionando en primer plano. + En algunos dispositivos, esto es necesario para la sincronización automática. + + Ajustes + Depuración + Mostrar la información de depuración + Registro extendido + El registro está deshabilitado + Optimización de batería + La app está exenta (recomendado) + Se aplican restricciones de batería (no se recomienda) + Conexión + Tipo de proxy + + Por defecto del sistema + Sin proxy + HTTP + SOCKS (para Orbot) + + Nombre de proxy anfitrión + Puerto proxy + Seguridad + Permisos de la aplicación + Revisar los permisos necesarios para la sincronización + Invalidar los certificados del sistema + Los CA del sistema y los añadidos por el usuario no serán válidos + Los CA del sistema y los añadidos por el usuario serán usados y de confianza (recomendado) + Reiniciar certificados (in)validados + Reinicia la validez de todos los certificados particulares + Todos los certificados particulares han sido limpiados + Interfaz de usuario + Ajustes de notificación + Administrar notificación de canales y sus ajustes + Seleccionar tema + + Por defecto del sistema + Claro + Oscuro + + Restablecer advertencias + Habilita las advertencias que han sido rechazadas con anterioridad + Todas las advertencias se mostrarán nuevamente + Integración + Aplicación de tareas + No se han encontrado aplicaciones de tareas compatibles + + CardDAV + CalDAV + Webcal + Se requieren permisos adicionales para sincronizar estas colecciones. + Administrar permisos + Sincronizar ahora + Ajustes de cuenta + Renombrar cuenta + Los datos locales no guardados pueden ser perdidos. Es necesario volver a sincronizar después de renombrar. + Nuevo nombre de cuenta + Renombrar + El nombre de la cuenta ya está siendo utilizado + No se puede renombrar la cuenta + Eliminar cuenta + ¿Seguro que deseas eliminar la cuenta? + Todas las copias locales de tus contactos, calendarios y tareas serán eliminadas. + sincronizar ésta colección + solo lectura + calendario + contactos + diario + tareas + Mostrar solo personal + Recargar lista + Las subscripciones webcal pueden ser sincronizadas con apps externas. + No se encontró aplicación para administrar Webcal + Instalar ICSx⁵ + + Añadir cuenta + Inicio de sesión genérico + Inicio de sesión de proveedor + Continuar + Registrar + Acceder con cuenta de correo + Dirección de correo + Se requiere una dirección de correo válida + Los servicios son detectados usando registros DNS y URLs well-known.]]> + Contraseña + Esconder contraseña + Mostrar contraseña + Acceder con URL y nombre de usuario + Nombre de usuario + URL base + los servicios también son detectados usando registros DNS y URLs well-known.]]> + Seleccionar un certificado + Añadir cuenta + Nombre de cuenta + El uso de comillas (\') puede causar problemas en algunos dispositivos. + Usa tu dirección de correo como nombre de cuenta puesto que Android usará el nombre de la cuenta como campo de \"organizador\" en los eventos que cree. No puedes tener dos cuentas con el mismo nombre. + Método de contacto de grupo: + Nombre de cuenta requerido + El nombre de la cuenta ya está siendo utilizado + Inicio de sesión avanzado + Certificado de cliente: %s + No se ha encontrado ningún certificado + Instalar certificado + Google Contacts / Calendar + Cuenta de Google + Iniciar Sesión con Google + ID de cliente (opcional) + Política de privacidad para más información.]]> + Política de Datos de Usuario de los Servicios de las API de Google, incluyendo los requisitos de Uso Limitado.]]> + No se ha podido obtener el código de autorización + Nextcloud + Iniciar sesión con Nextcloud + Esto iniciará el Login Flow de Nextcloud en un navegador web. + Dirección del servidor Nextcloud + Iniciar sesión + No se ha podido obtener la URL de inicio de sesión + No se han podido obtener los datos de inicio de sesión + Detectar configuración + Por favor espera, consultando al servidor… + No se pudo encontrar el servicio CalDAV o CardDAV. + La URL base no parece ser una URL de CalDAV/CardDAV accesible, por lo que la detección de servicios no fue fructuosa. + lista de servicios probados y sus URL base.]]> + Por favor, también comprueba la autenticación (normalmente nombre de usuario y contraseña). + Hay más información técnica disponible en los registros. + Ver registros + + Sincronización + Intervalo de sincronización de contactos + Solo manualmente + Cada %d minutos + inmediatamente con cambios locales + Intervalo de sincronización de calendarios + Intervalo de sincronizacion de Tasks + + Solo manualmente + Cada 15 minutos + Cada 30 minutos + Cada hora + Cada 2 horas + Cada 4 horas + Una vez al dia + + Sincronizar sólo sobre WiFi + La sincronización está restringida a conexiones WiFi + Tipo de conexión no tenido en cuenta + Restricción WiFi SSID + Solo se sincronizará a través de %s + Todas las conexiones WiFi serán usadas + Nombres separados por comas (SSIDs) de redes WiFi permitidas (deje vacío para todas) + La restricción del SSID WiFi requiere más ajustes + Administrar + VPN requiere una conexión a Internet. + VPN sin una conexión a Internet válida no es suficiente para ejecutar la sincronización (recomendado) + VPN sin una conexión a Internet válida es suficiente para ejecutar la sincronización. + Autenticación + Nombre de usuario + Nueva contraseña + Actualiza la contraseña de acuerdo a tu servidor. + Certificado de cliente + No hay ninguna certificado disponible o seleccionado + Instalar certificado + CalDAV + Límite de tiempo de eventos pasados + Todos los eventos serán sincronizados + + Los eventos anteriores a un día serán ignorados + Los eventos anteriores a %d días serán ignorados + Los eventos anteriores a %d días serán ignorados + + Los eventos anteriores a este número de días serán ignorados (puede ser 0). Deja en blanco el campo para sincronizar todos los eventos. + Recordatorio por defecto + + Recordatorio por defecto un minuto antes del evento + Recordatorio por defecto %dminutos antes del evento + Recordatorio por defecto %dminutos antes del evento + + No se han creado recordatorios por defecto + Si se crearán recordatorios por defecto para los eventos que no los tengan: el número de minutos antes del evento. Déjelo en blanco para deshabilitar los recordatorios por defecto. + Colores de calendario + Los colores del calendario se restablecen en cada sincronización + Los colores del calendario pueden ser establecidos por otras aplicaciones + Soporte de colores en eventos + Los colores de los eventos están sincronizados + Los colores de los eventos no están sincronizados + CardDAV + Método de contacto de grupo + + Los grupos son vCards separadas + Los grupos son categorías de cada contacto + + + Crear nueva agenda + La creación de agendas de contactos sobre CardDAV puede no ser soportada por el servidor. + Crear calendario + + Posibles entradas de calendario + Eventos + Tareas + Notas / jornal + La creación de calendarios sobre CalDAV puede no ser soportada por el servidor. + Color + Título + Ubicación del almacenamiento + Descripción (opcional) + Crear + + contactos + tareas + Eliminar colección + La colección (%s) y todos sus datos serán eliminadas permanentemente, tanto localmente como del servidor. + Sincronización + Sincronización habilitada + Sincronización deshabilitada + Solo lectura + Solo lectura (desde el servidor) + Solo lectura (localmente) + Lectura/escritura + Título + Descripción + Propietario + Soporte de Push + El servidor tiene soporte para Push + Última sincronización (%s) + Dirección (URL) + + Información de depuración + Archivo ZIP + Contiene información de depuración y registro + Comparte el archivo para transferirlo al ordenador, para enviarlo por email o para adjuntarlo a un ticket de soporte. + Compartir archivo + Información de depuración adjunta a este mensaje (requiere soporte de adjuntos de la aplicación receptora). + Error HTTP + Error del servidor + Error de WebDAV + Error de E/S + Ver detalles + Se ha recogido la información de depuración + Recursos implicados + Relacionado con el problema + Recurso remoto: + Recurso local: + Registros + Los registros verbosos están disponibles + Ver registros + Copiar URL + + Ocurrió un error. + Ha ocurrido un error HTTP. + Ha ocurrido un error I/O. + Mostrar detalles + + Montajes WebDAV + Cuota usada: %1$s/ disponible: %2$s + Compartir contenido + Desmontar + Agregar montaje WebDAV + Accede directamente a tus archivos en la nube agregando un punto de montaje WebDAV + Mostrar nombre + URL WebDAV + URL inválida + Autenticación + Nombre de usuario + Contraseña + Agregar montaje + No hay un servicio WebDAV en esta URL + Eliminar punto de montaje + Los detalles de la conexión se perderán pero ningún archivo será eliminado. + Accediendo al fichero WebDAV + Descargando el fichero WebDAV + Subiendo fichero WebDAV + Montaje WebDAV + + Permisos de DAVx⁵ + Permisos adicionales requeridos + %s muy antiguo + Mínima versión requerida: %1$s + Falló la autenticación (revise credenciales de inicio de sesión) + Error de red o E/S – %s + Error de servidor – %s + Error de almacenamiento local – %s + Error no crítico (se ha llegado al número máximo de intentos) + Contacto inválido recibido del servidor + Evento inválido recibido del servidor + Tarea inválida recibidas del servidor + Ignorando uno o más recursos inválidos + + Sincronizar todos + Sincronizar todas las cuentas + + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..6328e92 --- /dev/null +++ b/app/src/main/res/values-et/strings.xml @@ -0,0 +1,480 @@ + + + + Kasutajakontot ei leidu (enam) + DAVx⁵ aadressiraamat + Palun ära muuda kasutajakontot siin! Selle asemel pruugi kasutajakontode halduseks otseselt rakendust. + Kustuta + Eemalda + Katkesta + Võta kasutusele + See väli on kohustuslik + Abiteave + Liigu üles + Valikute menüü + Jaga + Sünkroniseerimine algas või on tööde järjekorras + Andmebaas on vigane + Kõik kasutajakontod on kohalikust seadmest eemaldatud + Silumine ja veaotsing + Muud olulised sõnumid + Väheolulised olekuteated + Sünkroniseerimine + Sünkroniseerimisvead + Olulised vead, mis peatavad sünkroniseerimise, nagu näiteks ootamatud päringuvastused serverist + Sünkroniseerimishoiatused + Vähetõsised sünkroniseerimisteated näiteks vigaste failide kohta + Võrgu- ja sisend/väljundvead + Ühenduste aegumine ja muud sarnased probleemid (tihti ajutised) + + Sinu andmed. Sinu valik. + Sina otsustad. + Regulaarne sünkroniseerimisvälp + Selleks, et sünkroniseerimine soovitud ajavahemike järel toimiks taustateenusena, vajab %s õigust töötada taustal. Vastasel juhul võib Android igal ajal sünkroniseerimise peatada. + Ma ei soovi kasutada regulaarset sünkroniseerimisvälpa. * + %s ühilduvus + Nutiseadme tootja poolt lisatud püsivara võib blokeerida sünkroniseerimist. Kui see sinu tegevust mõjutab, siis saad olukorra lahendada käsitsi. + Ma juba kasutan nõutavaid seadistusi. Ära enam tuleta seda mulle meelde.* + * Kui soovid hilisemat meeldetuletust, jäta see märkimata. Lisaks saad seada muuta rakenduse seadistustest / %s. + Lisateave + jtx Board + + Ülesannete tugi + Kui sinu kasutatav server toetab ülesannete haldust, siis nende sünkroniseerimine on võimalik toetatud ülesannete rakendusega: + OpenTasks + Tundub, et arendus on lõppenud ja seega pole kasutamine enam mõistlik. + Tasks.org + pole toetatud.]]> + Rakendustepoodi pole saadaval + Ma ei vaja ülesannete tuge.* + Avatud lähtekoodiga tarkvara + Me oleme rõõmsad, et kasutad avatud lähtekoodil põhinevat rakendust %s. Selle arendus, hooldus ja kasutajatugi nõuavad märgatavat tööd. Palun kaalu erinevaid võimalusi osalemiseks või rahalist toetamist. Me hindaksime seda väga! + Võimalused kaastööks või rahaliseks toetamiseks + Ära näita seda uuesti + + %d kuu jooksul + %d kuu jooksul + + Järgmine + + Õigused + %s vajab korralikuks toimimiseks õigusi. + Kõik alljärgnev + Kasuta seda valikut kõikide funktsionaalsuste sisselülitamiseks (soovitatav) + Rakenduse õigused on olemas + Kontaktide õigused + Kontaktide sünkroniseerimine puudub (pole soovitatud) + Kontaktide sünkroniseerimine on võimalik + Kalendri õigused + Kalendri sünkroniseerimine puudub (pole soovitatud) + Kalendri sünkroniseerimine on võimalik + Teavituste õigused + Teavitused pole kasutusel (pole soovitatav) + Teavitused on kasutusel + Õigused - jtx Board + Õigused - OpenTasks + Ülesannete õigused + Ülesannete sünkroniseerimine puudub + Ülesannete sünkroniseerimine on võimalik + Säilita õigused + Õigusi võib muuta automaatselt (pole soovitatud) + Õigused ei saa olema automaatselt muudetud + Klõpsi Õigused ja eemalda valik „Eemalda load, kui rakendust ei kasutata“ + Kui muutmine ei toimi, siis kasuta rakenduse õiguste seadistusi. + Rakenduse seadistused + + WiFi SSID õigused + Selleks, et toimiks ligipääs hetkel kasutatavale WiFi võrgunimele (SSID), peavad olema täidetud järgnevad tingimused: + Õigused täpse asukoha tuvastamiseks + Õigused asukoha tuvastamiseks on olemas + Õigused asukoha tuvastamiseks on keelatud + Õigused asukoha tuvastamiseks taustal + Luba alati + Asukohaõigused on: %s + Asukohaõiguseid pole: %s + %s kasutab asukohaandmeid (vaid WiFi SSID võrgutunnust) vaid sünkroniseerimise tagamiseks konkreetse WiFi-võrgu piires. See kehtib ka siis, kui sünkroniseerimine on seadistatud töötama taustal. + Kõik asukohaandmed (vaid WiFi SSId võrgutunnus) on kasutusel kohalikus nutiseadmes ega saadeta mitte kuhugile mujale. + Asukohateenus on alati kasutusel + Asukohateenus on lubatud + Asukohateenus pole lubatud + + Tõlked + Teegid + Versioon %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) ja kaasautorid + Selle rakenduse kasutamisega EI KAASNE MITTE ÜHTEGI GARANTIID. Tegemist on vaba ja avatud tarkvaraga ning sa võid seda levitada kindlate tingimuste alusel. + + Logifaili loomine ei õnnestunud + Nüüd logime kõiki %s rakenduse tegevusi + Vaata/jaga + Lülita välja + + CalDAV/CardDAV sünkroniseerimise sobitaja + Teave / litsents + Beetaversiooni tagasiside + Palun paigalda veebibrauser + Seadistused + Uudised ja uuendused + Tarvikud + Välised lingid + Veebisait + Käsiraamat + KKK + Organisatsioonide jaoks + Kogukond + Toeta projekti + Osalemise viisid + Privaatsusreeglid + Tere tulemast kasutama rakendust DAVx⁵! + Loo ühendus oma serveriga ja hoia kalendrid ning kontaktid sünkroniseerituna. + Sünkroniseeri kõik kasutajakontod + + Teavitused on välja lülitatud ja seega sünkroniseerimisvigade infot sa ei näe. + Automaatne sünkroniseerimine pole aktiivne (kontrollitud internetiühendus puudub) + Halda ühendusi + Andmemahu piiraja on kasutusel. Taustal sünkroniseerimine võib toimida piirangutega. + Halda andmemahu piirajat + Akukasutuse piiraja on kasutusel. Taustal sünkroniseerimine võib toimida piirangutega. + Halda akukasutuse piirajat + Vaba andmeruumi napib. Android ei sünkroniseeri kohalikke muudatusi kohe, vaid järgmise regulaarse sünkroniseerimise ajal. + Halda andmeruumi + Kalendri teenusepakkuja puudub. + Kas sa oled lülitanud välja süsteemse kalendri salvestusruumi rakenduse „Calendar storage“ välja? + Kontaktide teenusepakkuja puudub. + Kas sa oled lülitanud välja süsteemse kontaktide salvestusruumi rakenduse „Contacts storage“ välja? + Halda rakendusi + + Teenuse tuvastamine ei õnnestunud + Kogumike loendi uuendamine ei õnnestunud + + Töötame esiplaanil + See eelistus on vajalik sünkroniseerimiseks mõnedes seadmetes. + + Seadistused + Silumine ja veaotsing + Näita silumisteavet + Vaata/jaga seadistuse üksikasju ja logisid + Väga üksikasjalik logimine + Logimine on kasutusel. Silumisteabe osana saad vaadata logisid. + Logimine pole kasutusel + Akukasutuse optimeerimine + See rakendus ei allu akukasutuse optimeerimisele (soovitatav valik) + Akukasutuse optimeerimise piirangud on kasutusel (mittesoovitatav valik) + Ühendus + Proksiserveri tüüp + + Süsteemi proksiserver + Proksiserver puudub + HTTP + SOCKS (Orboti jaoks) + + Proksiserveri hostinimi + Proksiserveri port + Turvalisus + Rakenduse õigused + Täpsusta sünkroniseerimiseks vajalike õigusi + Ära usalda nutiseadme süsteemseid sertifikaate + Süsteemsed ja kasutaja lisatud sertifitseerimiskeskused ei ole usaldatud + Süsteemsed ja kasutaja lisatud sertifitseerimiskeskused on usaldatud (soovitatav valik) + Kui see seadistus on aktiivne, siis operatsioonisüsteemis leiduvad sertifikaate ei loeta usaldusväärseteks. See tähendab, et iga kord pead sertifikaadiga käsitsi nõustuma (seda ka siis, kui server uuendab oma sertifikaate), vastasel juhul kasutajakonto seadistamine ja sünkroniseerimine ei toimi. + Lähtesta (mitte)usaldatud sertifikaatide loend + Selle valikuga eemaldatakse kõik sinu lisatud sertifikaatide usaldusmärked + Kõik sinu lisatud sertifikaatide usaldusmärked on eemaldatud + Kasutajaliides + Teavituste seadistused + Halda teavituskanaleid ja nende seadistusi + Vali kujundus + + Süsteemi kujundus + Hele kujundus + Tume kujundus + + Lähtesta vihjed + Lülitab varem väljalülitatud vihtjete kuvamise uuesti sisse + Näitame jälle kõiki vihjeid + Lõimimine + Ülesannete rakendus + Ühilduvat ülesannete rakendust ei leidu + UnifiedPush (katseline) + Puudub (tõuketeenuseid pole) + Vali levitaja + Ühtegi tõukesõnumite levitajat pole paigaldatud + Otspunkt on seadistamata + Valmis tõuketeadete vastuvõtmiseks %s vahendusel + FCM (Google Play) + Tõuketeavituste sõnumid on alati krüptitud. + + Kasutajakonto on eemaldatud + CardDAV + CalDAV + Webcal + Nende kogumike sünkroniseerimiseks on vajalikud täiendavad õigused. + Halda õigusi + Sünkroniseeri nüüd + Kasutajakonto seadistused + Muuda kasutajakonto nime + Salvestamata kohalik teave võib vahele jääda. Peale nime muutmist palun sünkroniseeri uuesti. + Kasutajakonto uus nimi + Muuda nime + Selline nimi on juba kasutusel + Kasutajakonto nime muutmine ei õnnestunud + Kustuta kasutajakonto + Kas tõesti kustutame kasutajakonto? + Sellega kustutame ka kõik aadresside, kalendrite ja ülesannete kohalikud koopiad. + sünkroniseeri see kogumik + ainult lugemisõigus + kalender + kontaktid + päevik + ülesanded + Näita vaid isiklikke + Uuenda loendit + Webcali tellimusi on võimalik sünkroniseerida väliste rakendustega. + Webcaliga ühilduvaid rakendusi ei leidu + Paigalda ICSx⁵ + + Lisa kasutajakonto + meie Privaatsusreeglitest.]]> + Üldine sisselogimine + Teenusepakkujakohane sisselogimine + Jätka + Logi sisse + Logi sisse e-posti aadressiga + E-posti aadress + Nõutav on korrektne e-posti aadress + Teenused tuvastame nimeserveri kirjete ning „.well-known“ tunnusaadresside abil.]]> + Salasõna + Peida salasõna + Näita salasõna + Salasõna (kui on vaja) + Logi sisse võrguaadressi ja kasutajanimega + Kasutajanimi + Kasutajanimi (kui on vaja) + Alustuseks mõeldud võrguaadress + tuvastame teenuseid nimeserveri kirjete ning „.well-known“ tunnusaadresside abil.]]> + Vali sertifikaat + Lisa kasutajakonto + Kasutajakonto nimi + Ülakomade (\') kasutamine tundub mõnedes seadmetes tekitama probleeme. + Kuna Android pruugib kasutajakonto nime sinu loodavate ürituste Korraldaja ehk ORGANIZER välja väärtustamiseks, siis soovitame, et sinu kasutajakonto nimi on sinu e-posti aadress. Palun arvesta, et sul ei saa olla kahte samanimelist kasutajakontot. + Kontaktgrupi meetod: + Kasutajakonto nimi on nõutav + Selline nimi on juba kasutusel + Kasutajakonto lisamine ei õnnestunud + Lõpeta + Täiendavad sisselogimise seadistused + Kliendisertifikaat puudub (kui on vaja) + Kliendi sertifikaat: %s + Kliendisertifikaati ei leidunud + Paigalda sertifikaat + Fastmail + Fastmaili kasutajakonto + Logi sisse Fastmaili kasutajakontoga + Google\'i Kontaktid / Kalender + Google\'i kasutajakonto + Logi sisse Google\'i kasutajakontoga + Klienditunnus (kui soovid lisada) + Privaatsusreeglitest.]]> + Google\'i API teenuste kasutajaandmete poliitikat, sealhulgas piiratud kasutuse nõudeid.]]> + Autoriseerimiskoodi saamine polnud võimalik + Nextcloud + Logi sisse Nextcloudi kontoga + Selle eelistusega käivitad Nextcloudi sisselogimise veebibrauseris. + Nextcloudi serveri aadress + Logi sisse + Sisselogimise võrguaadressi tuvastamine polnud võimalik + Sisselogimisandmete tuvastamine polnud võimalik + Seadistuste tuvastamine + Palun oota, pärime andmeid serverist… + Ei õnnestunud leida CalDAV või CardDAV teenust. + Antud võrguaadress ei tundu olema ligipääsetav CalDAVi/CardDAVi võrguaadress ja teenuse tuvastamine ei õnnestunud. + meie poolt testitud teenuste loendist koos toimivate võrguaadressidega.]]> + Palun samuti topeltkontrolli autentimist (tavaliselt kasutajanimi ja salasõna) + Täiendav tehniline teade leidub logides. + Vaata logisid + + Sünkroniseerimine + Kontaktide sünkroniseerimise välp + Vaid käsitsi + Iga %d minuti järel + kohalikud muudatused koheselt + Kalendrite sünkroniseerimise välp + Ülesannete sünkroniseerimise välp + + Vaid käsitsi + Iga 15 minuti järel + Iga 30 minuti järel + Kord tunnis + Iga 2 tunni järel + Iga 4 tunni järel + Kord päevas + + Sünkroniseeri vaid WiFi ühendusega + Sünkroniseerimine on lubatud vaid WiFi ühendusega + Ühenduse liik pole oluline + WiFi SSID piirangud + Sünkroniseeri vaid %s võrgus + Kasuta kõiki WiFi ühendusi + Lubatud WiFi võrgunimede (SSID) komadega eraldatud loend (kui jätad tühjaks on kõik lubatud) + WiFi SSID piirang vajab täiendavat saedistamist + Halda + VPNi kasutamine eeldab, et võrguühendus toimib + VPN ilma toimiva ja kontrollitud internetiühenduseta pole piisav sünkroniseerimiseks (soovitatud) + VPN ilma toimiva ja kontrollitud internetiühenduseta on sünkroniseerimiseks piisav + Autentimine + Kasutajanimi + Salasõna või rakenduse salasõna + Rakenduse salasõna kasutamine peaks olema esimene eelistus.]]> + Uus salasõna + Uuenda salasõna vastavalt oma serveri juhendile. + Autoriseeri uuesti (OAuth) + Kasuta olukorras, kus ligipääs on tühistatud + Autoriseerimine õnnestus + Kliendi sertifikaat + Sertifikaati pole saadaval või paigaldatud + Paigalda sertifikaat + CalDAV + Möödunud sündmuste ajapiir + Kõik sündmused kuuluvad sünkroniseerimisele + + Eira enam kui üks päev vanu sündmuseid + Eira enam kui %d päeva vanu sündmuseid + + Sündmused, mis on vanemad, kui siin märgitud päevade arv, jäävad sünkroniseerimata (võib olla ka 0). Kõikide sündmuste sünkroniseerimiseks jäta tühjaks. + Vaikimisi meeldetuletus + + Vaikimisi meeldetuletus üks minutit enne sündmust + Vaikimisi meeldetuletus %d minutit enne sündmust + + Vaikimisi meeldetuletused puuduvad + Eelistus määrab, kas kasutame vaikimisi meeldetuletust sündmuste puhul, kus eraldi meeldetuletus on seadistamata. Aktiveerimiseks sisesta vaikimisi meeldetuletuse aeg minutites. Väljalülitamiseks jäta tühjaks. + Halda kalendrivärve + Kalendri värvid lähtestatakse igal sünkroniseerimisel + Muud rakendused võivad kalendrivärve seadistada + Sündmuste värvide tugi + Sündmuste värvid kuuluvad sünkroniseerimisele + Sündmuste värvid ei kuulu sünkroniseerimisele + CardDAV + Kontaktgrupi meetod + + Grupid on eraldi vCard-kirjed + Grupid on kontaktikohased kategooriad + + + Loo aadressiraamat + See server ei pruugi toetada aadressiraamatu loomist CardDAVi ühenduse abil. + Loo kalender + Vaikimisi ajavöönd (kui on vaja) + + Võimalikud kalendrikirjed + Sündmused + Ülesanded + Märkmed / päevik + See server ei pruugi toetada kalendri loomist CalDAVi ühenduse abil. + Värv + Pealkiri + Andmeruumi asukoht + Kirjeldus (kui on vaja) + Loo + + kontaktid + sündmust + ülesanded + Kustuta kogumik + See kogumik (%s) koos oma kõikide andmetega kustutatakse nüüd jäädavalt nii serverist, kui kohalikust nutiseadmest. + Sünkroniseerimine + Sünkroniseerimine on kasutusel + Sünkroniseerimine pole kasutusel + Ainult lugemisõigus + Ainult lugemisõigus (serveri poolt) + Ainult lugemisõigus (reeglite alusel) + Ainult lugemisõigus (ainult kohalikus nutiseadmes) + Lugemis- ja kirjutamisõigus + Pealkiri + Kirjeldus + Omanik + Tõuketeenuse tugi + Server teavitab tõuketeenuse toe olemasolust + Tellitud %1$s, aegub %2$s + Viimane sünkroniseerimine (%s) + Aadress (võrguaadress) + + Silumisteave + ZIP-arhiivifail + Sisaldab silumisteavet ja logisid + Tõsta arhiiv uurimiseks arvutisse, saada huvilisele e-postiga või lisa veateatele meie veahalduses. + Jaga arhiivi + Sõnumile lisatud silumisteave (eeldab, et vastuvõttev rakendus oskab manuseid käsitleda). + HTTP-viga + Serveri viga + WebDAVi viga + Sisend-/väljundviga + Server keeldus päringule vastamast. + Päritud andmeressurssi ei leidu (enam). + Server ei võimalda antud päringu tüüpi kasutada või soovitud tegevust teha. + Tekkis serveripoolne viga. Palun võta ühendust serveri haldajaga. + Tekkis ootamatu viga. Lisainfot leiad silumisteabest. + Vaata üksikasju + Silumisteave on kogutud + Seotud teenused ja tarvikud + Probleemi või veaga seotud teave + Serveris asuvad teenused ja tarvikud: + Kohalikus nutiseadmes teenused ja tarvikud: + Logid + Saadaval on üksikasjalikud logid + Vaata logisid + Privaatsusteade + Logid ja veaotsingu teave võivad sisaldada privaatset teavet. Nende andmete avalikul jagamisel palun arvesta sellega. + + Tekkis viga. + Tekkis http-viga. + Tekkis sisend-väljundviga. + Näita üksikasju + + WebDAVi haakepunktid + Kasutatud mahukvoot: %1$s / saadaval: %2$s + Jaga sisu + Eemalda haakimine + Lisa WebDAVi haakepunkt + Otseligipääs sinu failidele WebDAVi haakepunktist! + kuidas WebDAVi haakepunktid toimivad.]]> + Kuvatav nimi + WebDAVi võrguaadress + Vigane võrguaadress + Haakepunkt ja kuvatav nimi + Autentimine + Kasutajanimi + Salasõna + Kasutajanimi (kui on vaja) + Salasõna (kui on vaja) + Lisa haakepunkt + Sellel võrguaadressil ei leidu WebDAVi teenust + Eemalda haakepunkt + Ühenduse andmed lähevad kaotsi, aga ühtegi faili ei kustutata. + Ligipääs WebDAVi failile + Laadime WebDAVi faili alla + Laadime WebDAVi faili üles + WebDAVi haakepunkt + + DAVx⁵ õigused + Vajalikud on täiendavad õigused + %s on liiga vana + Väikseim nõutav versioon: %1$s + Autentimine ei õnnestunud (kontrolli, et kasutajanimi/salasõna oleksid õiged) + Võrgu- või sisend/väljundviga – %s + HTTP serveri viga – %s + Kohaliku salvestusruumi viga – %s + Pehme viga (korduspäringute arvu ülempiir on käes) + Saime serverist vigase kontaktikirje + Saime serverist vigase sündmusekirje + Saime serverist vigase ülesandekirje + Eirame ühte või enamat teenust või tarvikut + Sünkroniseerimine on ootel + Serveris olevad andmed on muutunud + + Sünkroniseeri kõik + Sünkroniseeri kõik kasutajakontod + Sildiga sünkroniseerimisnupp + Ikooniga sünkroniseerimisnupp + Klõpsi sünkroniseerimise käsitsi käivitamiseks. + + diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..542f519 --- /dev/null +++ b/app/src/main/res/values-eu/strings.xml @@ -0,0 +1,477 @@ + + + + Kontua ez da existitzen (dagoeneko) + DAVx⁵ Helbide-liburua + Ez aldatu kontua hemen! Erabili aplikazioa zuzenean kontuak kudeatzeko. + Ezabatu + Kendu + Utzi + Gaitu + Eremu hau beharrezkoa da + Laguntza + Nabigatu gora + Aukeren menua + Partekatu + Sinkronizazioa hasi/ilaran jarri da + Datu-basea hondatua + Kontu lokal guztiak ezabatu dira. + Arazten + Beste mezu garrantzitsu batzuk + Prioritate baxuko egoera mezuak + Sinkronizazioa + Sinkronizazio erroreak + Sinkronizazioa gelditzen duten errore garrantzitsuak, esaterako ustekabeko zerbitzariaren erantzunak + Sinkronizazio abisuak + Fitxategi baliogabe moduko sinkronizazio arazo ez-kritikoak + Sare eta S/I erroreak + Denbora-mugak, konexio arazoak, etab. (normalean behin-behinekoak) + + Zure datuak. Zure aukera. + Har ezazu kontrola. + Sinkronizazio tarte erregularrak + Sinkronizazioa tarte erregularretan ahalbidetzeko, %s atzeko planoan exekutatzen utzi behar da. Bestela, Androidek sinkronizazioa gelditu dezake edozein unean. + Ez ditut sinkronizazio tarte erregularrak behar.* + %s bateragarritasuna + Salatzailearen firmwareak sinkronizazioa blokeatu dezake. Kaltetua bazara, eskuz bakarrik konpon dezakezu. + Beharrezko ezarpenak bukatu ditut. Ez gogorarazi berriro.* + * Utzi aktibatu gabe gero gogorarazteko. Aplikazioaren ezarpenetan berrezarri daiteke / %s + Informazio gehiago + jtx taula + + Tasks bateragarritasuna + Zereginak zure zerbitzarian onartuta badaude, onartutako zeregin aplikazio batekin sinkronizatu daitezke: + OpenTasks + Badirudi ez dela garatzen – ez da gomendatzen. + Tasks.org + ez dira onartzen.]]> + Ez dago denda aplikaziorik eskuragarri + Ez dut zereginen funtzionalitatea behar.* + Kode irekiko softwarea + %s erabiltzen duzula pozik gaude, software irekia delako. Garapen, mantentze eta laguntza lan gogorrak dira. Mesedez pentsatu kolaboratzen (modu asko daude) edo dohaintza bat. Asko eskertuko genuke! + Nola lagundu/dirua eman + Ez gogorarazi honetarako + + Hilabete %d + %d hilabete + + Hurrengoa + + Baimenak + %s baimenak behar ditu ondo funtzionatzeko. + Azpiko guztiak + Erabili hau ezaugarri guztiak gaitzeko (gomendatuta) + Baimen guztiak eman dira + Kontaktuen baimenak + Kontaktu sinkronizaziorik ez (ez gomendatuta) + Kontaktuen sinkronizazioa posible + Egutegiaren baimenak + Egutegi sinkronizaziorik ez (ez gomendatuta) + Egutegiaren sinkronizazioa posible + Jakinarazpen baimena + Jakinarazpenak desgaituta (ez gomendatuta) + Jakinarazpenak gaituta + jtx taularen baimenak + OpenTasks baimenak + Tasks baimenak + Zeregin sink. ez + Zereginen sinkronizazioa posible + Mantendu baimenak + Baimenak automatikoki berezarri daitezke (ez gomendatuta) + Baimenak ez dira automatikoki berezarriko + Egin klik Baimenak atalean > kendu marka \"Kendu baimenak aplikazioa ez bada erabiltzen\" aukeratik + Interruptore batek funtzionatzen ez badu, erabili aplikazioaren ezarpenak / Baimenak. + Aplikazioaren ezarpenak + + WiFi SSID baimenak + Uneko WiFi izena (SSID) atzitzeko, baldintza hauek bete behar dira: + Kokapen zehatz baimena + Kokapen baimena emanda + Kokapen baimena ukatuta + Atzeko planoko kokapen baimena + Baimendu beti + Kokapen-baimena honela ezarri da: %s + Kokapen-baimena ez da honela ezarri: +%s + %s kokapen-datuak (WiFi SSID soilik) erabiltzen ditu sinkronizazioa WiFi SSID zehatz batera mugatzeko soilik. Hori sinkronizazioa atzeko planoan exekutatzen denean ere gertatuko da. + Kokapen-datu guztiak (WiFi SSID soilik) lokalean bakarrik erabiltzen dira eta ez dira inora bidaltzen. + Kokapena beti gaituta + Kokapen zerbitzua gaituta dago + Kokapen zerbitzua desgaituta dago + + Itzulpenak + Liburutegiak + %1$s (%2$d) bertsioa + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) eta kolaboratzaileak + Programa hau INOLAKO BERMERIK GABE dator. Software librea da, eta birbanatzeko baimena duzu baldintza batzuk kontuan hartuz. + + Ezin izan da egunkari fitxategia sortu + %s jarduera guztiak erregistratzen + Ikusi/partekatu + Desgaitu + + CalDAV/CardDAV sinkronizazio moldagailua + Honi buruz / Lizentzia + Beta iritzia + Instalatu web nabigatzaile bat + Ezarpenak + Berriak eta eguneraketak + Tresnak + Kanpo loturak + Webgunea + Manuala + FAQ + Erakundeentzat + Komunitatea + Lagundu proiektuari + Nola lagundu + Pribatutasun gidalerroak + Ongi etorri DAVx⁵-ra! + Konektatu zure zerbitzarira eta mantendu zure egutegi eta kontaktuak sinkronizatuta. + Sinkronizatu kontu guztiak + + Jakinarazpenak desgaituta. Ez zaitugu sinkronizazio-erroreei buruz jakinaraziko. + Sinkronizazio automatikoa ez dago gaituta (ezin da Internet konexioa egiaztatu) + Kudeatu konexioak + Datu-aurrezpena gaituta. Atzeko planoko sinkronizazioa murriztuko da. + Kudeatu datu-aurrezpena + Bateria-aurrezlea aktibatuta dago. Sinkronizazioa mugatuta egon daiteke. + Kudeatu bateria-arrezpena + Biltegiratze lekua baxua. Android-ek ez ditu tokiko aldaketak berehala sinkronizatuko, hurrengo sinkronizazio arruntean baizik. + Kudeatu biltegia + Egutegiaren hornitzailea falta da + \"Egutegiaren biltegia\" sistemaren aplikazioa desgaitu al duzu? + Kontaktuen hornitzailea falta da + \"Kontaktuen biltegia\" sistemaren aplikazioa desgaitu al duzu? + Kudeatu aplikazioak + + Zerbitzuaren detekzioak huts egin du + Ezin izan da bilduma zerrenda freskatu + + Aurrealdean exekutatzen + Gailu batzuetan, hau beharrezkoa da sinkronizazio automatikorako. + + Ezarpenak + Arazketa + Erakutsi arazte informazioa + Ikusi/partekatu konfigurazio xehetasun eta egunkariak + Erregistro xehatuak + Erregistratzea aktibo dago. Erregistroak arazketa-informazioaren zati gisa ikus ditzakezu. + Erregistratzea desgaituta dago + Bateria optimizazioa + Aplikazioa salbuetsita dago (gomendatua) + Bateriaren murrizketak aplikatzen dira (ez da gomendagarria) + Konexioa + Proxy mota + + Sistemaren lehenetsia + Proxyrik ez + HTTP + SOCKS (Orbot-erako) + + Proxy ostalariaren izena + Proxy ataka + Segurtasuna + Aplikazioaren baimenak + Berrikusi sinkronizaziorako beharrezkoak diren baimenak + Mesfidatu sistemaren ziurtagiritaz + Sistemako eta erabiltzaileak gehitutako CAk ez dira fidagarritzat hartuko + Sistemako eta erabiltzaileak gehitutako CAk fidagarritzat hartuko dira (gomendatuta) + Ezarpen hau aktibo badago, sistemaren ziurtagiriak ez dira fidagarritzat hartzen. Horrek esan nahi du ziurtagiri guztiak eskuz onartu beharko dituzula (zerbitzariak ziurtagiria berritzen duenean ere bai) baita kontuaren konfigurazioa eta sinkronizazioak ez duela funtzionatuko. + Berezarri (mes)fidatutako ziurtagiriak + Ziurtagiri pertsonalizatu guztien fidagarritasuna berezartzen du + Ziurtagiri pertsonalizatu guztiak garbitu dira + Erabiltzaile interfazea + Jakinarazpen-ezarpenak + Kudeatu jakinarazpen kanalak eta haien ezarpenak + Hautatu gaia + + Sistemaren lehenetsia + Argia + Iluna + + Berezarri aholkuak + Lehen baztertu diren aholkuak berriro gaitzen ditu + Aholku guztiak erakutsiko dira berriro + Integrazioa + Tasks aplikazioa + Ez da zeregin aplikazio bategarririk aurkitu + UnifiedPush (esperimentala) + Bat ere es (desgaitu push) + Aukeratu banatzaile bat + Ez dago push banatzailerik instalatuta + Ez da amaiera punturik konfiguratu + Push jakinarazpenak %s(r)en bidez jasotzeko prest + FCM (Google Play) + Bultzatutako mezuak beti zifratzen dira. + + Kontua ezabatu da + CardDAV + CalDAV + Webcal + Bilduma hauek sinkronizatzeko baimen gehigarriak behar dira. + Kudeatu baimenak + Sinkronizatu orain + Kontuaren ezarpenak + Berrizendatu kontua + Gorde gabeko datu lokalak baztertu daitezke. Berriro sinkronizatu behar da izena aldatu ostean. + Kontuaren izen berria + Berrizendatu + Kontuaren izena hartuta dago + Ezin izan da kontua berrizendatu + Ezabatu kontua + Ezabatu kontua? + Helbide liburu, egutegi eta zeregin zerrenden kopia lokal guztiak ezabatuko dira. + sinkronizatu kolekzio hau + irakurri-soilik + egutegia + kontaktuak + egunkaria + zereginak + Erakutsi pertsonala soilik + Freskatu zerrenda + Webcal harpidetzak kanpoko aplikazioen bidez sinkroniza daitezke. + Ez da Webcal-ekin bateragarria den aplikaziorik aurkitu + Instalatu ICSx⁵ + + Gehitu kontua + pribatutasun politika.]]> + Saio-hasiera orokorra + Hornitzailearen berariazko saio-hasiera + Jarraitu + Saioa hasi + Saioa hasi helbide elektronikoarekin + Helbide elektornikoa + Baliozko eposta beharrezkoa da + Zerbitzuak aurkitzen dira DNS erregistroak eta URL ezagunak erabilita.]]> + Pasahitza + Ezkutatu pasahitza + Erakutsi pasahitza + Pasahitza (aukerakoa) + Saioa hasi URL eta erabiltzaile izenarekin + Erabiltzaile izena + Erabiltzaile izena (aukerakoa) + Oinarri URL + zerbitzuak ere aurkitzen dira DNS erregistroak eta URL ezagunak erabilita.]]> + Aukeratu ziurtagiria + Gehitu kontua + Kontuaren izena + Apostrofoak (\') erabiltzeak gailu batzuetan.arazoak sortzen dituela dirudi. + Erabili zure eposta helbidea kontu izen bezala Androidek kontuaren izena ANTOLATZAILE eremuan ezarriko duelako sortzen dituzun gertaerentzako. Ezin dituzu bi kontu izen berdinarekin eduki. + Kontaktuen taldekatze metodoa: + Kontuaren izena beharrezkoa + Kontuaren izena hartuta dago + Ezin izan da kontua gehitu + Bukatu + Saio-hasiera aurreratua + Bezero-ziurtagiririk gabe (aukerakoa) + Bezeroaren ziurtagiria: %s + Ez da ziurtagiririk aurkitu + Instalatu ziurtagiria + Fastmail + Fastmail kontua + Ireki saioa Fastmail-ekin + Google Kontaktuak / Egutegia + Google kontua + Hasi saioa Google-rekin + Bezeroaren ID (aukerazkoa) + Pribatutasun politika xehetasunetarako.]]> + <![CDATA[%1$s -k <a href="%2$s">Google API Zerbitzuen Erabiltzaileen Datuen Gidalerroak</a>, erabilera mugatuko eskakizunak barne.]]. + Ezin izan da baimen-kodea lortu + Nextcloud + Hasi saioa Nextcloud-ekin + Honek Nextcloud Flow saio-hasiera abiaraziko du web-nabigatzaile batean. + Nextcloud zerbitzariaren helbidea + Hasi saioa + Ezin izan da saio-hasieraren URLa lortu + Ezin izan dira saio-hasierako datuak lortu + Konfigurazio detekzioa + Mesedez itxaron, zerbitzaria kontsultatzen... + Ezin izan da CalDAV edo CardDAV zerbitzua aurkitu. + Oinarrizko URLa ez dirudi CalDAV/CardDAV URL eskuragarria denik eta zerbitzuaren detekzioa ez da gauzatu. + gure probatutako zerbitzuen zerrenda eta haien oinarrizko URLak.]]> + Mesedez, egiaztatu autentifikazioa ere (normalean erabiltzaile-izena eta pasahitza). + Informazio tekniko gehiago eskuragarri dago erregistroetan. + Ikusi egunkariak + + Sinkronizazioa + Kontaktuen sink. tartea + Eskuz soilik + %d minuturo + berehala aldaketa lokaletan + Egutegien sink. tartea + Zereginen sink. tartea + + Eskuz soilik + 15 minuturo + 30 minuturo + Ordu batero + 2 orduro + 4 orduro + Egunero + + Sinkronizatu soilik WiFi bidez + Sinkronizazioa WiFi konexioetara murriztuta dago + Konexio mota ez da kontuan hartzen + WiFi SSID murriztapena + %s(r)en bidez soilik sinkronizatuko du + WiFi konexio guztiak erabiliko dira + Komaz banatutako izenak (SSIDak) baimendutako WiFi sareentzako (utzi hutsik denentzako) + WiFi SSID murriztapenak ezarpen gehiago behar ditu + Kudeatu + VPN-k azpiko Internet behar du + Interneterako konexio balioztatu gabeko VPN ez da nahikoa sinkronizazioa exekutatzeko (gomendatua) + Interneterako konexio balioztatu gabeko VPN nahikoa da sinkronizazioa exekutatzeko + Autentifikazioa + Erabiltzaile izena + Pasahitza edo aplikazioaren pasahitza + aplikazio pasahitzaerabili .]]> + Pasahitz berria + Eguneratu pasahitza zure zerbitzariaren arabera + Baimendu berriro (OAuth) + Erabili sarbidea ukatu denean + Baimena eskuratu da + Bezeroaren ziurtagiria + Ez dago ziurtagiririk eskuragarri edo hautaturik + Instalatu ziurtagiria + CalDAV + Aurreko gertaeren denbora muga + Ekintza guztiak sinkronizatuko dira + + Egun bat baino gehiago dituzten gertaerak ezikusiko dira + %d egun baino gehiago dituzten gertaerak ezikusiko dira + + Orain dela egun hauek gertatu ziren gertaerak ezikusiko dira (0 izan daiteke). Utzi hutsik gertaera guztiak sinkronizatzeko. + Abisu lehentsia + + Abisu lehenetsia gertaera baino minutu bat lehenago + Abisu lehenetsia gertaera baino %d minutu lehenago + + Ez dago abisu lehenetsirik sortuta + Abisu lehenetsiak sortu behar badira abisurik gabeko gertaerentzat: nahi den minutu kopurua gertaera baino lehen. Utzi hutsik abisu lehenetsiak desgaitzeko. + Kudeatu egutegi koloreak + Egutegiaren koloreak sinkronizazio bakoitzean berrezarten dira + Egutegiaren koloreak beste aplikazio batzuetatik ezarri daitezke + Gertaera kolore bateragarritasuna + Gertaera koloreak sinkronizatuta daude + Gertaera koloreak ez daude sinkronizatuta + CardDAV + Kontaktu taldekatze metodoa + + Taldeak vCard banatuak dira + Taldeak kontaktu bakoitzeko kategoriak dira + + + Sortu helbide liburua + Baliteke zerbitzariak ez onartzea CardDAV bidez sortutako helbide-liburua. + Sortu egutegia + Lehenetsitako ordu-zona (aukerakoa) + + Egutegi sarrera posibleak + Gertaerak + Zereginak + Notak / egunkaria + Baliteke zerbitzariak ez onartzea CalDAV bidez sortutako egutegia. + Kolorea + Izenburua + Biltegiratze kokapena + Deskribapena (aukerakoa) + Sortu + + kontaktuak + gertakizunak + zereginak + Ezabatu kolekzioa + Bilduma hau (%s) eta bere datu guztiak behin betiko kenduko dira, bai lokaletik bai zerbitzaritik. + Sinkronizazioa + Sinkronizazioa gaitua dago + Sinkronizazioa desgaitua dago + Irakurtzeko soilik + Irakurtzeko soilik (zerbitzarian) + Irakurtzeko soilik (politikengatik) + Irakurtzeko soilik (lokala soilik) + Irakurri/idatzi + Izenburua + Deskribapena + Jabea + Bultzaden onarpena + Zerbitzariak bultzaden laguntza iragartzen du + %1$s-(e)n harpidetuta, %2$s iraungitzen da + Azken sinkronizazioa (%s) + Helbidea (URL) + + Arazketa informazioa + ZIP artxiboa + Arazte informazioa eta erregistroak ditu + Partekatu artxiboa ordenagailu batera transferitzeko, e-posta edo ticket baten bidez bidaltzeko. + Partekatu artxiboa + Arazketa informazioa mezuan erantsiko da (hartuko dituen aplikazioak eranskinak onartu behar ditu). + HTTP errorea + Zerbitzari errorea + WebDAV errorea + S/I errorea + Ikusi xehetasunak + Arazketa informazioa lortu da + Parte hartzen duten baliabideak + Arazoarekin erlazionatuta + Kanpoko baliabidea: + Baliabide lokala: + Egunkariak + Erregistro xehetuak eskuragarri daude + Ikusi egunkariak + Kopiatu URL + Pribatutasun oharra + Erregistroek eta arazketa-informazioak informazio pribatua izan dezakete. Kontuan izan hau publikoki partekatzerakoan. + + Errore bat gertatu da + HTTP errore bat gertatu da. + S/I errore bat gertatu da. + Erakutsi xehetasunak + + WebDAV muntaiak + Erabilitako kuota: %1$s / eskuragarri: %2$s + Partekatu edukia + Desmuntatu + Gehitu WebDAV muntaia + Atzitu zure cloud fitxategiak zuzenean WebDAV muntaia bat gehitzen! + nola funtzionatzen du WebDAVek.]]> + Bistaratze-izena + WebDAV URL + URL baliogabea + Muntatu puntua eta bistaratu izena + Autentifikazioa + Erabiltzaile izena + Pasahitza + Erabiltzaile izena (aukerakoa) + Pasahitza (aukerakoa) + Gehitu muntaia + Ez dago WebDAV zerbitzurik URL honetan + Kendu muntaia-puntua + Konexio xehetasunak galduko dira, baina ez da fitxategirik ezabatuko. + WebDAV fitxategia atzitzen + WebDAV fitxategia deskargatzen + WebDAV fitxategia kargatzen + WebDAV muntaia + + DAVx⁵ baimenak + Baimen gehigarriak beharrezkoak + %s zaharregia + Beharrezko bertsio minimoa: %1$s + Autentifikazioak huts egin du (egiaztatu saio-hasiera kredentzialak) + Sare edo S/I errorea – %s + HTTP zerbitzari-errorea – %s + Biltegiratze lokal errorea – %s + Errore leuna (saiakera maximora heldu da) + Kontaktu baliogabea jaso da zerbitzaritik + Gertaera baliogabea jaso da zerbitzaritik + Zeregin baliogabea jaso da zerbitzaritik + Baliabide baliogabe bat edo gehiago ezikusten + Sinkronizazioa zain + Urruneko datuak aldatu egin dria + + Sinkronizatu guztiak + Sinkronizatu kontu guztiak + Etiketatutako sinkronizazio botoia + Ikonotu Sinkronizatu botoia + Sakatu sinkronizazioa eskuz exekutatzeko. + + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..142422d --- /dev/null +++ b/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,329 @@ + + + + حساب کاربری موجود نیست (بیشتر از این) + کتاب آدرس DAVx⁵ + حذف + لغو + فعال + این قسمت الزامیست + راهنما + اشتراک گذاری + پایگاه داده، دارای مشکل است + تمام حساب های کاربری حذف شدند. + خطایابی + پیام های مهم دیگر + پیامهای وضعیت با اولویت پایین + همگام‌سازی + خطای همگام‌سازی + خطاهای مهمی که همگام سازی را مانند پاسخ های غیر منتظره سرور متوقف می کند + هشدارهای همگام‌سازی + مشکلات همگام سازی مانند برخی از پرونده های نامعتبر + شبکه و خطاهای ورودی خروجی + مهلت زمانی ، مشکلات اتصال و غیره (اغلب موقت) + + داده های شما، انتخاب شما + کنترل را بدست بگیرید + فواصل همگام سازی منظم + برای همگام سازی در فواصل منظم ، باید %s در پس زمینه اجرا شود. در غیر این صورت ، اندروید ممکن است هماهنگ سازی را متوقف کند. + من به فواصل همگام سازی منظم نیاز ندارم. * + سازگاری %s + من تنظیمات مورد نیاز را انجام داده ام. دیگر به من یادآوری نکن. * + * علامت را بردارید تا بعداً یادآوری شود. در تنظیمات برنامه %s قابل تنظیم مجدد است. + اطلاعات بیشتر + jtx Board + + پشتیبانی فعالیت ها + اگر وظایف توسط سرور شما پشتیبانی شود، می توان آنها را همگام سازی کرد: + وظایف را باز کنید + عدم توسعه توسط برنامه نویسان - توصیه نمیشود. + Tasks.org + فروشگاه در دسترس نیست + من به پشتیبانی وظایف نیاز ندارم. * + برنامه‌های متن باز + ما خوشحالیم که شما از %sکه یک نرم افزار منبع باز است استفاده می‌کنید. توسعه، نگهداری و پشتیبانی کار سختی است. لطفاً با یک کمک مالی (راه های زیادی وجود دارد) کمک کنید. بسیار ممنون می‌شویم! + نحوه کمک / اهدا + + مجوزها + %s برای کارکرد صحیح به این مجوزها نیاز دارد. + همه موارد زیر + برای فعال کردن همه ویژگی ها از این (توصیه می شود) استفاده کنید + همه مجوزها اعطا شده است + مجوزهای مخاطبین + بدون همگام سازی تماس (توصیه نمی شود) + همگام سازی تماس امکان پذیر است + مجوزهای تقویم + همگام سازی تقویم انجام نشود (توصیه نمی شود) + همگام سازی تقویم امکان پذیر است + مجوز اعلان + اعلان ها غیرفعال اند (توصیه نمی شود). + اعلان ها فعال اند. + مجوز برنامه jtx Board + مجوزهای OpenTasks + مجوز فعالیت ها + عدم همگام سازی وظایف + امکان همگام سازی وظایف وجود دارد + نگاه داری مجوزها + مجوزها ممکن است به طور خودکار تنظیم مجدد شوند (توصیه نمی شود) + مجوز به طور خودکار تنظیم مجدد نخواهد شد + روی مجوزها کلیک کنید> علامت \"حذف مجوزها در صورت استفاده نشدن برنامه\" را بردارید + اگر سوییچ کار نمی کند ، از تنظیمات / مجوزهای برنامه استفاده کنید. + تنظیمات برنامه + + مجوزهای SSID WiFi + برای دسترسی به نام WiFi فعلی (SSID) ، باید این شرایط را داشته باشید: + مجوز موقعیت دقیق + مجوز مکان اعطا شده است + مجوز مکان رد شد + مجوز مکان پس زمینه + همیشه اجازه دهید + مکان همیشه فعال است + سرویس مکان فعال است + سرویس مکان غیرفعال است + + ترجمه ها + کتابخانه ها + ورژن %1$s (%2$d) + این برنامه کاملاً بدون ضمانت است. این یک نرم افزار رایگان است ، و شما می توانید تحت شرایط خاص توزیع مجدد آن را انجام دهید. + + پرونده ثبت ایجاد نشد + اکنون همه %s فعالیت ها را ثبت می کنید + مشاهده / اشتراک گذاری + غیر فعال + + همگام سازی CalDAV / CardDAV + درباره / مجوز + بازخورد بتا + لطفاً یک مرورگر وب نصب کنید + تنظیمات + اخبار و amp؛ به روز رسانی + ابزارها + لینک های خارجی + سایت اینترنتی + دستی + FAQ + انجمن + سیاست حفظ حریم خصوصی + همگام سازی همه حساب‌ها + + اعلان ها غیر فعال اند. از همگام سازی با خبر نخواهید شد. + مدیریت ارتباطات + محافظ داده فعال است. همگام سازی در پس زمینه محدود می شود. + مدیریت محافظ داده + مدیریت حافظه + + تشخیص سرویس ناموفق بود + لیست مجموعه به روز نشد + + در حال اجرا در پیش زمینه + در برخی از دستگاه ها ، این مورد برای همگام سازی خودکار لازم است. + + تنظیمات + اشکال زدایی + نمایش اطلاعات اشکال زدایی + ورود به سیستم + ورود به سیستم غیرفعال است + بهینه ساز باتری + ارتباط + نوع پروکسی + + پیش فرض سیستم + بدون پروکسی + HTTP + ساکس (برای Orbot) + + هاست پروکسی + پورت پروکسی + امنیت + مجوزهای برنامه + مجوزهای لازم برای همگام سازی را مرور کنید + به گواهینامه های سیستم بی اعتماد باشید + سیستم و CA های اضافه شده توسط کاربر قابل اعتماد نخواهند بود + سیستم و CA های اضافه شده توسط کاربر قابل اعتماد خواهند بود (توصیه می شود) + تنظیم مجدد گواهی های مورد اعتماد و یا غیر قابل اعتماد + اعتماد همه گواهینامه های سفارشی را تنظیم مجدد می‌کند + همه گواهینامه های سفارشی پاک شده اند + رابط کاربر + تنظیمات اعلان + کانال های اعلان و تنظیمات آنها را مدیریت کنید + انتخاب تم + + پیش فرض سیستم + روشن + تیره + + تنظیم مجدد نکات + نکاتی را که قبلاً رد شده اند دوباره فعال می کند + همه نکات دوباره نشان داده خواهد شد + ادغام + برنامه مدیریت فعالیت ها + برنامه سازگار یافت نشد + + CardDAV + CalDAV + Webcal + اکنون همگام سازی کنید + تنظیمات حساب + تغییر نام حساب + تغییر نام + نام حساب قبلاً گرفته شده است + نمی‌توانید نام حساب را تغییر دهید + حذف حساب + واقعاً حساب حذف شود؟ + همه نسخه های محلی کتاب آدرس ، تقویم ها و لیست کارها حذف می شوند. + همگام سازی این مجموعه + فقط خواندنی + تقویم + وقایع + فقط شخصی ها را نمایش بده + هیچ برنامه ای با قابلیت Webcal پیدا نشد + ICSx⁵ را نصب کنید + + افزودن حساب + وارد شدن + با آدرس ایمیل وارد شوید + آدرس پست الکترونیکی + آدرس ایمیل معتبر لازم است + گذرواژه + با URL و نام کاربری وارد شوید + نام کاربری + آدرس پایه + گواهی را انتخاب کنید + افزودن حساب + عنوان حساب + از آدرس ایمیل خود به عنوان نام حساب استفاده کنید زیرا Android از نام حساب به عنوان قسمت ORGANIZER برای رویدادهایی که ایجاد می کنید استفاده خواهد کرد. نمی توانید دو حساب با یک نام داشته باشید. + روش گروه تماس: + نام حساب لازم است + نام حساب قبلاً گرفته شده است + گواهی یافت نشد + نصب گواهی + تشخیص پیکربندی + لطفا صبر کنید، پرس و جو سرور ... + سرویس CalDAV یا CardDAV پیدا نشد. + دیدن رویدادها + + همگام سازی + همگام سازی مخاطبین + فقط دستی + هر %d دقیقه + بلافاصله با تغییرات محلی + همگام سازی تقویم ها + همگام سازی فعالیت ها + + فقط به صورت دستی + هر ۱۵ دقیقه + هر ۳۰ دقیقه + هر ساعت + هر ۲ ساعت + هر ۴ ساعت + یک‌بار در روز + + همگام سازی فقط از طریق WiFi + همگام سازی محدود به اتصالات WiFi است + نوع اتصال در نظر گرفته نمی شود + محدودیت SSID WiFi + فقط بیش از %s همگام سازی می شود + از تمام اتصالات WiFi استفاده خواهد شد + نام های جدا شده با کاما (SSID) شبکه های WiFi مجاز (برای همه خالی بگذارید) + محدودیت WiFi SSID به تنظیمات بیشتری نیاز دارد + مدیریت + احراز هویت + نام کاربری + رمز عبور را با توجه به سرور خود به روز کنید. + نصب گواهی + CalDAV + محدودیت زمانی رویداد گذشته + همه رویدادها همگام سازی می شوند + + رخدادهایی که بیش از یک روز از گذشتنشان می‌گذرد نادیده گرفته می‌شوند + رویدادهای بیش از٪ d روز گذشته نادیده گرفته خواهد شد + + رویدادهایی که بیش از این تعداد روز در گذشته باشد نادیده گرفته می شوند (ممکن است 0 باشد). برای همگام سازی همه رویدادها خالی بگذارید. + یادآوری پیش فرض + + یادآوری پیش‌فرض یک دقیقه پیش از رویداد + یادآوری پیش‌فرض %d دقیقه پیش از رویداد + + هیچ یادآوری پیش فرض ایجاد نمی شود + اگر یادآوری های پیش فرض برای رویدادهای بدون یادآوری ایجاد شود: به میزان دلخواه دقیقه قبل از رویداد. برای غیرفعال کردن یادآوری های پیش فرض ، خالی بگذارید. + رنگ های تقویم را مدیریت کنید + رنگ تقویم در هر همگام سازی تنظیم مجدد می شود + رنگ های تقویم را می توان توسط برنامه های دیگر تنظیم کرد + پشتیبانی از رنگ رویداد + رنگهای رویداد همگام سازی می شوند + رنگهای رویداد همگام سازی نمی شوند + CardDAV + روش گروه تماس + + گروه‌ها کارت‌های مجازی جداگانه هستند + گروه ها دسته های هر مخاطب هستند + + + ایجاد دفترچه آدرس + تقویم ایجاد کنید + ورودی های احتمالی تقویم + رویدادها + فعالیت ها + یادداشت ها / ژورنال + رنگ + عنوان + محل ذخیره سازی + ایجاد + + حذف مجموعه + همگام سازی + عنوان + شرح + + اطلاعات اشکال زدایی + آرشیوسازی ZIP + حاوی اطلاعات و پیغام های دیباگ + اشتراک گذاری فایل آرشیو شده، جهت انتقال به کامپیوتر، ارسال از طریق ایمیل و افزودن به تیکت پشتیبانی + اشتراک گذاری آرشیو + اطلاعات اشکال زدایی پیوست شده به این پیام (نیاز به پشتیبانی پیوست از برنامه دریافت کننده دارد). + خطای HTTP + خطای سرور + خطای WebDAV + خطای ورودی خروجی + نمایش جزئیات + اطلاعات اشکال زدایی جمع آوری شده است + منابع درگیر + مربوط به مشکل است + منبع از راه دور: + منبع محلی: + رویدادها + رویدادهای مربوط به گفتار موجود است + دیدن رویدادها + URL را کپی کنید + + خطایی رخ داده است. + خطای HTTP رخ داده است. + خطای ورودی خروجی رخ داده است. + نمایش جزئیات + + اشتراک گذاری محتوا + نام نمایشی + URL نامعتبر + احراز هویت + نام کاربری + گذرواژه + اتصال قطع میگردد، اما هیچ فایلی حذف نخواهد شد. + دسترسی به فایل WebDAB + بارگیری فایل WebDAV + بارگذاری فایل WebDAV + + مجوزهای همگام‌ساز DAVx⁵ + مجوزهای اضافی لازم است + %s خیلی قدیمی است + حداقل نسخه مورد نیاز: %1$s + احراز هویت ناموفق بود (اعتبار ورود به سیستم را بررسی کنید) + شبکه یا خطای ورودی / خروجی – %s + خطای سرور HTTP – %s + خطای ذخیره سازی محلی – %s + مخاطب نامعتبر از سرور دریافت شد + رویداد نامعتبر از سرور دریافت شد + کار نامعتبر از سرور دریافت شد + نادیده گرفتن یک یا چند منبع نامعتبر + + همگام سازی همه حساب‌ها + + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..08afe72 --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,42 @@ + + + + DAVx⁵ + DAVx⁵ Osoitekirja + Osoitekirjat + Apua + Hallitse tilejä + Debuggaus + Muut tärkeät viestit + Synkronointi + Synkronoinnin virheet + Huomattavat virheet jotka estävät synkronoinnin kuten palvelimen odottamattomat vastaukset + Synkronoinnin varoitukset + Ei-kohtalokkaat synkronoinnin ongelmat kuten tietyt virheelliset tiedostot + Verkko ja I/O virheet + Aikakatkaisut, yhteysvirheet, yms. (usein väliaikaisia) + + + + + + + + + + + + Kirjaudu sähköpostilla + Sähköpostiosoite + Salasana + Kirjaudu verkko-osoitteella ja käyttäjänimellä + Käyttäjänimi + + Käyttäjänimi + Salasana + + + + + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..024f0fa --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,437 @@ + + + + Le compte n’existe plus (supprimé) + Carnet d\'adresses DAVx⁵ + Ne modifiez pas votre compte ici ! Utilisez plutôt l\'application pour configurer directement votre compte. + Supprimer + Retirer + Annuler + Activer + Ce champ est requis + Aide + Revenir en haut + Menu d\'options + Partager + Synchronisation démarrée/en file d\'attente + Base de données corrompue + Tous les comptes ont été supprimés localement. + Débogage + Autres messages importants + Messages d’état de faible priorité + Synchronisation + Erreurs de synchronisation + Erreurs importantes qui bloquent la synchronisation, telles que des réponses inattendues du serveur + Avertissements de synchronisation + Problèmes de synchronisation non fatals tels que certains fichiers non valides + Erreurs de réseau et d\'entrée/sortie + Délais d\'attente, problèmes de connexion, etc. (souvent temporaires) + + Vos données, votre choix. + Prenez le contrôle. + Intervalles de synchronisation régulières + Pour une synchronisation à intervalles réguliers, %s doit être autorisé à fonctionner en arrière-plan. Sinon, Android peut interrompre la synchronisation à tout moment. + Je n\'ai pas besoin d\'intervalles de synchronisation réguliers.* + %s compatibilité + J\'ai fait les réglages nécessaires. Ne me le rappelez plus.* + * Laisser non coché pour un rappel ultérieur. Peut être réinitialisé dans les paramètres de l\'application / %s. + Plus d\'informations + jtx Board + + Gestion des taches + Si les tâches sont prises en charge par votre serveur, elles peuvent être synchronisées avec une application de tâches externe : + OpenTasks + Ne semble plus être développé - non recommandé. + Tasks.org + Pas de magasin d\'application disponible + Je n\'ai pas besoin de support des tâches.* + Logiciels open-source + Nous sommes heureux que vous utilisiez %s, qui est un logiciel open-source. Le développement, la maintenance et l\'assistance sont un travail difficile. Veuillez envisager de contribuer (il y a plusieurs façons de le faire) ou de faire un don. Ce serait très apprécié ! + Comment contribuer / donner + Ne me rappelle pas pour + Suivant + + Autorisations + %s nécessite des autorisations pour fonctionner correctement. + Tout autoriser + A utiliser pour activer toutes les fonctionnalités (recommandé) + Toutes les autorisations accordées + Autorisations d\'accès aux contacts + Pas de synchronisation du carnet d\'adresses (non recommandé) + Synchronisation du carnet d\'adresses possible + Autorisations du calendrier + Pas de synchronisation du calendrier (non recommandé) + Synchronisation du calendrier possible + Autorisations des notifications + Notifications désactivées (non recommandé) + Notifications activées + Autorisations jtx Board + Autorisations d\'OpenTasks + Autorisations de Tasks + Aucune tâche de synchro + Synchronisation des tâches possible + Conserver les autorisations + Les autorisations peuvent être réinitialisées automatiquement (non recommandé) + Les autorisations ne seront pas réinitialisées automatiquement + Cliquez sur Autorisations > décochez \"Supprimer les autorisations si l\'application n\'est pas utilisée\". + Si un commutateur ne fonctionne pas, utiliser les paramètres de l\'application / Autorisations + Paramètres de l\'application + + WiFi SSID autorisations + Pour pouvoir accéder au nom du WiFi actuel (SSID), ces conditions doivent être remplies : + Permission de localisation précise + Autorisation de localisation accordée + Autorisation de localisation refusée + Autorisation de localisation en arrière-plan + Autorisez tout le temps + Autorisation de localisation réglée sur : %s + Autorisation de localisation non réglée sur : %s + %sutilise vos données de localisation (seulement concernant les SSID de WiFi) uniquement pour restreindre la synchronisation à un SSID WiFi spécifique. Cela se produira même lorsque la synchronisation se fera en tâche de fond. + Les données de localisation (seulement les SSID WiFi) ne sont utilisées que localement et ne sont envoyées nulle part. + Localisation toujours activée + Le service de localisation est activé + Le service de localisation est désactivé + + Traductions + Librairies + Version %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) et les contributeurs + Ce programme est fourni sans AUCUNE GARANTIE. C\'est un logiciel libre, et vous êtes en droit de le redistribuer sous certaines conditions. + + Impossible de créer le fichier journal + Maintenant toutes les activités de %s seront enregistrées + Voir/partager + Désactiver + + Adaptateur de synchronisation CalDAV/CardDAV + À propos / Licence + Commentaire pour la version Beta + Veuillez installer un navigateur + Paramètres + Actualités & mises à jour + Outils + Liens externes + Site Web + Manuel + Foire aux questions + Communauté + Soutenir le projet + Comment contribuer + Politique de confidentialité + Bienvenue sur DAVx⁵! + Connectez-vous à votre serveur pour garder vos calendriers et contacts synchronisés. + Synchroniser tous les comptes + + Notifications désactivées. Vous ne serez pas averti des erreurs de synchronisation. + La synchronisation automatique n\'est pas active (aucune connexion Internet vérifiée) + Gérer les connexions + L\'économiseur de données est activé. La synchronisation en arrière-plan est limitée. + Gérer l\'économiseur de données + L\'économiseur de batterie est activé. La synchronisation risque d\'être limitée. + Gérer l\'économiseur de batterie + Espace de stockage faible. Android ne synchronisera pas les changements locaux immédiatement mais pendant la prochaine synchronisation. + Gérer le stockage + Fournisseur de calendrier manquant + Avez-vous désactivé l\'application système \'Stockage du calendrier\' ? + + La détection du service a échoué + Impossible d\'actualiser la liste de collection + + Fonctionne au premier plan + Sur certains appareils, cela est nécessaire pour la synchronisation automatique. + + Paramètres + Débogage + Afficher les infos de débogage + Voir/partager les détails de configuration et les logs + Journalisation verbeuse + Les journaux sont activés. Vous pouvez voir les journaux parmi les informations de débogage. + La journalisation est désactivée + Optimisation de la batterie + Pas de restriction (recommandé) + Restriction d\'utilisation de la batterie activée (non recommandé) + Connexion + Type de proxy + + Par défaut (système) + Pas de proxy + HTTP + SOCKS (pour Orbot) + + Nom de l\'hôte du proxy + Port du proxy + Sécurité + Autorisations de l\'application + Consulter les autorisations requises pour la synchronisation + Révoquer les certificats du système + Les certificats du système et ceux ajoutés par l\'utilisateur ne seront pas dignes de confiance + Les certificats du système et ceux ajoutés par l\'utilisateur seront dignes de confiance (recommandé) + Réinitialiser les certificats de (non)confiance + Réinitialiser la confiance de tous les certificats personnalisés + Tous les certificats personnalisés ont été effacés + Interface utilisateur + Paramètres de notification + Gérer les canaux de notification et leurs paramètres + Sélectionner un thème + + Par défaut + Clair + Sombre + + Réinitialiser les astuces + Réactiver les astuces qui ont été vues précédemment + Toutes les astuces seront affichés à nouveau + Intégration + Applications de gestion de tâches + Aucune application de tâches compatibles trouvée + UnifiedPush (expérimental) + Aucun point de terminaison (endpoint) configuré + + Carnets d\'adresses (CardDAV) + Agendas (CalDAV) + WebCal + Des autorisations supplémentaires sont nécessaires pour synchroniser ces collections. + Gérer les autorisations + Synchroniser maintenant + Paramètres du compte + Renommer le compte + Les données locales non sauvegardées risquent d\'être perdues. Une resynchronisation est nécessaire après le renommage. + Nouveau nom de compte + Renommer + Le nom du compte est déjà pris + Impossible de renommer le compte + Supprimer le compte + Voulez-vous vraiment supprimer le compte ? + Toutes les copies locales des carnets d\'adresses, des calendriers et des listes de tâches seront supprimées. + Synchroniser cette collection + En lecture seulement + calendrier + contacts + journal + tâches + N\'afficher que les comptes personnels + Rafraîchir la liste + Les abonnements Webcal peuvent être synchronisés avec des applications tierces. + Aucune application compatible WebCal + Installer ICSx⁵ + + Ajouter un compte + la politique de confidentialité.]]> + Connexion standard + Connexion par fournisseur spécifique + Suivant + Se connecter + Connexion avec une adresse de courriel + Adresse de courriel + Une adresse de courriel valide est requise + Les services sont découverts en utilisant les enregistrements DNS et les URL connues.]]> + Mot de passe + Cacher le mot de passe + Montrer le mot de passe + Connexion avec une URL et un nom d\'utilisateur + Nom d\'utilisateur + URL de base + les services sont aussi découverts en utilisant les enregistrements DNS et les URL connues.]]> + Choisir le certificat + Ajouter un compte + Nom du compte + L\'utilisation d\'apostrophe (\') semble poser problème sur certains appareils. + Utilisez votre adresse de courriel comme nom de compte car Android utilisera ce nom en tant que champ ORGANISATEUR pour les événements que vous créerez. Vous ne pouvez pas avoir deux comptes avec le même nom. + Méthode pour les contacts de type groupe : + Nom du compte requis + Le nom du compte est déjà pris + Le compte n\'a pas pu être ajouté + Finis + Connexion avancée + Certificat client : %s + Aucun certificat trouvé + Installer un certificat + Google Contacts / Agenda + Compte Google + Se connecter avec Google + ID client (optionnel) + Politique de confidentialité.]]> + politique des données utilisateur des services API Google, incluant les exigences de limitation d\'utilisation.]]> + N\'a pas pu obtenir le code d\'autorisation + Nextcloud + Se connecter avec Nextcloud + La page de connexion Nextcloud va se lancer dans le navigateur + Adresse du serveur Nextcloud + Se connecter + Impossible d\'obtenir l\'URL de connexion + Impossible d\'obtenir les données de connexion + Détection de la configuration + Veuillez patienter, nous interrogeons le serveur … + Aucun accès possible au service CalDAV ou CardDAV. + L\'URL de base ne semble pas être une URL CalDAV/CardDAV et la détection du service a échoué. + notre liste de services testés ainsi que leurs URL de base.]]> + Merci de bien vérifier l\'authentification (souvent le nom d\'utilisateur et le mot de passe). + Plus d\'information technique est disponible dans les journaux. + Voir les journaux + + Synchronisation + Intervalle de synchronisation des carnets d\'adresses + Manuellement + Toutes les %d minutes et immédiatement après un changement local + Intervalle de synchronisation des agendas + Intervalle de synchronisation des tâches + + Manuellement + Tous les quarts d\'heure + Toutes les demi-heures + Toutes les heures + Toutes les deux heures + Toutes les quatre heures + Une fois par jour + + Synchronisation en Wifi seulement + La synchronisation est limitée aux connexions WiFi + Le type de connexion n\'est pas pris en charge + Restriction WiFi SSID + Synchronisation possible seulement en %s + Toutes les connexions WiFi seront utilisées + Liste des points d\'accès WiFi (SSID) autorisés, séparés par des virgules. (Laissez vide pour tous) + La restriction du SSID WiFi nécessite des réglages supplémentaires + Gérer + Le VPN nécessite un accès Internet fonctionnel + Un VPN sans connexion Internet fonctionnelle ne permet pas de lancer une synchronisation (recommandé) + Un VPN sans connexion Internet fonctionnelle peut lancer une synchronisation + Authentification + Nom d\'utilisateur + Nouveau mot de passe + Mettre à jour le mot de passe + Certificat client + Aucun certificat disponible ou sélectionné + Installer un certificat + CalDAV + Limite des événements passés + Tous les événements seront synchronisés + + Les événements de plus d’un jour passé seront ignorés + Les événements de plus de %d jours passés seront ignorés + Les événements de plus de %d jours passés seront ignorés + + Les événements antérieurs à ce nombre de jours seront ignorés (peut être 0). Laissez vide pour synchroniser tous les événements. + Rappel par défaut + + Rappel par défaut une minute avant l\'événement + Rappel par défaut %d minutes avant les événements + Rappel par défaut %d minutes avant les événements + + Aucun rappel par défaut + Si des rappels par défaut doivent être créé pour des événements sans rappel: le nombre de minutes avant l\'événement. Laisser blanc pour désactiver les rappels par défaut. + Choisir la couleur du calendrier + Les couleurs du calendrier sont réinitialisées à chaque synchronisation + Les couleurs du calendrier peuvent être définies par d\'autres applications + Couleur associée aux événements + Les couleurs des événements sont synchronisées + Les couleurs des événements sont pas synchronisées + CardDAV + Méthode pour les contacts de type groupe + + Les groupes sont des VCards indépendantes + Les groupes sont des catégories pour chacun des contacts + + + Créer un carnet d\'adresses + La création de carnet d\'adresses via CardDAV n\'est peut-être pas supportée par ce serveur. + Créer un calendrier + + Entrées possibles de calendrier + Événements + Tâches + Notes / journal + La création de calendrier via CalDAV n\'est peut-être pas supportée par ce serveur. + Couleur + Titre + Emplacement de stockage + Description (facultative) + Créer + + contacts + tâches + Supprimer la collection + Cette collection (%s) et toutes ses données vont être définitivement supprimées, sur cet appareil et sur le serveur. + Synchronisation + Synchronisation activée + Synchronisation désactivée + Lecture seule + Lecture seule (côté serveur) + Lecture seule (selon les politiques) + Lecture seule (sur cet appareil) + Lecture / écriture + Titre + Description + Responsable + Support du \'Push\' + Le serveur annonce supporter \'Push\' + Dernière synchro (%s) + Adresses (URL) + + Infos de débogage + Archive ZIP + Contient des informations de débogage et des journaux + Partagez l’archive pour la transférer sur un ordinateur, pour l’envoyer par courriel ou pour la joindre à un ticket de support. + Partager l\'archive + Informations de débogage jointes à ce message (nécessite une application compatible). + Erreur HTTP + Erreur Serveur + Erreur WebDAV + Erreur d\'entrée/sortie + Voir les détails + Les informations de débogage ont été collectées + Ressources impliquées + En rapport avec le problème + Ressource à distance : + Ressource locale : + Journaux + Des journaux verbeux sont disponibles + Voir les journaux + Copier l\'URL + + Une erreur est survenue. + Une erreur HTTP est survenue. + Une erreur d\'entrée/sortie est survenue. + Voir détails + + Points de montage WebDAV + Quota utilisé : %1$s / disponible : %2$s + Partager le contenu + Démonter + Ajouter un point de montage WebDAV + Accédez directement à vos fichiers du cloud en ajoutant un point de montage WebDAV ! + Nom affiché + URL WebDAV + URL incorrecte + Authentification + Nom d\'utilisateur + Mot de passe + Ajouter un point de montage + Aucun service WebDAV à cette URL + Retirer le point de montage + Les détails de la connexion seront perdus, mais aucun fichier ne sera supprimé. + Accès au fichier WebDAV + Téléchargement du fichier WebDAV + Téléversement du fichier WebDAV + Point de montage WebDAV + + Autorisations DAVx⁵ + Autorisations supplémentaires demandées + %s trop ancien + Version minimale requise : %1$s + Echec de connexion (vérifier vos identifiants de connexion) + Erreur de réseau ou d\'entrée/sortie - %s + Erreur de serveur HTTP - %s + Erreur de stockage local - %s + Erreur logicielle (nombre maximum de tentatives atteint) + Reçu un contact invalide du serveur + Reçu un événement invalide du serveur + Reçu une tâche invalide du serveur + Ignorer une ou plusieurs ressources non valides + Synchronisation en attente + Les informations à distance ont changé + + Tout synchroniser + Synchroniser tous les comptes + + diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..83b1013 --- /dev/null +++ b/app/src/main/res/values-gl/strings.xml @@ -0,0 +1,450 @@ + + + + A conta non existe (definitivamente) + Libreta de enderezos DAVx⁵ + Non cambies de conta aquí! Usa directamente a aplicación para xestonar as contas. + Eliminar + Eliminar + Desbotar + Activar + Este campo é requerido + Axuda + Ir arriba + Menú de opcións + Compartir + Sincronización iniciada/en agarda + Base de datos estragada + Elimináronse todas as contas de xeito local + Depurando + Outras mensaxes importantes + Mensaxes de estado de baixa prioridade + Sincronización + Fallos na sincronización + Erros importantes como respostas non agardadas do servidor que deteñen a sincronización + Avisos sobre a sincronización + Problemas non-fatais de sincronización como certos ficheiros non válidos + Fallos de rede e I/O + Caducidades, problemas de conexión, etc. (soen ser temporais) + + Os teus datos. Ti elixes. + Toma o control. + Intervalos regulares de sincronización + Para sincronizar a intervalos regulares, %s debe ter permiso para executarse en segundo plano. Se non, Android podería deter a sincronización. + Non preciso sincr. con regularidade.* + Compatibilidade de %s + Xa fixen o que me pediades. Non mo lembres máis.* + * Deixar sen marcar para lembrar máis tarde. Pode restablecerse nos axustes da app / %s. + Máis información + jtx Board + + Soporte para Tasks + Se as tarefas están soportadas polo teu servidor, poden ser sincronizadas cunha app que soporte tarefas: + OpenTasks + Semella que xa non está en desenvolvemento – non recomendado. + Task.org + Non hai compatibilidade para algunhas características]]> + Sen tenda de apps dispoñible + Non necesito soporte para tarefas.* + Software de código aberto + Encántanos que uses %s, que é software de código aberto. O desenvolvemento, mantemento e soporte son un traballo difícil. Considera colaborar (hai moitos xeitos) ou facer unha doazón. Sería de agradecer! + Como contribuír/doar + Non mo lembres durante + + %d mes + %d meses + + Seguinte + + Permisos + %s require permisos para funcionar axeitadamente. + Todos os de abaixo + Usa isto para habilitar tódalas características (recomendado) + Todos os permisos concedidos + Permisos de contactos + Sen sincronización de contactos (non recomendado) + É posible sincronizar os contactos + Permisos de calendario + Sen sincronización de calendario (non se recomenda) + É posible sincronizar o calendario + Permiso de notificacións + Notificacións desactivadas (non recomendado) + Notificacións activadas + permisos para jtx Board + Permisos para OpenTasks + Permisos para Tasks + Tarefas non sincr. + É posible sincronizar as tarefas + Manter permisos + Os permisos poden restablecerse automáticamente (non recomendado) + Os permisos non se restablecerán automáticamente + Preme en Permisos > desmarca \"Eliminar permisos se a app non se usa\" + Se unha opción non funciona, usa axustes da aplicación / Permisos. + Permisos da aplicación + + Permisos WiFi SSID + Para poder acceder á WiFi (SSID) actual, deben darse estas condicións: + Permiso para localización precisa + Permiso de localización outorgado + Permiso de localización denegado + Permiso de localización en segundo plano + Permitir en todo momento + Permiso de localización establecido como: %s + O permiso de localización non é: %s + %s usa datos de localización (só WiFi SSID) có ánimo de limitar a sincronización a unha rede WifFi concreta. Isto acontece incluso cando a sincronización se realiza en segundo plano. + Todos os datos de localización (WiFi SSID) só se usan de xeito local e non se envían a ningures. + Localización sempre activada + Servizo de localización activado + Servizo de localización desactivado + + Traducións + Bibliotecas + Versión %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e colaboradoras + Este programa non proporciona NINGUNHA GARANTÍA. É software libre, e convidámoste a redistribuílo baixo certas condicións. + + Non se creou ficheiro de rexistro + Rexistrando todas as %s actividades + Ver/compartir + Desactivar + + Adaptador de sincr. CalDAV/CardDAV + Acerca de / Licenza + Comenta sobre a Beta + Instala un navegador web + Axustes + Novas & actualizacións + Ferramentas + Ligazóns externas + Sitio web + Manual + PMF + Comunidade + Apoia ao proxecto + Como colaborar + Política de Privacidade + Benvida a DAVx⁵! + Conecta co teu servidor e mantén os contactos e calendarios sincronizados. + Sincroniza todas as contas + + Notificacións desactivadas. Non verás os avisos de erro na sincr. + A sincronización automática non está activa (sen conexión a internet verificada). + Xestionar conexións + O aforro de datos está activado. A sincronización en segundo plano está restrinxida. + Xestionar aforro de datos + Activado o aforrador de batería. A sincronización podería estar limitada. + Xestionar aforro da batería + Queda pouco espazo de almacenaxe. Android non vai sincronizar os cambios locais inmediatamente, farao na próxima sincronización regular. + Xestionar almacenaxe + Falta o provedor do calendario + Desactivouse a app do sistema \"Almacenaxe do calendario\"? + Falta o provedor de contactos + Desactivouse a app do sistema \"Almacenaxe de contactos\"? + Xestionar apps + + Fallou a detección do servizo + Non se actualizou a lista da colección + + Executándose en primeiro plano + En algúns dispositivos esto é necesario para a sincronización automática. + + Axustes + Depurando + Mostrar info de depuración + Ver/compartir os detalles da configuración e rexistros + Rexistro polo miúdo + O rexistro está activo. Podes ver os rexistros como parte da info de depuración. + O rexistro está desactivado + Optimización da batería + A app está excluída (recomendado) + Aplícanse restricións da batería (non recomendado) + Conexión + Tipo de Proxy + + Por defecto no sistema + Sen proxy + HTTP + SOCKS (para Orbot) + + Servidor do proxy + Porto do proxy + Seguridade + Permisos da App + Revisa os permisos requeridos para a sincronización + Non confiar en certificados do sistema + Non se confiará nos CAs do sistema ou engadidos por usuaria + Confiarase nos CAs do sistema e engadidos por usuaria (recomendado) + Se este axuste está activo, os certificados do sistema non se consideran de confianza. Isto significa que haberá que aceptar manualmente cada certificado (tamén cando o servidor o renove) ou a configuración e a sincronización non funcionarán. + Restablecer certificados repudiados + Restablece a confianza en todos os certificados personalizados + Todos os certificados personalizados foron admitidos + Interface de usuaria + Axustes das notificacións + Xestiona as canles de notificación e os seus axustes + Elixe decorado + + Por defecto no sistema + Claro + Escuro + + Restablecer consellos + Restablece todos os consellos que foran desbotados anteriormente + Mostraranse todos os consellos de novo + Integración + App Tarefas + Non se atopan app de tarefas compatible + UnifiedPush (experimental) + Ningún (push desactivado) + Elixe un distribuidor + Non hai un distribuidor instalado + Sen punto de acceso configurado + Preparado para recibir mensaxes push desde %s + + CardDAV + CalDAV + Webcal + Requírense permisos adicionais para poder sincronizar estas coleccións. + Xestionar permisos + Sincronizar agora + Axustes da conta + Renomear conta + Poden perderse os datos locais non gardados. Requírese a sincronización após o cambio de nome. + Novo nome da conta + Renomear + O nome de conta xa está a ser utilizado + Non cambiou o nome da conta + Eliminar conta + Desexas eliminar a conta? + Todas as copias locais de libretas de enderezos, calendarios e tarefas eliminaranse. + sincronizar esta colección + só lectura + calendario + contactos + diario + tarefas + Mostrar só personal + Actualizar lista + As subscricións Webcal poden sincronizarse con apps externas. + Non se atopou app para Webcal + Instalar ICSx⁵ + + Engadir conta + política de privacidade.]]> + Acceso xenérico + Acceso específico do provedor + Continuar + Conectar + Conectar con enderezo de correo-e + Enderezo de correo-e + Requíre un enderezo de correo válido + Servizos + usando rexistros DNS de URLs tipo well-known.]]> + Contrasinal + Agochar contrasinal + Mostrar contrasinal + Conectar con URL e nome de usuaria + Nome de usuaria + URL base + servizos tamén se atopan usando rexistros DNS e URLs tipo well-known.]]> + Escoller certificado + Engadir conta + Nome da conta + O uso de apóstrofes (\') semella que causa problemas nalgúns dispositivos. + Utiliza o teu enderezo de correo electrónico como nome de conta xa que Android utilizará o nome da conta como campo ORGANIZADOR para os eventos que cree. Non poderás ter dúas contas co mesmo nome. + Método para agrupar contacto: + Nome de conta requerido + O nome de conta xa está a ser utilizado + Non se puido engadir a conta + Rematar + Acceso avanzado + Certificado cliente: %s + Non se atopa certificado + Instalar certificado + Contactos / Calendario de Google + Conta de Google + Inicia sesión con Google + ID Cliente (optativo) + política de Privacidade para saber máis.]]> + Google API Services User Data Policy, incluíndo os requerimentos de Limited Use.]]> + Non se puido obter o código de autorización + Nextcloud + Acceder con Nextcloud + Accederás usando Nextcloud nun navegador Web. + Enderezo do servidor Nextcloud + Acceder + Non se obtivo o URL de acceso + Non se obtiveron os datos de acceso + Detección da configuración + Agarda por favor, consultando o servidor… + Non se atopou servizo CalDAV ou CardDAV. + O URL base non parece ser accesible. Non se puido detectar o URL do servizo CalDAV/CardDAV. + a nosa lista de servizos comprobados e o seu URL base.]]> + Ademais comproba ben a autenticación (normalmente identificador e contrasinal). + Tes dispoñible máis información técnica nos rexistros. + Ver rexistros + + Sincronización + Intervalo de sincr. contactos + Só manual + Cada %d minutos + tras cambios locais + Intervalo de sincr. calendarios + Intervalo sincr. de tarefas + + Só manual + Cada 15 minutos + Cada 30 minutos + Cada hora + Cada 2 horas + Cada 4 horas + Unha vez ao día + + Sincronizar só con WiFi + Sincronización restrinxida a conexións WiFi + Non se terá en conta o tipo de conexión + Restrición SSID WiFi + Sincr. só en %s + Utilizaranse todas as conexións WiFi + Nome das rede WiFi permitidas (SSIDs) separados por vírgulas (en branco para todas) + A restrición WiFi SSID precisa máis axustes + Xestionar + A VPN require acceso a Internet + VPN, sen ter a conexión a Internet verificada, non é suficiente para lanzar a sincronización (recomendado) + VPN, sen ter a conexión a Internet verificada, é suficiente para lanzar a sincronización + Autenticación + Nome de usuaria + Novo contrasinal + Actualizar o contrasinal de acordo ao teu servidor. + Certificado do cliente + Sen certificado dispoñible ou seleccionado + Instalar certificado + CalDAV + Límite temporal para eventos pasados + Sincronizaranse todos os eventos + + Eventos anteriores a un día serán ignorados + Eventos anteriores a %d días serán ignorados + + Eventos anteriores a máis dos días indicados serán ignorados (podería ser 0). Deixar en branco para sincronizar todos os eventos. + Alarma por omisión + + Alarma por omisión un minuto antes do evento + Alarma por omisión %d minutos antes do evento + + Non se crearon alarmas por omisión + Se deben ser creadas as alarmas por omisión para eventos sen alarma: o número de minutos desexado antes do evento. Deixar baleiro para desactivar alarmas por omisión. + Xestionar cores dos calendarios + As cores do calendario restablécense tras cada sincr. + Outras apps poden establecer as cores do calendario + Soporte para cor de eventos + As cores dos eventos están sincronizadas + As cores dos eventos non están sincronizadas + CardDAV + Método para agrupar contacto + + Grupos son vCards separados + Grupos son categorías por contacto + + + Crear libreta de enderezos + O servidor podería non ter soporte para crear libretas de enderezos usando CardDAV. + Crear calendario + + Entradas posibles no calendario + Eventos + Tarefas + Notas / diario + O servidor podería non ter soporte para crear caledarios usando CalDAV. + Cor + Título + Localización do almacenamento + Descrición (optativo) + Crear + + contactos + tarefas + Eliminar colección + Esta colección (%s) e todos os seus datos serán borrados permanentemente, tanto localmente como no servidor. + Sincronización + Sincronización activada + Sincronización desactivada + Só lectura + Só lectura (polo servidor) + Só-lectura (polas normas) + Só lectura (só localmente) + Lectura/escritura + Título + Descrición + Dona + Soporte Push + O servidor anuncia soporte para Push + Subscrición desde %1$s, caduca o %2$s + Última sincr (%s) + Enderezo (URL) + + Info depuración + Arquivo ZIP + Contén info de depuración e rexistros + Comparte o arquivo para pasalo á computadora, envialo por email ou anexalo a un tícket de axuda + Compartir arquivo + Info de depuración anexa a esta mensaxe (require soporte de anexos na app receptora). + Erro HTTP + Fallo no servidor + Fallo WebDAV + Fallo I/O + Ver detalles + Recolleuse a info de depuración + Recursos implicados + Relacionado co problema + Recurso remoto: + Recurso local: + Rexistros + Están dispoñibles rexistros explicativos + Ver rexistros + Copiar URL + + Algo fallou. + Houbo un fallo HTTP. + Houbo un fallo I/O. + Mostrar detalles + + Montaxes WebDAV + Cota utilizada: %1$s / dispoñible: %2$s + Compartir contido + Desmontar + Engadir montaxe WebDAV + Accede aos teus ficheiros na nube engadindo unha montaxe WebDAV! + Nome mostrado + URL WebDAV + URL inválido + Autenticación + Nome de usuaria + Contrasinal + Engadir montaxe + Neste URL non hai ningún servizo WebDAV + Eliminar punto de montaxe + Perderanse os detalles da conexión, mais non se eliminarán ficheiros. + Accedendo ao ficheiro WebDAV + Descargando ficheiro WebDAV + Subindo ficheiro WebDAV + Montaxe WebDAV + + Permisos DAVx⁵ + Precísanse permisos adicionais + %s demasiado antigo + Versión mínima requerida: %1$s + Fallo na autenticación (verifique credenciais) + Fallo de Rede ou I/O – %s + Fallo servidor HTTP – %s + Fallo almacenamento local – %s + Erro (acadouse o máx. de reintentos) + Recibido contacto non válido desde o servidor + Recibido evento non válido desde o servidor + Recibida tarefa non válida desde o servidor + Ignorando un ou varios recursos non válidos + Sincr pendente + Os datos remotos cambiaron + + Sincr todo + Sincroniza todas as contas + + diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..638b745 --- /dev/null +++ b/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,281 @@ + + + + Račun (više) ne postoji + DAVx⁵ Adresar + Omogući + Pomoć + Dijeli + Debugging + Ostale važne poruke + Statusne poruke niskog prioriteta + Sinkronizacija + Sinkronizacijske greške + Važne greške koje zaustavljaju sinkronizaciju poput neočekivanih odgovora poslužitelja + Sinkronizacijska upozorenja + Sinkronizacijski problemi koji nisu fatalni poput određenih nevaljanih datoteka + Mrežne i I/O greške + Vremensko ograničenje, greške povezivanja, itd. (obično privremeno) + + Vaši podaci. Vaš izbor. + Preuzmite kontrolu. + Redoviti intervali sinkronizacije + Za sinkronizaciju u redovitim intervalima, %s mora imati omogućen rad u pozadini. Inače, Android može zaustaviti sinkronizaciju u bilo kojem trenutku. + Ne trebam redovita sinkroniziranja. + %s kompatibilnost + Tražene izmjene su napravljene. Ne podsjećaj me više. + * Ostavi neoznačeno za podsjetnik kasnije. Moguće je resetirati u aplikacijskim postavkama / %s. + Više informacija + Podrška za zadatke + Ukoliko su zadatci podržani od strane vašeg poslužitelja, moguće ih je sinkronizirati sa podržanom aplikacijom za zadatke: + OpenTasks + Trgovina aplikacijama nije dostupna + Ne trebam podršku za zadatke.* + Softver otvorenog koda + Sretni smo što upotrebljavate %s, softver otvorenog koda. Razvoj, održavanje i podrška težak su posao. Molimo razmislite o doprinosu (mnogo je načina) ili o donaciji. Biti ćemo vam vrlo zahvalni! + Kako doprinijeti/donirati + + Dopuštenja + %s treba dopuštenja kako bi radio ispravno. + Sve od ispod + Koristi ovo za aktiviranje svih mogućnosti (preporučeno) + Sva dopuštenja odobrena + Dopuštenja kontakata + Bez sinkronizacije kontakata (nije preporučeno) + Moguća sinkronizacija kontakata + Dopuštenja kalendara + Bez sinkronizacije kalendara (nije preporučeno) + Moguća sinkronizacija kalendara + OpenTasks dopuštenja + Dopuštenja zadataka + Moguća sinkronizacija zadataka + Zadrži dopuštenja + Dopuštenja mogu biti automatski resetirana (nije preporučeno) + Dopuštenja neće biti automatski resetirana + Ukloni dopuštenja ukoliko se aplikacija ne upotrebljava + Ukoliko prekidač ne radi, koristi postavke aplikacije / Dopuštenja. + Postavke aplikacije + + WiFi SSID dopuštenja + Kako bi se pristupilo trenutnom WiFi imenu (SSID), ovi uvjeti trebaju biti zadovoljeni: + Dopuštenja lokacije odobrena + Dopuštenja lokacije odbijena + Pozadinska dopuštenja lokacije + Dopusti cijelo vrijeme + Lokacija uvijek omogućena + Lokacijske usluge su omogućene + Lokacijske usluge su onemogućene + + Prijevodi + Bibiloteke + Verzija %1$s(%2$d) + Ovaj program dolazi BEZ APSOLUTNO BILO KAKVOG JAMSTVA. To je besplatni softver i možete ga distribuirati pod određenim uvjetima. + + Couldn\'t create log file + Now logging all %s activities + Pregledaj/podijeli + Onemogući + + CalDAV/CardDAV Sync Adapter + About / License + Beta feedback + Molimo instalirajte web preglednik + Postavke + Novosti & aktualnosti + Vanjske poveznice + Web stranica + Upute + FAQ + Pravila o zaštiti privatnosti + Sinkroniziraj sve račune + + + Detekcija servisa nije uspjela + Nije moguće osvježiti popis zbirki + + Pokrenuto u prednjem planu + Na nekim uređajima, ovo je neophodno za automatsku sinkronizaciju. + + Postavke + Debugging + Prikaži debug informacije + Verbose logging + Logging je onemogućen + Veza + Sigurnost + Dopuštenja aplikacije + Pregledajte dopuštenja potrebna za sinkronizaciju + Ukidanje povjerenja sistemskim certifikatima + Sistemski i korisnički dodani CA-i neće biti od povjerenja + Sistemski i korisnički dodani CA-i su od povjerenja (preporučeno) + Resetiraj certifikate od (ne)povjerenja + Reseira povjerenje svih prilagođenih certifikata + Svi prilagođeni certifikati su obrisani + Korisničko sučelje + Postavke obavijesti + Uredi izvore obavještavanja i njihove postavke + Resetiraj naznake + Ponovno omogućuje naznake koje su prethodno bile odbačene + Sve naznake će ponovno biti prikazane + Integracija + Tasks aplikacija + Kompatibilna aplikacija za zadatke nije pronađena + + CardDAV + CalDAV + Webcal + Sinkroniziraj sada + Postavke računa + Preimenuj račun + Preimenuj + Naziv računa se već koristi + Račun nije moguće preimenovati + Obriši račun + Stvarno izbrisati račun? + Sve lokalne kopije adresara, kalendara i popisa zadataka biti će izbrisane. + sinkroniziraj ovu zbirku + read-only + kalendar + Prikaži samo osobno + Aplikacija sa Webcal mogućnostima nije pronađena + Instaliraj ICSx⁵ + + Dodaj račun + Prijava + Prijavi se sa adresom e-pošte + Adresa e-pošte + Potrebna je valjana adresa e-pošte + Lozinka + Prijava sa URL-om i korisničkim imenom + Korisničko ime + Osnovni URL + Odaberi certifikat + Dodaj račun + Naziv računa + Koristite svoju adresu e-pošte kao naziv računa jer Android će koristiti naziv računa kao ORGANIZER polje za događaje koje kreirate. Nije moguće imati dva računa sa istim imenom. + Metoda kontaktnih grupa: + Potreban je naziv računa + Naziv računa se već koristi + Certifikat nije pronađen + Instaliraj certifikat + Detektiranje konfiguracije + Pričekajte, postavljanje upita poslužitelju... + Nije moguće pronaći CalDAV ili CardDAV uslugu. + Pregledaj logove + + Sinkronizacija + Interval sinkr. kontakata + Samo ručno + Svakih %d minuta + odmah nakon lokalnih izmjena + Interval sinkr. kalendara + Interval sinkr. zadataka + + Samo ručno + Svakih 15 minuta + Svakih 30 minuta + Svaki sat + Svaka 2 sata + Svaka 4 sata + Jednom dnevno + + Sinkroniziraj preko WiFi + Sinkronizacija je ograničena na WiFi veze + Vrsta veze se ne uzima u obzir + WiFi SSID ograničenje + Sinhronizirat će se samo preko %s + Koristit će se sve WiFi veze + Zarezom odijeljeni nazivi (SSIDa) dozvoljenih WiFi mreža (ostaviti prazno za sve mreže) + WiFi SSID ograničenje zahtijeva daljnje postavke + Upravljaj + Autentifkacija + Korisničko ime + Ažurirajte lozinku vezanu uz vaš poslužitelj. + Instaliraj certifikat + CalDAV + Vremensko ograničenje za prošli događaj + Svi događaju biti će sinkronizirani + + Događaji duži od jednog dana u prošlosti bit će zanemareni + Događaji duži od %d dana u prošlosti bit će zanemareni + Događaji duži od %d dana u prošlosti bit će zanemareni + + Događaji koji su prošli više od ovog broja dana bit će zanemareni (može biti 0). Ostavite prazno za sinkronizaciju svih događaja. + Zadani podsjetnik + + Zadani podsjetnik jednu minuta prije događaja + Zadani podsjetnik %d minuta prije događaja + Zadani podsjetnik %d minuta prije događaja + + Zadani podsjetnici nisu kreirani + Ako će se stvoriti zadani podsjetnici za događaje bez podsjetnika: željeni broj minuta prije događaja. Ostavite prazno da biste onemogućili zadane podsjetnike. + Upravljaj bojama kalendara + Boje kalendara se resetiraju pri svakoj sinkronizaciji + Boje kalendara mogu biti postavljene od strane drugih aplikacija + Podrška za boju događaja + Boje događaja su sinkronizirane + Boje događaja nisu sinkronizirane + CardDAV + Metoda kontaktnih grupa + + Grupe su odvojene vCards + Grupe su kategorije po kontaktima + + + Kreiraj adresar + Kreiraj kalendar + Mogući unosi u kalendar + Događaji + Zadatci + Bilješke / dnevnik + Boja + Naslov + Mjesto pohrane + Kreiraj + + Obriši zbirku + Sinkronizacija + Naslov + Opis + + Debug info + Debug info priložen je uz ovu poruku (zahtijeva podršku za privitke aplikacije koja će primiti poruku). + HTTP greška + Greška na poslužitelju + WebDAV greška + I/O greška + Pogledaj pojedinosti + Debug info je prikupljen + Uključeni resursi + Povezano s problemom + Udaljeni resurs: + Lokali resurs: + Logovi + Opsežniji logovi su dostupni + Pregledaj logove + Kopiraj URL + + Dogodila se greška. + Dogodila se HTTP greška. + Dogodila se I/O greška. + Prikaži detalje + + Autentifkacija + Korisničko ime + Lozinka + + DAVx⁵ dopuštenja + Dodatna dopuštenja su potrebna + %s prestar + Najmanja potrebno verzija: %1$s + Autentifikacija nije uspjela (provjerite podatke za prijavu) + Mrežna ili I/O greška – %s + HTTP poslužiteljska greška – %s + Greška lokalne pohrane – %s + Primljen je nevažeći kontakt sa poslužitelja + Primljen je nevažeći dogđaj sa poslužitelja + Primljen je nevažeći zadatak sa poslužitelja + Zanemarivanje jednog ili više nevaljanih resursa + + Sinkroniziraj sve račune + + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..bb2ef91 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,450 @@ + + + + A felhasználói fiók (már) nem létezik + DAVx⁵ címjegyzék + Itt ne változtassa meg a fiókot! Használja közvetlenül az alkalmazást a fiókok kezeléséhez. + Törlés + Eltávolítás + Mégse + Bekapcsolás + A mező megadása kötelező + Súgó + Navigáció felfelé + Beállítások menü + Megosztás + A szinkronizáció elkezdődött/sorba lett állítva + Az adatbázis megsérült + A fiókok törölve lettek az eszközön. + Hibakeresés + Egyéb fontos üzenetek + Alacsony prioritású státuszüzenetek + Szinkronizáció + Szinkronizációs hibák + Fontos hibák, amelyek leállítják a szinkronizációt, például a váratlan kiszolgálóválaszok + Szinkronizációs figyelmeztetések + Nem kritikus szinkronizációs hibák, például bizonyos fajta hibás fájlok + Hálózati és I/O hibák + Időtúllépésék, kapcsolódási problémák, stb. (gyakran átmeneti problémák) + + Az Ön adatai. Az Ön döntése. + Vegye kézbe az irányítást. + Rendszeres ütemezett szinkronizálás + A rendszeres ütemezett szinkronizáláshoz a %s számára engedélyezni kell a háttérben futást, különben az Android rendszer a szinkronizálást bármikor leállíthatja. + Nincs szükségem rendszeres ütemezett szinkronizálásra.* + %s kompatibilitás + Elvégeztem a szükséges beállításokat, nincs szükségem további figyelmeztetésre.* + * Hagyja üresen, ha szeretné, ha legközelebb is kapjon emlékeztetést. Később felülírható az alkalmazásbeállításoknál (%s). + További információk + jtx Board + + Feladatok támogatása + Ha a kiszolgáló támogatja a feladatokat, akkor a következő feladatalkalmazásokkal lehet szinkronizálni őket: + OpenTasks + A fejlesztése leállt – nem ajánlott. + Tasks.org + nem támogatottak.]]> + Nincs elérhető alkalmazás-áruház + Nincs szükségem a feladatok támogatására.* + Nyílt forráskódú szoftver + Nagyon örülünk, hogy a %s felhasználói közé tartozik, amely nyílt forráskódú szoftver. A fejlesztés, karbantartás és támogatás ugyanakkor kemény munkát igényel. Fontolja meg, hogy ezt pénzzel, vagy valamilyen más módon támogassa (több lehetőség közül is választhat). Nagyon megköszönnénk! + A hozzájárulás lehetőségei + Ne emlékeztessen + + %d hónapig + %d hónapig + + Tovább + + Engedélyek + A %s megfelelő működése bizonyos engedélyeket igényel. + Az összes alább felsorolt + Ezt választva valamennyi funkció bekapcsolható (javasolt) + Az összes engedély megadva + Névjegyengedélyek + A címtárak szinkronizálásának mellőzése (nem javasolt) + A névjegy-szinkronizálás lehetséges + Naptárengedélyek + A naptárak szinkronizálásának mellőzése (nem javasolt) + A naptárak szinkronizálása lehetséges + Értesítési engedély + Az értesítések tiltása (nem javasolt) + Értesítések engedélyezése + jtx Board engedélyek + OpenTasks engedélyek + Feladatok engedélyek + Nincs feladatszinkronizálás + A feladatlisták szinkronizálása lehetséges + Engedélyek megtartása + Az engedélyek automatikusan visszaállhatnak az alapértelmezettre (nem javasolt) + Az engedélyek nem fognak visszaállni az alapértelmezettre automatikusan + Kattintson az Engedélyekre > vegy ki a pipát az „Engedélyek eltávolítása, ha nem használja az alkalmazást” mellől + Ha a kapcsolók nem működnek, használja az alkalmazásbeállításokat. + Alkalmazásbeállítások + + WiFi SSID engedélyek + A jelenlegi WiFi nevének (SSID) az eléréséhez a következő feltételeknek kell teljesülniük: + Pontos helyadatok használatának engedélyezése + Helyadatok engedélye megadva + Helyadatok engedélye megtagadva + Helyadatok engedélyezése a háttérben + Engedélyezés mindig + A Helyadatok engedély erre van állítva: %s + A Helyadatok engedély nincs erre állítva: %s + A %s csak arra használ helyadatokat (a WiFi SSID-t), hogy a szinkronizációt egy konkrét WiFi SSID-ra korlátozza. Ez akkor is megtörténik, ha a szinkronizáció a háttérben fut. + Az összes helyadat (csak a WiFi SSID) csak helyben lesz használva, és nem lesz sehová sem elküldve + A helyadatok mindig engedélyezve vannak + A helyadat-szolgáltatás bekapcsolva + A helyadat-szolgáltatás kikapcsolva + + Fordítások + Programkönyvtárak + Verziószám:%1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) és a közreműködők + Ehhez a program SEMMIFÉLE GARANCIA NEM JÁR. Szabad szoftver, amely bizonyos feltételek mellett szabadon terjeszthető. + + A naplófájl létrehozása nem sikerült + Mostantól a %s minden művelete naplózásra kerül + Megtekintés/megosztás + Kikapcsolás + + CalDAV/CardDAV szinkronizációs adapter + Névjegy/licenc + Tesztelői visszajelzés + Telepítsen egy böngészőt + Beállítások + Hírek és frissítések + Eszközök + Weblapok + Honlap + Kézikönyv + GYIK + Közösség + A projekt támogatása + A közreműködés lehetőségei + Adatvédelmi irányelvek + Üdvzöli a DAVx⁵! + Kapcsolódjon a kiszolgálójához, és tartsa szinkronban a naptárait és névjegyeit. + Az összes fiók szinkronizálása + + Az értesítések tiltva vannak. A szinkronizálási hibákról nem fog értesítést kapni. + Az automatikus szinkronizálás nem aktív (nincs megerősített internetkapcsolat) + Kapcsolatok kezelése + Az adatcsökkentés bekapcsolva. A háttérben futó szinkronizálás korlátozva van. + Adatcsökkentés kezelése + Az akkumulátorkímélés bekapcsolva. A háttérben futó szinkronizálás korlátozva van. + Akkumulátorkímélés kezelése + A rendelkezésre álló tárhely kevés. Az Android nem fogja a helyi erőforrásokat azonnal szinkronizálni, csak a következő rendes alkalommal. + Tárhely kezelése + A naptárszolgáltató hiányzik + Letiltotta a „Naptártároló” rendszeralkalmazást? + A névjegyszolgáltató hiányzik + Letiltotta a „Névjegytároló” rendszeralkalmazást? + Alkalmazások kezelése + + A szolgáltatások felderítése nem sikerült + A gyűjteménylista frissítése nem sikerült + + Futás előtérben + Egyes eszközökön szükséges az automatikus szinkronizáció működéséhez + + Beállítások + Hibakeresés + Hibakeresési információ megtekintése + A beállítások részleteinek és a naplók megtekintése/megosztása + Részletes naplózás + A naplózás aktív. A naplókat a hibakeresési információk részeként tekintheti meg. + Naplózás kikapcsolva + Akkumulátorhasználat optimalizálása + Az alkalmazás kivétel alóla (ajánlott) + Akkumulátorkorlátozások érvényesek (nem ajánlott) + Kapcsolat + A proxy típusa + + Alapértelmezett + Kikapcsolva + HTTP + SOCKS (Orbot számára) + + A proxy kiszolgálóneve + A proxy által használt port + Biztonság + Alkalmazásengedélyek + Tekintse át a szinkronizáláshoz szükséges engedélyeket + A rendszertanúsítványok elfogadása + A rendszer által kezelt, előre vagy felhasználó által telepített tanúsítványok figyelmen kívül lesznek hagyva + A rendszer által kezelt, előre vagy felhasználó által telepített tanúsítványok megbízhatóak (javasolt) + Ha ez a beállítás aktív, akkor a rendszertanúsítványok nem lesznek megbízhatónak tekintve. Ez azt jelenti, hogy minden tanúsítványt kézileg kell elfogadnia (akkor is, ha a kiszolgáló megújítja a tanúsítványát), különben a fiókbeállítások és a szinkronizáció nem fog működni. + A tanúsítványok megbízhatóságának törlésére + A tanúsítványok megbízhatóságával kapcsolatos beállítások törlésére + A tanúsítványok megbízhatóságával kapcsolatos beállítások törölve + Felhasználói felület + Értesítési beállíások + Az értesítési csatornák és azok beállításának kezelése + Stílus kiválasztása + + Rendszerbeállítás szerinti + Világos + Sötét + + Tippek visszaállítása + Újra jelenjen meg az összes tipp + Az összes tipp újra meg fog jelenni + Integráció + Feladatok alkalmazás + Nem található kompatibilis feladatalkalmazás + UnifiedPush (kísérleti) + Nincs (leküldés letiltása) + Válasszon egy disztribútort + Nincs leküldésdisztribútor telepítve + Nincs végpont beállítva + Készen áll a leküldéses üzenetek fogadására innen: %s + + CardDAV + CalDAV + Webcal + További engedélyek szükségesek ezen gyűjtemények szinkronizálásához. + Engedélyek kezelése + Szinkronizálás most + Fiókbeállítások + Fiók átnevezése + A mentetlen helyben tárolt adatok elvesznek. Az átnevezés után szinkronizálásra lesz szükség. Új fióknév: + Új fióknév + Átnevezés + A fióknév már használatban van + A fiók átnevezése nem sikerült. + Fiók törlése + Valóban törölni akarja a fiókot? + Az összes címjegyzék, naptár és feladatlista helyi példányai törölve lesznek. + a gyűjtemény szinkronizálása + csak olvasható + naptár + névjegyek + napló + feladatok + Csak a személyesek megjelenítése + Lista frissítése + A Webcal feliratkozások külső alkalmazásokkal szinkronizálhatók. + Nem található Webcal-képes alkalmazás + ICSx⁵ telepítése + + Fiók hozzáadása + adatvédelmi nyilatkozatot.]]> + Általános bejelentkezés + Szolgáltatófüggő bejelentkezés + Folytatás + Bejelentkezés + Bejelentkezés e-mail-cím segítségével + E-mail-cím: + Érvényes e-mail-cím szükséges + szolgáltatások felfedezése DNS rekordok és jól ismert webcímek alapján történik.]]> + Jelszó + Jelszó elrejtése + Jelszó megjelenítése + Bejelentkezés webcím és felhasználónév segítségével + Felhasználónév + Alapwebcím + szolgáltatások felfedezése DNS rekordok és jól ismert webcímek alapján is történik.]]> + Tanúsítvány kiválasztása + Fiók hozzáadása + A fiók neve + Az aposztrófok (\') használata a visszajelzések szerinte egyes eszközökön problémát okoz. + Használja az e-mail-címét fióknévként, mert később a létrehozandó események szervezőjeként (ORGANIZER mező) az Android ezt fogja használni. Két fiókot nem lehet azonos néven létrehozni. + A csoportok kezelésének módja: + A fióknév kötelező + A fióknév már használatban van + A fiók hozzáadása nem sikerült + Befejezés + Speciális bejelentkezés + Klienstanúsítvány: %s + Nem található tanúsítvány + Tanúsítvány telepítése + Google Névjegyek / Naptár + Google-fiók + Bejelenetezés a Google használatával + Kliensazonosító (nem kötelező) + Adatvédelmi irányelveket.]]> + Google API szolgáltatások felhasználói adatokra vonatkozó irányelvének, ide értve a korlátozott felhasználásra vonatkozó előírásokat is.]]> + A hitelesítőkódot nem sikerült megszerezni + Nextcloud + Bejelentkezés Nextcloud használatával + Ez elindítja a bejelentkezési folyamatot egy webböngészőben. + Nextcloud-kiszolgáló címe + Bejelentkezés + A bejelentkezési webcím megszerzése nem sikerült + A bejelentkezési adatok megszerzése nem sikerült + A konfiguráció felderítése + Várjon, a kiszolgáló lekérdezése… + Nem található CalDAV vagy CardDAV szolgáltatás. + Ez az alapwebcím nem tűnik elérhető CalDAV/CardDAV webcímnek, és a szolgáltatásfelderítés nem sikerült. + általunk tesztelt szolgáltatások listáját, és az alapwebcímeiket.]]> + Ellenőrizze a hitelesítő adatok is (általában a felhasználónevet és a jelszót). + További műszaki információk érhetők el a naplókban. + Naplóbejegyzések megtekintése + + Szinkronizálás + Névjegyszinkronizálás sűrűsége + Csak kézileg + Minden %d percben + az eszközön történt módosítás után + Naptárszinkronizálás sűrűsége + Feladatlisták szinkronizálásának sűrűsége + + Csak kézi + 15 percenként + 30 percenként + Óránként + Kétóránként + Négyóránként + Naponta + + Szinkronizálás csak WIFI-n + Szinkronizálás csak WIFI kapcsolaton keresztül + Szinkronizálás a kapcsolat típusától függetlenül + WiFi SSID-ra korlátozása + Az alábbi hálózatok használhatók: %s + Minden hálózat használható + A használható WiFi hálózatok nevei (SSID), vesszővel elválasztva (hagyja üresen, ha nem akar szűrést beállítani) + A WiFi SSID-ra szűréséhez további beállítások szükségesek + Beállítások + A VPN-hez mögöttes internetkapcsolat szükséges + A VPN ellenőrzött internetkapcsolat nélkül nem elég a szinkronizáláshoz (ajánlott) + A VPN ellenőrzött internetkapcsolat nélkül is elég a szinkronizáláshoz + Hitelesítés + Felhasználónév + Új jelszó + Adja meg a kiszolgálón érvényes új jelszót. + Klienstanúsítvány + Nem érhető el vagy nincs kiválasztva tanúsítvány + Tanúsítvány telepítése + CalDAV + Múltbéli események időkorlátja + Minden esemény szinkronizálása + + Az egy napnál régebbi események figyelmen kívül hagyása + A(z) %d napnál régebbi események figyelmen kívül hagyása + + Az ennyi napnál (lehet 0) régebbi események figyelmen kívül lesznek hagyva. Hagyja üresen, ha minden múltbéli eseményt szinkronizálni akar. + Alapértelmezett emlékeztető + + Az alapértelmezett emlékeztető az esemény kezdete előtt egy perccel van + Az alapértelmezett emlékeztető az esemény kezdete előtt %d perccel van + + Nem lesznek alapértelmezett emlékeztetők beállítva + Ha szeretné, hogy az emlékeztető nélküli eseményekhez egy alapértelmezett emlékeztető legyen beállítva, akkor adja meg, hogy az hány perccel az esemény előtt legyen. Ha nem akar ilyet, hagyja üresen. + Naptárszínek kezelése + A naptárszínek minden szinkronizáláskor visszaállnak az alapértelmezettre + A naptárszíneket más alkalmazásokban lehet beállítani + Eseményszínek támogatása + Az eseményszínek szinkronizálva vannak + Az eseményszínek nincsenek szinkronizálva + CardDAV + A csoportok kezelésének módja + + A csoportok különálló vCard objektumok + A csoportok névjegy-kategóriák + + + Címjegyzék létrehozása + A címjegyzék CardDAV-on keresztüli létrehozását nem biztos, hogy támogatja a kiszolgáló. + Naptár létrehozása + + Lehetséges naptárbejegyzések + Események + Feladatok + Jegyzetek/napló + A naptár CalDAV-on keresztüli létrehozását nem biztos, hogy támogatja a kiszolgáló. + Szín + Cím + Tárhely + Leírás (nem kötelező) + Létrehozás + + névjegyek + feladatok + Gyűjtemény törlése + A gyűjtemény (%s) és a hozzá tartozó adatok véglegesen törölve lesznek, helyben és a kiszolgálóról is. + Szinkronizálás + Szinkronizálás bekapcsolva + Szinkronizálás kikapcsolva + Csak olvasható + Csak olvasható (kiszolgálón) + Csak olvasható (házirend szerint) + Csak olvasható (csak helyben) + Olvasás/írás + Cím + Leírás + Tulajdonos + Leküldés támogatása + A kiszolgáló a leküldés támogatását hirdeti + Feliratkozva: %1$s, lejár: %2$s + Utolsó szinkronizálás (%s) + Webcím + + Hibakeresési információk + ZIP archívum + Hibakeresési információkat tartalmaz + Az archívum megosztásával lehetőség van egy másik számítógépre áthelyezni, e-mail formájában elküldeni vagy egy hibabejelentéshez mellékelni. + Archívum megosztása + Hibakeresési információ csatolása ehhez az üzenethez (ha a fogadó alkalmazás támogatja a mellékleteket). + HTTP hiba + Kiszolgálóhiba + WebDAV hiba + Ki-/bemeneti hiba + Részletek megtekintése + A hibakeresési információ összegyűjtése befejeződött + Érintett erőforrások + A probléma kapcsán érintett erőforrások + Távoli erőforrás: + Helyi erőforrás: + Naplók + Rendelkezésre állnak részletes naplóbejegyzések + Naplóbejegyzések megtekintése + URL másolása + + Hiba történt. + HTTP hiba történt. + Ki-/bemeneti hiba történt. + Részletek megjelenítése + + WebDAV kötetek + Felhasznált kvóta: %1$s / keret: %2$s + Tartalom megosztása + Leválasztás + WebDAV kötet hozzáadása + Közvetlenül hozzáférhet a felhőben tárolt fájlokhoz egy WebDAV kötet hozzáadásával! + a WebDAV kötetek hogyan működnek.]]> + Megjelenítendő név + WebDAV webcím + Érvénytelen webcím + Hitelesítés + Felhasználónév + Jelszó + WebDAV kötet hozzáadása + Ezen a webcímen nincs WebDAV szolgáltatás + WebDAV kötet leválasztása + A kapcsolat beállításai elvesznek, de maguk a fájlok nem. + Hozzáférés a WebDAV fájlhoz + WebDAV fájl letöltése + WebDAV fájl feltöltése + WebDAV kötetek + + DAVx⁵ engedélyek + További engedélyek szükségesek + %s túl régi + Legalacsonyabb szükséges verzió: %1$s + A hitelesítés nem sikerült (ellenőrizze a hitelesítési adatokat) + Hálózati vagy ki-/bemeneti hiba – %s + HTTP kiszolgálóhiba – %s + Helyi tárhelyhiba –%s + Nem végzetes hiba (az újrapróbálkozások száma elérte a maximumot) + A kiszolgáló érvénytelen névjegyet küldött + A kiszolgáló érvénytelen eseményt küldött + A kiszolgáló érvénytelen feladatot küldött + Egy vagy több érvénytelen erőforrás kihagyva + Szinkronizálás függőben + A távoli adatok megváltoztak + + Az összes szinkronizálása + Az összes fiók szinkronizálása + + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..83f488c --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,406 @@ + + + + Account inesistente (o cancellato) + Rubrica DAVx⁵ + Cancella + Elimina + Annulla + Attiva + Questo campo è necessario + Aiuto + Menu opzioni + Condividi + Sincronizzazione avviata + Database danneggiato + Tutti gli account sono stati rimossi localmente. + Debugging + Altri messaggi importanti + Messaggi di stato a bassa priorità + Sincronizzazione + Errori di sincronizzazione + Errori importanti che bloccano la sincronizzazione, come risposte inattese del server + Avvisi di sincronizzazione + Problemi di sincronizzazione non gravi come alcuni file non validi + Errori di Rete e di I/O + Timeouts, problemi di connessione, ecc. (spesso temporanei) + + Tuoi i dati. Tua la scelta. + Riprendi il controllo. + Intervalli di sincronizzazione regolari. + Per sincronizzare i dati a intervalli regolari, %s deve essere autorizzato a girare in background. Altrimenti Android può mettere in pausa gli aggiornamenti in qualunque momento. + Non ho bisogno di sincronizzare a intervalli di tempo regolari.* + %s compatibilità + Ho settato le impostazioni richieste. Non ricordarmelo più. + * Lascia smarcato per fartelo ricordare dopo. Può essere reimpostato nelle impostazione dell\'app %s. + Maggiori informazioni + + Supporto per le attività + Se le attività sono supportate dal tuo server, possono essere sincronizzate con una app per attività supportata: + OpenTasks + Non sembra essere più sviluppato - non raccomandato. + Tasks.org + Nessun app store disponibile + Non ho bisogno del supporto alle attività.* + Software open-source + Siamo felici che tu usi %s, che è un software open source. Lo sviluppo, la manutenzione e il supporto sono compiti duri. Per piacere prendi in considerazione di dare una mano (puoi farlo in molti modi) o una donazione. Sarebbe davvero apprezzato! + Come aiutare/donare + + Autorizzazioni + %s richiede autorizzazioni per funzionare correttamente. + Tutti i seguenti + Usare questo per abilitare tutte le funzioni (consigliato) + Concedi tutte le autorizzazioni + Autorizzazioni per i contatti + Non sincronizzare i contatti (sconsigliato) + Possibilità di sincronizzare i contatti + Autorizzazioni per il calendario + Non sincronizzare il calendario (sconsigliato) + Permette di sincronizzare il calendario + Autorizza notifiche + Notifiche disabilitate (non consigliato) + Notifiche attive + Autorizzazioni di OpenTasks + Autorizzazioni delle attività + Permette di sincronizzare le attività + Mantieni autorizzazioni + Le autorizzazioni possono essere reimpostate automaticamente (sconsigliato) + Le autorizzazioni non si reimposteranno automaticamente + Fai click su Autorizzazioni > deseleziona \"Rimuovi autorizzazioni se l\'app non è in uso\" + Se uno slider non funziona, vai a impostazioni app/ autorizzazioni. + Impostazioni app + + Autorizzazioni per WiFi SSID + Per poter accedere al nome dell\'attuale nome del WIFI (SSID), devono essere soddfsfatte queste condizioni: + Autorizzazione precisa della localizzazione + Garantire l\'autorizzazione della posizione + Negare l\'autorizzazione della posizione + Autorizzazione della posizione in background + Permettere sempre + Permessi di localizzazione impostati a: %s + Permessi di localizzazione non impostati a: %s + Posizione sempre disabilitata + Servizio di posizione abiltato + Servizio di posizione disabilitato + + Traduzioni + Librerie + Versione %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e contibutori + Il programma è distribuito SENZA ALCUNA GARANZIA. È software libero e può essere redistribuito sotto alcune condizioni. + + Impossibile creare il file di log + Adesso l\'accesso all\' %s delle attività + Visualizza/condividi + Disabilita + + CalDAV/CardDAV adattatore di sincronizzazione + Informazioni / Licenza + Feedback sulla beta + Installare un browser Web + Impostazioni + Notizie & aggiornamenti + Strumenti + Link esterni + Sito web + Manuale + Domande Frequenti + Comunità + Supporta il progetto + Come contribuire + Politica sulla riservatezza + Sincronizzazione di tutti gli account + + Notifiche non attive. Non sarai avvisato di eventuali errori di sincronizzazione + Gestione connessioni + Risparmio dati attivo. La sincronizzazione in background è limitata, + Risparmio energetico attivo. La sincronizzazione in background è limitata, + Gestisci risparmio energetico + Spazio di memorizzazione scarso. Androin non salverà immediatamente i cambiamente, ma alla prossima sincronizzazione programmata. + Gestisci spazio di memorizzazione + + Impossibile trovare il servizio + Impossibile aggiornare la lista delle raccolte + + Esecuzione in primo piano + Su alcuni dispositivi, questo è necessario per la sincronizzazione automatica. + + Impostazioni + Debug + Mostra informazioni di debug + Log completo + Log disabilitato + Ottimizzazione batteria + Connessione + Tipo di proxy + + Predefinito di sistema + Nessun proxy + HTTP + SOCKS (per Orbot) + + Nome host proxy + Porta proxy + Sicurezza + Autorizzazioni app + Controlla le autorizzazioni per la sincronizzazione + Non ti fidare dei certificati di sistema + Le CA di sistema e quelle aggiunte dall\'utente non sono affidabili + Le CA di sistema e quelle aggiunte dall\'utente sono affidabili (raccomandato) + Reimposta la fiducia in tutti i certificati + Reimposta la fiducia nei certificati aggiunti + Sono stati cancellati tutti i certificati aggiunti + Interfaccia utente + Impostazioni di notifica + Gestisci i canali di notifica e le loro impostazioni + Seleziona il tema + + Sistema predefinito + Luce + Buio + + Reimposta i suggerimenti + Riabilita i suggerimenti precedentemente disabilitati + I suggerimenti verranno mostrati + Integrazione + Funzioni dell\'applicazione + Nessuna applicazione compatibile con e funzionalità trovata + + CardDAV + CalDAV + Webcal + Per sincronizzare questi dati sono richiesti permessi aggiuntivi. + Gestisci permessi + Sincronizza adesso + Impostazioni account + Rinomina account + Dati locali non salvati potrebbero venir persi. Dopo il cambio nome è necessaria la ri-sincronizzazione. + Nuovo nome account + Rinomina + Nome account già usato + Impossibile rinominare l\'account + Elimina account + Cancellare l\'account? + Tutte le copie locali delle rubriche, dei calendari e degli elenchi attività verranno eliminate. + Sincronizza questa raccolta + sola lettura + calendario + contatti + diario + attività + Mostra solo personale + Aggiorna lista + Sottoscrizioni al Webcal possono essere sincronizzate con applicazioni esterne. + Non ho trovato nessuna applicazione abilitata per Webcal + Installa ICSx⁵ + + Aggiungi account + Login generico + Login del Provider + Continua + Login + Accedi con indirizzo email + Indirizzo email + È necessario un indirizzo email valido + I servizi sono individuati usando record DNS e le URL well-known.]]> + Password + Nascondi password + Mostra password + Accedi con URL e nome utente + Nome utente + Base URL + i servizi sono individuati anche usando record DNS records e le URL well-known.]]> + Seleziona certificato + Aggiungi account + Nome account + L\'uso degli apostrofi (\') potrebbe causare problemi su alcuni dispositivi. + Inserisci il tuo indirizzo email come nome dell\'account in quanto Android userà il nome dell\'account nel campo ORGANIZER degli eventi creati. Non è possibile avere due account con nome uguale. + Metodo del contact group: + Richiesto il nome dell\'account + Nome account già usato + Login avanzato + Certificato client: %s + Nessun certificato trovato + Installa il certificato + Contatti Google / Calendario + Account Google + Accedi con Google + ID Client (facoltativo) + Google API Services User Data Policy, incluso il Limited Use requirements.]]> + Non posso ottenere il codice di autorizzazione + Nextcloud + Accedi con Nextcloud + Questo aprirà la pagina di login di Nextcloud nel browser. + Indirizzo del server Nextcloud + Iscriviti + Non posso ottenere l\'URL di login + Non posso ottenere i dati di login + Rilevazione configurazione + Attendere, invio richiesta al server… + Impossibile trovare servizi CalDAV o CardDAV. + L\'URL base non sembra essere un URL CalDAV/CardDAV accessibile e i servizi di individuazione hanno fallito. + Controlla attentamente i dati di autenticazione (normalmente username e password). + Informazioni tecniche aggiuntive sono reperibili nei log. + Vedi i registri + + Sincronizzazione + Intervallo sincr. Contatti + Solo manualmente + Ogni %d minuti e a seguito di ogni cambiamento locale + Intervallo sincr. calendari + Intervallo sincr. attività + + Solo manualmente + Ogni 15 minuti + Ogni 30 minuti + Ogni ora + Ogni 2 ore + Ogni 4 ore + Una volta al giorno + + Sincr. solo tramite WiFi + La sincronizzazione è limitata alle connessioni WiFi + Il tipo di connessione non è preso in considerazione + Restrizione SSID WiFi + Sincronizzeremo solo oltre %s + Verranno utilizzate tutte le connessioni WIFI + Nomi (SSID) delle reti WiFi autorizzate separati da virgola (lascia vuoto per autorizzarle tutte) + Le restrizioni del SSID WIFI richiedono ulteriori impostazioni + Riuscire + La VPN richiede connessione internet + La VPN senza una connessione internet validata non è sufficiente per lanciare la sincronizzazione (raccomandato) + La VPN senza una connessione internet validata è sufficiente per lanciare la sincronizzazione + Autenticazione + Nome utente + Nuova password + Aggiorna la password come sul tuo server. + Certificato client + Nessun certificato disponibile o selezionato + Installa il certificato + CalDAV + Limite di tempo per gli eventi trascorsi + Verranno sincronizzati tutti gli eventi + + Eventi più vecchi di un giorno saranno ignorati + Eventi più vecchi di %d giorni saranno ignorati + Eventi più vecchi di %d giorni saranno ignorati + + Eventi più vecchi di questo numero di giorni verranno ignorati(può anche essere 0). Lasciare in bianco per sincronizzare tutti gli eventi. + Promemoria predefinito + + Promemoria predefinito un minuto prima dell\'evento + Promemoria predefinito %d minuti prima dell\'evento + Promemoria predefinito %d minuti prima dell\'evento + + Nessun promemoria di default creato + Indicare il numero di minuti che si desidera per il promemoria predefinito. +Lasciare vuoto per non creare un promemoria predefinito. + Cambia il colore del calendario + I colori del calendario sono resettati ad ogni sincronizzazione + I colori del calendario possono essere scelti da altre applicazioni + Supporto colore dell\'evento + I colori degli eventi sono sincronizzati + I colori degli eventi non sono sicnronizzati + CardDAV + Organizzazione dei gruppi di contatto + + I gruppi sono vCards separate + I gruppi sono categorie per ogni contatto + + + Crea rubrica + La creazione di rubriche tramitte CardDAV potrebbe non essere supportata dal server. + Crea calendario + + Possibili voci del calendario + Eventi + Attività + Note / diario + La creazione do calendari tramite CalDAV potrebbe non essere supportata dal server. + Colore + Titolo + Percorso di archiviazione + Crea + + contatti + attività + Elimina raccolta + Questa raccolta (%s) e tutti i suoi dati saranno rimossi definitivamente, sia localmente che sul server. + Sincronizzazione + Sincronizzazione attivata + Sincronizzazione disattivata + Sola lettura + Sola lettura (dal server) + Sola lettura (locale) + Lettura/scrittura + Titolo + Descrizione + Proprietario + Supporto push + Ultima sincronizzazione %s + Indirizzo (URL) + + Informazioni di debug + Archivio ZIP + Contiene informazioni sui debug e sugli accessi + Condividi l\'archivio per trasferirlo ad un computer, per inviarlo tramite email o per fissarlo ad un ticket di supporto. + Condividi l\'archivio + Informazioni sul debug fissate a questo messaggio (richiede un supporto di fissaggio dell\'applicazione di supporto). + Errore HTTP + Errore del Server + Errore WebDAV + Errore I/O + Vedi dettagli + Sono state raccolte informazioni di debug + Fonti coinvolte + Collegate con il problema + Fonti remote: + Fonti locali: + Registri + Sono disponibili registri verbali + Vedi i registri + Copia URL + + Si è verificato un errore. + Si è verificato un errore HTTP. + Si è verificato un errore di I/O. + Mostra dettagli + + Installazioni WebDAV + Quantità utilizzata: %1$s / disponibile: %2$s + Condividi i contenuti + Disinstallazioni + Aggiungi installazioni WedDAV + Accedi direttamente ai tuoi file nel cloud aggiungendo un supporto WebDAV! + Nome del display + URL WebDVA + URL non valido + Autenticazione + Nome utente + Password + Aggiungi installazioni + Nessun servizio WebDAV a questo URL + Rimuovi punto di mont + I dettagli della connessione saranno perduti, ma nessun file verrà cancellato. + File di accesso WebDAV + File di download WebDAV + Caricare file WebDAV + Installazione WebDAV + + Autorizzazioni DAVx⁵ + Autorizzazioni addizionali richieste + %s troppo vecchio + Versione minima richiesta %1$s + Autenticazione fallita (controlla credenziali login) + Errore di rete o di I/O – %s + Errore server HTTP – %s + Errore di archiviazione locale – %s + Contatto non valido ricevuto dal server + Evento non valido ricevuto dal server + Attività non valida ricevuta dal server + Una o più risorse non valide ignorate + + Sincronizza tutto + Sincronizzazione di tutti gli account + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..48fcdbe --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,478 @@ + + + + アカウントがありません + DAVx⁵ アドレス帳 + ここでアカウントを変更しないでください! アカウントの管理は、アプリから直接行ってください。 + 削除 + 削除 + キャンセル + 有効 + このフィールドは入力必須です + ヘルプ + 上に移動 + オプションメニュー + 共有 + 同期中/待機中 + データベースが破損しています + すべてのアカウントがローカルから削除されました + デバッグ中 + 他の重要なメッセージ + 優先度の低いステータスメッセージ + 同期 + 同期エラー + 予期しないサーバーの応答のような、同期を停止させる重要なエラー + 同期の警告 + 特定の無効なファイルのような、致命的ではない同期の問題 + ネットワークおよび I/O エラー + タイムアウト、接続の問題など (多くの場合、一時的なもの) + + あなたのデータはあなたの手に + コントロールを始める + 一定の間隔で同期する + 一定の間隔で同期するには、%s のバックグラウンドでの動作を許可する必要があります。許可しない場合、Android によって同期が停止されることがあります。 + 一定間隔での同期は不要です* + %s 適合性 + 端末の製造元のファームウェアによって同期がブロックされている可能性があります。この問題に遭遇した場合、手動で問題を解決していただく必要があります。 + 必要な設定は完了したのでリマインダーは不要です* + * 未チェックのままにすると後でリマインドします。アプリの設定 / %s でリセットできます + 追加情報 + jtx Board + + ToDo リストの同期に対応 + お使いのサーバーが ToDo リストに対応している場合、対応するアプリで同期できます: + OpenTasks + 開発を停止している可能性があります – 非推奨 + Tasks.org + 対応していません]]> + アプリストアが利用できません + ToDo リスト対応は不要です* + オープンソースソフトウェア + オープンソースソフトウェアとして %s をお届けできることをとても嬉しく思っています。開発・維持・サポートは簡単ではありません。貢献 (さまざまな方法があります) や寄付をご検討ください。プロジェクトはそれらに支えられています。 + 貢献/寄付の方法 + 次の期間は通知しない: + + %d か月 + + 次へ + + 許可 + %s を正しく動作させるには、権限を許可してください。 + 以下のすべて + これを有効にすると、すべての機能が使用できるようになります (推奨) + すべての権限が許可されました + 連絡先へのアクセス + 無効のため、連絡先を同期しません (非推奨) + 連絡先を同期できます + カレンダーへのアクセス + 無効のため、カレンダーを同期しません (非推奨) + カレンダーを同期できます + 通知の権限 + 通知が無効になっています (非推奨) + 通知が有効です + jtx Board へのアクセス + OpenTasks へのアクセス + Tasks へのアクセス + 無効のため、ToDo リストを同期しません + ToDo リストを同期できます + 権限を維持する + 無効のため、権限が自動的にリセットされることがあります (非推奨) + 権限は自動でリセットされることはありません + 許可から「アプリが使用されていない場合に権限を削除」を無効化してください + スイッチが機能しない場合、アプリ情報 / 許可 にアクセスしてください + アプリ設定 + + WiFi SSID へのアクセス + 現在の WiFi 名 (SSID) にアクセスするため、これらの条件を満たす必要があります: + 正確な位置情報へのアクセス + 位置情報へのアクセスが許可されています + 位置情報の権限が拒否されています + バックグラウンドでの位置情報へのアクセス + 常に許可 + 位置情報の権限は %s に設定されています + 位置情報の権限は %s に設定されていません + %s は位置情報 (WiFi の SSID のみ) を、特定の WiFi SSID で同期する目的に限定して使用します。同期がバックグラウンドで実行中の場合にも発生します。 + すべての位置情報 (WiFi SSID のみ) はローカルのみで使用され、送信されることはありません。 + 位置情報は常に有効です + 位置情報サービスは有効です + 位置情報サービスは無効です + + 翻訳 + ライブラリー + バージョン %1$s (%2$d) + © Ricki Hirner、Bernhard Stockmann (bitfire web engineering GmbH) と貢献者 + このプログラムは完全に無保証で提供されます。これはフリーソフトウェアで、特定の条件下での再頒布を歓迎します。 + + ログファイルを作成できませんでした + %s のすべてのアクティビティのログを記録しています + 表示/共有 + 無効にする + + CalDAV/CardDAV 同期アダプター + アプリについて / ライセンス + ベータフィードバック + ウェブブラウザをインストールしてください + 設定 + ニュース & アップデート + ツール + 外部リンク + ウェブサイト + マニュアル + FAQ + 組織向け + コミュニティ + プロジェクトを支援 + 貢献する方法 + プライバシーポリシー + DAVx⁵ にようこそ! + サーバーに接続して、カレンダーと連絡先を同期しましょう。 + すべてのアカウントを同期 + + 通知が無効です。同期エラーが発生しても通知されません。 + 自動同期が無効です (有効なインターネット接続がありあせん)。 + 接続を管理 + データサーバーが有効です。バックグラウンド同期が制限されています + データサーバーを管理 + バッテリー最適化機能が有効です。同期が制限される可能性があります。 + バッテリー最適化機能を管理 + ストレージの容量が残りわずかです。Android はローカルの変更を即座に同期しませんが、次の通常サイクルで同期します。 + ストレージを管理 + カレンダープロバイダーが見つかりません + システムアプリ「Calendar storage (カレンダーの保存などと表示)」を無効にしていませんか? + 連絡先プロバイダーが見つかりません + システムアプリ「Contacts storage (連絡帳などと表示)」を無効にしていませんか? + アプリを管理 + + サービスの検出に失敗しました + コレクションリストを再読み込みできませんでした + + フォアグラウンドで実行 + 一部のデバイスでは自動同期にこの設定が必要になります。 + + 設定 + デバッグ + デバッグ情報を表示 + 設定の詳細とログを表示/共有します + 詳細ログ + ログを取得します。デバッグ情報の一部としてログを確認できます。 + ログを取得しません + バッテリー最適化 + アプリは除外されています (推奨) + バッテリー最適化が適用されています (非推奨) + 接続 + プロキシーの種類 + + システムのデフォルト + プロキシーなし + HTTP + SOCKS (Orbot 向け) + + プロキシーのホスト名 + プロキシーのポート番号 + セキュリティ + アプリの権限 + 同期に必要な権限を確認する + システム証明書を無視する + システムとユーザーが追加した CA を信頼しません + システムとユーザーが追加した CA を信頼します (推奨) + この設定が有効な場合、システム証明書は信頼されません。(サーバーが証明書を更新した場合を含めて) すべての証明書を手動で許可する必要があります。手動で許可しない場合、アカウントセットアップと同期は機能しません。 + (未) 信頼証明書をリセット + すべてのカスタム証明書の信頼をリセットします + すべてのカスタム証明書をクリアしました + ユーザーインターフェイス + 通知設定 + 通知チャネルとその設定を管理します + テーマを選択 + + システムのデフォルト + ライト + ダーク + + ヒントをリセット + 以前非表示にしたヒントを再表示します + すべてのヒントを再表示します + 統合 + ToDo リストアプリ + 連携できるアプリがありません + UnifiedPush (試験的) + なし (プッシュを無効にする) + ディストリビューターを選択 + プッシュのディストリビューターがインストールされていません + エンドポイントが設定されていません + %s 経由でプッシュメッセージを受信できます + FCM (Google Play) + プッシュメッセージは常に暗号化されます。 + + アカウントが削除されました + CardDAV + CalDAV + Webcal + これらのコレクションを同期するには、追加の権限が必要です。 + 権限を管理 + 今すぐ同期 + アカウント設定 + アカウントの名前を変更 + 保存していないローカルデータが破棄されることがあります。名前を変更した後にもう一度同期してください。 + 新しいアカウント名 + 名前を変更 + アカウント名はすでに取得されています + アカウントの名前を変更できません + アカウントを削除 + 本当にアカウントを削除しますか? + アドレス帳、カレンダー、ToDo リストのすべてのローカルコピーが削除されます。 + このコレクションを同期 + 読み取り専用 + カレンダー + 連絡先 + ジャーナル + ToDo リスト + プライベートのみ表示する + リストを再読み込み + Webcal の購読は外部アプリで同期できます。 + Webcal に対応するアプリが見つかりませんでした + ICSx⁵ をインストール + + アカウントを追加 + プライバシーポリシー をご確認ください。]]> + 共通のログイン方法 + プロバイダー固有のログイン方法 + 続行 + ログイン + メールアドレスでログイン + メールアドレス + 有効なメールアドレスが必要です + サービスを検出します。]]> + パスワード + パスワードを非表示 + パスワードを表示 + パスワード (オプション) + URL とユーザー名でログイン + ユーザー名 + ユーザー名 (オプション) + ベース URL + サービスの検出には DNS レコードと well-known URL も使用します。]]> + 証明書を選択 + アカウントを追加 + アカウント名 + アポストロフィー「\'」を使用すると一部のデバイスで問題が発生します。 + Android はあなたが作成した予定の ORGANIZER フィールドにアカウント名を使用するので、アカウント名としてメールアドレスを使用してください。同じ名前のアカウントを 2 つ保持することはできません。 + 連絡先グループ方法: + アカウント名が必要です + アカウント名はすでに取得されています + アカウントを追加できませんでした + 完了 + 高度なログイン + クライアント証明書が選択されていません (オプション) + クライアント証明書: %s + 証明書が見つかりませんでした + 証明書をインストール + Fastmail + Fastmail アカウント + Fastmail でログイン + Google コンタクト / カレンダー + Google アカウント + Google でログイン + クライアント ID (オプション) + プライバシーポリシー をご確認ください。]]> + Google API サービスのユーザーデータに関するポリシー に準拠しています。]]> + 認可コードを取得できませんでした + Nextcloud + Nextcloud でログイン + ウェブブラウザーでのログインフローが開始されます。 + Nextcloud サーバーアドレス + サインイン + ログイン URL を入手できませんでした + ログイン情報を入手できませんでした + 設定の検出 + しばらくお待ちください。サーバーに問い合わせ中… + CalDAV または CardDAV サービスが見つかりませんでした。 + ベース URL から CalDAV/CardDAV URL に到達できなかったため、サービス検出に失敗しました。 + 私たちがテストしたサービス一覧 を確認してください。]]> + 認証情報 (ほとんどの場合はユーザー名とパスワード) もご確認ください。 + さらに詳細な技術情報はログで確認できます。 + ログを表示 + + 同期 + 連絡先の同期間隔 + 手動のみ + %d 分ごと + ローカルの変更時はすぐに + カレンダーの同期間隔 + ToDo リストの同期間隔 + + 手動のみ + 15 分ごと + 30 分ごと + 1 時間ごと + 2 時間ごと + 4 時間ごと + 毎日 1 回 + + WiFi のみで同期 + WiFi 接続のみで同期します + 接続の種類は考慮されません + WiFi SSID 制限 + %s のみで同期します + すべての WiFi 接続が使用されます + 利用可能な WiFi ネットワークのカンマ区切りの名前 (SSID) (空白にするとすべて) + WiFi SSID 制限にはさらに設定が必要です + 管理 + インターネット接続のない VPN + 検証されたインターネット接続のない VPN では同期を実行しません (推奨) + 検証されたインターネット接続のない VPN でも同期を実行します + 認証 + ユーザー名 + パスワードまたはアプリパスワード + アプリパスワードを使用しているかもしれません。]]> + 新しいパスワード + ご利用のサーバーに従ってパスワードを更新します。 + もう一度認可する (OAuth) + アクセス権が失効した場合に使用してください + 正常に認可しました + クライアント証明書 + クライアント証明書が使用できないか、選択されていません + 証明書をインストール + CalDAV + 過去の予定の読み込み制限 + すべての予定が同期されます + + %d 日より前の予定は無視されます + + この日数より過去の予定は無視されます (0 も可)。すべての予定を同期するには、空白のままにしてください。 + デフォルトのリマインダー + + デフォルトのリマインダーは予定の %d 分前です + + デフォルトのリマインダーはありません + デフォルトのリマインダーは、リマインダーのない予定に適用されます。希望する分数を入力してください。デフォルトのリマインダーを無効にするには、空白のままにしてください。 + カレンダーの色を管理する + カレンダーの色は同期ごとにリセットされます + カレンダーの色は他のアプリで設定できます + 予定の色に対応する + 予定の色が同期されます + 予定の色は同期されません + CardDAV + 連絡先のグループ方法 + + グループで個別の vCard に分割する + 各連絡先にグループをカテゴリーとして記録する + + + アドレス帳を作成 + サーバーが CardDAV 経由のカレンダー作成に対応していない可能性があります。 + カレンダーを作成 + 既定のタイムゾーン (オプション) + + 可能なカレンダーエントリー + 予定 + ToDo リスト + メモ / ジャーナル + サーバーが CalDAV 経由のカレンダー作成に対応していない可能性があります。 + + タイトル + ストレージの場所 + 説明 (オプション) + 作成 + + 連絡先 + 件の予定 + ToDo リスト + コレクションを削除 + このコレクション (%s) とコレクション内のすべてのデータが、ローカルとサーバーから永久に削除されます。 + 同期 + 同期が有効です + 同期が無効です + 読み取り専用 + 読み取り専用 (サーバー設定) + 読み取り専用 (ポリシーによる) + 読み取り専用 (ローカル設定) + 読み取り/書き込み + タイトル + 説明 + 所有者 + プッシュ対応 + サーバーがプッシュへの対応を通知しています + 購読開始 %1$s、期限 %2$s + 最終同期 (%s) + アドレス (URL) + + デバッグ情報 + ZIP アーカイブ + デバッグ情報とログを含みます + アーカイブをコンピューターに転送、メールで送信、サポートチケットに添付するには共有してください + アーカイブを共有 + デバッグ情報がこのメッセージに添付されています (受信したアプリが添付ファイルに対応している必要があります) + HTTP エラー + サーバーエラー + WebDAV エラー + I/O エラー + リクエストはサーバーにより拒否されました。 + リクエストされたリソースが存在しません。 + サーバーがリクエストされた形式の操作を許可していません。 + サーバー側で問題が発生しました。あなたのサーバーのサポートに連絡してください。 + 予期せぬエラーが発生しました。詳細はデバッグ情報を確認してください。 + 詳細を表示 + 収集されたデバッグ情報 + 関連するリソース + 関係する問題 + リモートリソース: + ローカルリソース: + ログ + 詳細なログが利用できます + ログを表示 + URL をコピー + プライバシー通知 + ログやデバッグ情報はプライベートな情報を含むことがあります。共有する場合には、注意して取り扱ってください。 + + エラーが発生しました + HTTP エラーが発生しました + I/O エラーが発生しました + 詳細を表示 + + WebDAV マウント + 割り当て 使用中: %1$s / 利用可能: %2$s + コンテンツを共有 + マウント解除 + WebDAV マウントを追加 + WebDAV マウントを追加してクラウドファイルに直接アクセスしましょう! + WebDAV マウントの動作についてを確認してください。]]> + 表示名 + WebDAV URL + 無効な URL + マウントポイントと表示名 + 認証 + ユーザー名 + パスワード + ユーザー名 (オプション) + パスワード (オプション) + マウントを追加 + この URL では WebDAV サービスがありません + マウントポイントを削除 + 接続の詳細が失われました。ファイルは削除されていません。 + WebDAV ファイルにアクセスしています + WebDAV ファイルをダウンロードしています + WebDAV ファイルをアップロードしています + WebDAV マウント + + DAVx⁵ の権限 + アクセス権限の許可が必要です + %s が古すぎます + 最小要求バージョン: %1$s + 認証に失敗しました (ログイン情報を確認してください) + ネットワークまたは I/O エラー – %s + HTTP サーバーエラー – %s + ローカルストレージエラー – %s + ソフトエラー (再試行回数の上限に到達) + サーバーから無効な連絡先を受信しました + サーバーから無効な予定を受信しました + サーバーから無効な ToDo リストを受信しました + 1 件または複数の無効なリソースを無視します + 同期が中断されました + リモートの情報が変更されました + + すべて同期 + すべてのアカウントを同期 + 同期ボタン (ラベル) + 同期ボタン (アイコン) + 手動で同期したいときにタップしてください。 + + diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..39bf4d7 --- /dev/null +++ b/app/src/main/res/values-ka/strings.xml @@ -0,0 +1,413 @@ + + + + ანგარიში (აღარ) არსებობს + DAVx⁵ მისამართთა წიგნაკი + წაშლა + ამოშლა + გაუქმება + ეს ველი სავალდებულოა + დახმარება + ზემოთ გადასვლა + ოპციების მენიუ + გაზიარება + სინქრონიზაცია დაიწყა/დადგა რიგში + მონაცემთა ბაზა კორუმპირებულია + ყველა ანგარიში წაშლილ იქნა ადგილობრივად. + დებაგი + სხვა მნიშვნელოვანი შეტყობინებები + დაბალი პრიორიტეტის სტატუსის შეტყობინებები + სინქრონიზაცია + სინქრონიზაციის შეცდომები + მნიშვნელოვანი შეცდომები, რომლებიც აჩერებს სინქრონიზაციას, მაგ., მოულოდნელი სერვერის პასუხები + სინქრონიზაციის გაფრთხილებები + არა-ლეტალური სინქრონიზაციის პრობლემები, როგორც ზოგი არასწორი ფაილი + ქსელის ან ჩაწერა/წაკითხვის შეცდომები + ვადის გასვლა, კავშირის პრობლემები, სხვა (ხშირად დროებითი) + + თქვენი მონაცემები. თქვენი არჩევანი. + აიღეთ კონტროლი. + რეგულარული სინქრონიზაციის ინტერვალები + რეგულარული ინტერვალი სინქრონიზაციისთვის, %s-ს უნდა ჰქონდეს უფლება გაეშვას ფონურ რეჟიმში. სხვაგვარად, Android-მა შეიძლება ნებისმიერ მომენტში შეაჩეროს სინქრონიზაცია. + მე არ მჭირდება რეგულარული სინქრონიზაციის ინტერვალები.* + %s თავსებადობა + მე შევცვალე საჭირო პარამეტრები. აღარ შემახსენოთ.* + * დატოვეთ მოუნიშნელად მოგვიანებით შესახსენებლად. შეიძლება ჩამოგდებულ იქნას აპის პარამეტრებში /%s. + მეტი ინფორმაცია + jtx Board + + დავალებების მხარდაჭერა + თუ დავალებები მხარდაჭერილია თქვენი სერვერის მიერ, მათი სინქრონიზირება შეიძლება მხარდაჭერილი დავალებათა აპით: + OpenTasks + აღარ მიმდინარეობს განვითარება - არ არის რეკომენდებული. + Tasks.org + აპების მაღაზია ხელმიუწვდომია + მე არ მჭირდება დავალებების მხარდაჭერა.* + ღია კოდის პროგრამული უზრუნველყოფა + კმაყოფილები ვართ, რომ იყენებთ %s-ს, რომელიც ღია კოდის პროგრამული უზრუნველყოფაა. განვითარება და მხარდაჭერა რთული სამუშაო. გთხოვთ, გაითვალისწინოთ წილის შეტანა (მრავალი გზა არსებობს) ან ფულის ჩუქბეა. ძალიან მადლობელი ვიქნებით! + როგორ შევიტანო წვლილი/დაგეხმაროთ + + უფლებები + %s-ს სჭირდება უფლებები სწორად სამუშაოდ. + ყველა ქვემოთ მოცემული + გამოიყენეთ ეს ყველა ფუნქციის ჩასართავად (რეკომენდებული) + ყველა უფლება დართულია + კონტაქტების უფლებები + კონტაქტის სინქრონიზაციის გარეშე (არა რეკომენდებული) + კონტაქტის სინქრონიზაცია შესაძლებელია + კალენდარის უფლებები + კალენდარის სინქრონიზაციის გარეშე (არა რეკომენდებული) + კალენდარის სინქრონიზაცია შესაძლებელია + შეტყობინებების უფლება + შეტყობინებები გათიშულია (არა რეკომენდებული) + შეტყობინებები ჩართლია + jtx Board-ის უფლებები + OpenTasks-ის უფლებები + დავალებების უფლებები + დავალებების სინქრონიზაციის გარეშე + დავალებების სინქრონიზაცია შესაძლებელია + Keep-ის უფლებები + უფლებები შეიძლება ავტომატურად ჩამოიყაროს (არა რეკომენდებული) + უფლებები ავტომატურად არ ჩამოიყრება + შეამოწმეთ უფლებები > მოხსენით \"უფლებების ამოშლა, თუ აპი არ გამოიყენება\"-ს მონიშვნა + თუ გადამრთველი არ მუშაობს, გამოიყენეთ აპის პარამეტრები / უფლებები. + აპის პარამეტრები + + WiFi SSID-ს უფლებები + რათა მიწვდეთ მიმდინარე WiFi-ს სახელს (SSID), ეს პირობები უნდა შესრულდეს: + ზუსტი ადგილმდებარეობის უფლება + ადგილმდებარეობის უფლება დართულია + ადგილმდებარეობის უფლება უარყოფილია + ფონური ადგილმდებარეობის უფლება + ყოველთვის დაშვება + ადგილმდებარეობის უფლების მნიშვნელობა: %s + ადგილმდებარეობის უფლება არ არის შემდეგი: %s + ადგილმდებარეობა ყოველთვის ჩართულია + ადგილმდებარეობის სერვისი ჩართულია + ადგილმდებარეობის სერვისი გათიშულია + + თარგმანი + ბიბლიოთეკები + ვერსია %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) და მონაწილეები + ამ პროგრამას არ აქვს არანაირი გარანტია. იგი არის უფასო პროგრამული უზრუნველყოფა, ხოლო თქვენ შეგეძლეიათ იგი გაავრცელოთ გარკვეული პირობების გათვალისწინებით. + + ჟურნალის ფაილი ვერ შეიქმნა + აწი მიმდინარეობს ყველა %s აქტივობის ჟურნალში ჩაწერა + ნახვა/გაზიარება + გათიშვა + + CalDAV/CardDAV სინქრონიზაციის ადაპტერი + შესახებ / ლიცენზია + ბეტას უკუკავშირი + გთხოვთ, დააყენოთ ვებ ბრაუზერი + პარამეტრები + ახალი ამბები & განახლებები + ხელსაწყოები + გარე ბმულები + ვებ საიტი + ინსტრუქცია + ხდკ + საზოგადოება + პროექტის მხარდაჭერა + როგორ შევიტანო ღვაწლი + პირადულობის პოლიტიკა + ყველა ანგარიშის სინქრონიზაცია + + შეტყობინებები გათიშული. თქვენ არ მიიღებთ შეტყობინებებს სიქნრონიზაციის შეცდომების შესახებ. + კავშირების მართვა + გააქტიურებულია მონაცემთა შემნახველი. ფონური სინქრონიზაცია შეზღუდულია. + მონაცემთა შემნახველის მართვა + გფააქტიურებულია კვების ელემენტის შემნახველი. სინქრონიზაცია შეიძლება შეზღუდულ იქნას. + კვების ელემენტის შემნახველის მართვა + მეხსიერება ცოტა დარჩა. Android არ დაასინქრონიზირებს ადგილობრივ ცვლილებებს დაუყონებლივ, ხოლო დაასინქრონიზირებს შემდეგი რეგულარული სინქრონიზაციის დროს. + მეხსიერების მართვა + + სერვისის აღმოჩენა ჩაიშალა + კოლექციათა სიის განახლება ვერ მოხერხდა + + მუშაობს ფონში + ზოგ მოწყობილობაზე, ეს საჭიროა ავტომატური სინქრონიზაციისთვის. + + პარამეტრები + დებაგი + დებაგის ინფორმაციის ჩვენება + დეტალური ჟურნალში ჩაწერა + ჟურნალში ჩაწერა გათიშულია + კვების ელემენტის ოპტიმიზაცია + აპი გამორიცხულია (რეკომენდებულია) + გამოიყენება კვების ელემენტის შეზღუდვები (არა რეკომენდებულია) + კავშირი + პროქსის ტიპი + + ნაგულისხმევი სისტემის მიერ + პროქსის გარეშე + HTTP3 + SOCKS (Orbot-სთვის) + + პროქსის ჰოსტის სახელი + პროქსის პორტი + უსაფრთხოება + აპის ეფლებები + გადახედეთ სინქრონიზაციისთვის საჭირო ეფლებებს + სისტემური სერთიფიკატების ნდობის გაუქმება + სისტემური და მომხმარებლის მიერ დამატებული სერთიფიცირების ავტორიტეტების ნდობა არ იქნება + სისტემური და მომხმარებლის მიერ დამატებული სერთიფიცირების ავტორიტეტების ნდობა იქნება (რეკომენდებული) + (არა) ნდობითი სერთიფიკატების ჩამოყრა + ნდობის ჩამოყრა ყველა კერძო სერთიფიკატზე + ყველა კერძო სერთიფიკატი გასუფთავდა + მომხმარებლის ინტერფეისი + შეტყობინებების პარამეტრები + შეტყობინებების არხების და პარამეტრების მართვა + აირჩიეთ თემა + + სისტემის მიერ ნაგულისხმევი + ღია + მუქი + + მითითებების ჩამოყრა + თავიდან ააქტიურებს მითითებებს, რომლებიც დამალულ იქნა წარსულში + ყველა მითითება თავიდან იქნება ნაჩვენები + ინტეგრაცია + დავალებათა აპი + თავსებადი დავალებათა აპი ვერ მოიძებნა + + CardDAV + CalDAV + Webcal + საჭიროა დამატებითი უფლებები ამ კოლექციების სინქრონიზაციისთვის. + უფლებების მართვა + ახლავე სინქრონიზირება + ანგარიშის პარამეტრები + ანგარიშის სახელის შეცვლა + შეუნახავი ადგილობრივი მონაცემები შეიძლება გაუქმებულ იქნას. საჭიროა თავიდან სინქრონიზირება სახელის შეცვლის შემდეგ. + ახალი ანგარიშის სახელი + სახელის შეცვლა + ანგარიშის სახელი უკვე დაკავებულია + ანგარიშის სახელის შეცვლა ვერ მოხერხდა + ანგარიშის წაშლა + მართლა წაიშალოს ანგარიში? + წაიშლება მისამართთა წიგნაკების, კალენდრების და დავალებათა სიების ყველა ადგილობრივი ასლი. + ამ კოლექციის სინქრონიზირება + მხოლოდ წაკითხვადი + კალენდარი + კონტაქტები + ჟურნალი + დავალებები + მხოლოდ პირადის ჩვენება + სიის განახლება + Webcal გამოწერები შეიძ₾ება სინქრონიზირებულ იქნას გარე აპებთან. + Webcal-თან თავსებადი აპი ვერ მოიძებნა + ICSx⁵-ს დაყენება + + ანგარიშის დამატება + ზოგადი შესვლა + პროვაიდერის შესვლა + გაგრძელება + შესვლა + ელ. ფოსტის მისამართით შესვლა + ელ. ფოსტის მისამართი + საჭიროა სწორი ელ. ფოსტის მისამართი + აღმოჩენილია სერვისები DNS ჩანაწერების და კარგად ცნობილი URL-ების მეშვეობით.]]> + პაროლი + პაროლის დამალვა + პაროლის ჩვენება + URL-ით და მომხმარებლის სახელით შესვლა + მომხმარებლის სახელი + საბაზო URL + ასევე აღმოჩენილია სერვისები DNS ჩანაწერების და კარგად ცნობილი URL-ების მეშვეობით.]]> + სერტიფიკატის არჩევა + ანგარიშის დამატება + ანგარიშის სახელი + აპოსტროფების (\') გამოყენება იწვევს პრობლემებს ზოგ მოწყობილობაზე. + გამოიყენეთ თქვენი ელ. ფოსტის მსიამართი ანგარიშის სახელად, რადგან Android გამოიყენებს ანგარიშის სახელს ორგანიზატორის ველში თქვენს მიერ შექმნილ ღონისძიებებისთვის. თქვენ არ შეიძლება გქონდეთ ორი ანგარიში იგივე სახელით. + კონტაქტების დაჯგუფების მეთოდი: + საჭიროა ანგარიშის სახელი + ანგარიშის სახელი უკვე დაკავებულია + გაფართოებული შესვლა + კლიენტის სერტიფიკატი: %s + სერტიფიკატი ვერ მოიძებნა + სერტიფიკატის დაყენება + Google კონტაქტები / კალენდარი + Google ანგარიში + Google-ით შესვლა + კლიენტის ID (aრასავალდებულო) + პირადულობის პოლიტიკა დეტალებისთვის.]]> + Google API სერვისების მომხმარებელთა მონაცემების პოლიტიკას, მათ შორის, შეზღუდული გამოყენების მოთხოვნებს.]]> + ავტორიზაციის კოდის მიღება ვერ მოხერხდა + Nextcloud + შესვლა Nextcloud-ისთ + ეს დაიწყებს Nextcloud-ის შესვლის პროცესს ვებ ბრაუზერში. + Nextcloud-ის სერვერის მისამართი + შესვლა + შესვლის URL-ის მიღება ვერ მოხერხდა + შესვლის მონაცემების მიღება ვერ მოხერხდა + კონფიგურაციის აღმოჩენა + გთხოვთ, დაელოდოთ, მიმდინარეობს სერვერის გამოკითხვა... + CalDAV-ის ან CardDAV-ის სერვისის მოძებნა ვერ მოხერხდა. + საბაზო URL არ არის წვდომადი CalDAV/CardDAV URL და სერვერისის აღმოჩენა არ იყო წარმატებული. + ჩვენს მიერ ტესტირებული სერვისების სია და მათი საბაზო URL.]]> + გთხოვთ, ასევე გადაამოწმოთ აუთენტიფიკაცია (ზოგადად, მომხმარებლის სახელი დაპაროლი). + დამატებითი ტექნიკური ინფორმაცია ხელმისაწვდომია ჟურნალებში. + ჟურნალების ნახვა + + სინქრონიზაცია + კონტაქტების სინქრონიზაციის ინტერვალი + მხოლოდ ხელით + ყოველ %d წუთში + დაუყონებლივ ადგილობრივი ცვლილებებისას + კალენდრების სინქრონიზაციის ინტერვალი + დავალებვათა სინქრონიზაციის ინტერვალი + + მხოლოდ ხელით + ყოველ 15 წუთში + ყოველ 30 წუთში + ყოველ 1 საათში + ყოველ 2 საათში + ყოველ 4 საათში + ყოველდღე + + მხოლოდ WiFi-ით სინქრონიზაცია + სინქრონიზაცია შეზღუდულია WiFi კავშირზე + კავშირის ტიპი არ გაითვალისწინება + WiFi SSID-ს შეზღუდვა + დასინქრონიზირდება მხოლო %s-ით + გამოიყენება ყველა WiFi კავშირი + დაშვებული WiFi ქსელების მძიმეთი დაყოფილი სახელები (SSID) (დატოვეთ ცარიელად ყველასთვის) + WiFi SSID-ს შეზღუდვას სჭირდება დამატებითი პარამეტრები + მართვა + VPN-ს სჭირდება არსებული ინტერნეტ-კავშირი + VPN არსებული დადასტურებული ინტერნეტ-კავშირის გარეშე არ არის საკმარისი სინქრონიზაციის გასაშვებად (რეკომენდებული) + VPN არსებული დადასტურებული ინტერნეტ-კავშირის გარეშე არ არის საკმარისი სინქრონიზაციის გასაშვებად + აუთენტიფიკაცია + მომხმარებლის სახელი + ახალი პაროლი + პაროლის განახლება თქვენი სერვერის მიხედვით + კლიენტის სერთიფიკატი + სერთიფიკატი ხელმიუწვდომია ან არ არის არჩეული + სერტიფიკატის დაყენება + CalDAV + გასული ღონისძიების დროის შეზღუდვა + დასინქრონიზირდება ყველა ღონისძიება + + ერთ დღეზე უფრო ძველი ღონისძიებები იქნება იგნორირებული + %d დღეზე უფრო ძველი ღონისძიებები იქნება იგნორირებული + + ღონისძიებები, რომლებიც უფრო ძველია, ვიდრე დღეთა მითითებული რაოდენობა, იქნება იგნორირებული (შეიძლება იყოს 0). დატოვეთ ცარიელად ყველას სინქრონიზებისთვის. + ნაგულისხმევა შეხსენება + + ნაგულისხმევი შეხსენება ღონისძიებამდე ერთი წუთით ადრე + ნაგულისხმევი შეხსენება ღონისძიებამდე %d წუთით ადრე + + ნაგულისხმევი შეხსენება არ არის შექმნილი + თუ ნაგულისხმევი შეხსენება უნდა შეიქმნას შეხსენების გარეშე ღონისძიებებისთვის: ღონისძიებამდე წუთების სასურველი რიცხვი. დატოვეთ ცარიელად ნაგულისხმევი შეხსენებების გასათიშად. + კალენდარის ფერების მართვა + კალენდარის ფერები ჩამოიყრება ყოველ სინქრონიზაციაზე + კალენდარის ფერები შეიძლება დაყენებულ იქნას სხვა აპების მიერ + ღონისძიების ფერის მხარდაჭერა + ღონისძიების ფერები არის სინქრონიზირებული + ღონისძიების ფერები არ არის სინქრონიზირებული + CardDAV + კონტაქტების დაჯგუფების მეთოდი + + ჯგუფები ცალკე vCard-ებია + ჯგუფები არის კონტაქტთა კატეგორია + + + მისამართთა წიგნაკის შექმნა + მისამართთა წიგნაკის შექმნა CardDAV-ით შეიძლება არ იყოს მხარდაჭერილი სერვერის მიერ. + კალენდარის შექმნა + + დაშვებული კალენდარის ჩანაწერები + ღონისძიებები + დავალებები + შენიშვნები / ჟურნალი + კალენდრის შექმნა CalDAV-ით შეიძლება არ იყოს მხარდაჭერილი სერვერის მიერ. + ფერი + სათაური + მეხსიერების ადგილმდებარეობა + აღწერა (არასავალდებულო) + შექმნა + + კონტაქტები + დავალებები + კოლექციის წაშლა + ეს კოლექცია (%s) და მისი ყველა მონაცემი სამუდამოდ წაიშლება, როგორც ადგილობრივად, ისე სერვერზეც. + სინქრონიზაცია + სინქრონიზაცია ჩართულია + სინქრონიზაცია გამორთულია + მხოლოდ წაკითხვადი + მხოლოდ წაკითხვადი (სერვერის მიერ) + მხოლოდ წაკითხვადი (მხოლოდ ადგილობრივად) + წაკითხვა/ჩაწერა + სათაური + აღწერა + მფლობელი + Push-ის მხარდაჭერა + სერვერი გადმოსცემს Push-ის მხარდაჭერას + ბოლო სინქრონიზაცია (%s) + მისამართი (URL) + + დებაგის ინფო + ZIP არქივი + შეიცავს დებაგის ინფოს და ჟურნალებს + გააზიარეთ არქივი მისი კომპიუტერზე გადასაგზავნად, ელ. ფოსტით გასაგზავნად ან მისი მხარდაჭერის ბილეთზე მისაბმელად. + არქივის გაზიარება + დებაგის ინფო მიბმულია ამ შეტყობინებაზე (სჭირდება მიბმის მხარდაჭერა მიმღებ აპში). + HTTP შეცდომა + სერვერის შეცდომა + WebDAV შეცდომა + წაკითხვა/ჩაწერის შეცდომა + დეტალების ნახვა + დებაგის ინფო შეგროვდა + შესაბამისი რესურსები + დაკავშირებული პრობლემასთან + დაშორებული რესურსი: + ადგილობრივი რესურსი: + ჟურნალები + ხელმისაწვდომია დეტალური ჟურნალები + ჟურნალების ნახვა + + მოხდა შეცდომა. + მოხდა HTTP შეცდომა. + მოხდა წაკითხვა/ჩაწერის შეცდომა. + დეტალების ჩვენება. + + WebDAV-ის მიბმები + გამოყენებული კვოტა: %1$s / ხელმისაწვდომი: %2$s + შიგთავსის გაზიარება + მიბმის გათიშვა + WebDAV-ის მიბმის დამატება + პირდაპირ იქონიეთ წვდომა თქვენი ღრუბლის ფაილებზე WebDAV-ის მიბმის დამატებით! + ნაჩვენები სახელი + WebDAV URL + არასწორი URL + აუთენტიფიკაცია + მომხმარებლის სახელი + პაროლი + მიბმის დამატება + WebDAV სერვისი ამ URL-ზე არ არის + მიბმის წერტილის ამოშლა + კავშირის დეტალები დაიკარგება, მაგრამ ფაილები არ წაიშლება. + მიმდინარეობს WebDAV ფაილზე წვდომა + მიმდინარეობს WebDAV ფაილის გადმოტვირთვა + მიმდინარეობს WebDAV ფაილის ატვირთვა + WebDAV-iს მიბმა + + DAVx⁵-ის უფლებები + საჭიროა დამატებითი უფლებები + %s ნამეტანი ძველია + მინიმალური საჭირო ვერსია: %1$s + აუთენტიფიკაცია ჩაიშალა (შეამოწმეთ შევლის იდენტიფიკატორები) + ქსელური ან ჩაწერა/წაკითხვის შეცდომა - %s + HTTP სერვერის შეცდომა - %s + ადგილობრივი მეხსიერების შეცდომა - %s + რბილის შეცდომა (მიღწეულია თავიდან ცდის მაწსიმუმი) + მიღებულია არასწორი კონტაქტი სერვერიდან + მიღებულია არასწორი ღონისძიება სერვერიდან + მიღებული არასწორი დავალება სერვერიდან + ერთი ან მეტი არასწორი რესურსის იგნორირება + + ყველაფრის სინქრონიზირება + ყველა ანგარიშის სინქრონიზაცია + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..5817875 --- /dev/null +++ b/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,462 @@ + + + + 계정이 (더이상) 존재하지 않음 + DAVx⁵ 주소록 + 여기에서 계정을 변경하지 마세요. 앱에서 직접 계정을 관리해 주세요. + 삭제 + 제거 + 취소 + 활성 + 이 항목은 필수입니다 + 도움말 + 상위 항목으로 이동 + 옵션 메뉴 + 공유 + 동기화 시작/대기열에 추가 + 데이터베이스 손상 + 모든 계정이 로컬에서 제거되었습니다. + 디버깅 + 기타 중요한 메시지 + 우선순위가 낮은 메시지 + 동기화 + 동기화 오류 + 예기치 않은 서버 응답과 같이 동기화를 중지시키는 중요한 오류 + 동기화 경고 + 일부 잘못된 파일과 같이 심각하지 않은 동기화 문제 + 네트워크 및 I/O 에러 + 시간 초과, 연결 문제 등 (주로 일시적인 문제) + + 당신의 데이터. 당신의 선택. + 관리. + 정기적 동기화 주기 + 정기적으로 동기화하려면 백그라운드에서 %s이 실행되도록 허용해야 합니다. 그렇지 않으면 Android는 언제든지 동기화를 일시 중지할 수 있습니다. + 나는 정기적인 동기화 주기가 필요하지 않다.* + %s 호환성 + 필요한 설정을 완료했습니다. 더 이상 다시 수행하지 마세요.* + * 나중에 알림이 표시되도록 선택 해제된 상태로 둡니다. / 앱 설정에서 재설정할 수 있습니다. / %s + 상세 정보 + jtx Board + + 작업 지원 + 서버에서 작업을 지원하는 경우 지원되는 작업 앱과 동기화할 수 있습니다. + OpenTasks + 더 이상 개발되지 않는 것 같으니 추천하지 않습니다. + Tasks.org + 일부 기능이 지원되지 않습니다.]]> + 사용 가능한 앱 스토어 없음 + 업무 지원은 필요 없습니다.* + 오픈 소스 소프트웨어 + 오픈 소스 소프트웨어인 %s를 사용해 주셔서 기쁩니다. 개발, 유지보수 및 지원은 힘든 작업입니다. (여러 방식의) 기여나 기부를 고려해 보세요. 정말 고마울 것 같습니다. + 기여/기부 방법 + 한동안 알리지 않기 + + %d 개월 + + 다음 + + 권한 + %s이 제대로 작동하려면 사용 권한이 필요합니다 + 아래의 모든 것 + 이를 통해 모든 기능을 사용할 수 있습니다(권장). + 모든 권한 부여 + 연락처 권한 + 연락처 동기화 하지않음 (권장하지 않음) + 연락처 동기화 가능 + 캘린더 권한 + 캘린더 동기화 없음(권장하지 않음) + 캘린더를 동기화 할 수 있음 + 알림 권한 + 알림 사용 안함 (권장하지 않음) + 알림 활성화 + jtx Board 권한 + OpenTasks 권한 + 작업 권한 + 할일 목록 동기화 하지않음 + 할일 목록 동기화 가능 + 권한 유지 + 권한이 자동으로 재설정됨(권장하지 않음) + 권한이 자동으로 재설정 되지않음 + 권한을 선택하세요 > \"앱을 사용하지 않을 경우 사용 권한 제거\" + 스위치가 작동하지 않으면 앱 설정 / 사용 권한을 사용하십시오. + 앱 설정 + + WiFi SSID 권한 + 현재 WiFi 이름(SSID)에 액세스할 수 있으려면 다음 조건을 충족해야 합니다. + 상세 위치 권한 + 위치 권한 부여 + 위치 권한 거부 + 백그라운드 위치 권한 + 항상 허용 + 위치 권한 설정: %s + 위치 권한 미설정: %s + %s 은(는) 특정 WiFi SSID로 동기화를 제한하기 위한 목적으로 위치 데이터(WiFi SSID만 사용)를 활용합니다. 이 동작은 백그라운드에서 동기화할 때도 동일하게 적용됩니다. + 모든 위치 데이터(와이파이 SSID만 해당)는 오직 기기 내에서만 사용되며, 어디에도 전송되지 않습니다. + 위치 정보 항상 사용 + 위치 서비스를 사용할 수 있습니다. + 위치 서비스가 거부되었습니다. + + 번역 + 라이브러리 + 버전 %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) and contributors + 이 프로그램은 보증 없이 제공됩니다. 그것은 무료 소프트웨어이며, 특정한 조건 하에서 재배포하는 것을 환영합니다. + + log file을 만들 수 없습니다. + 이제 모든 %s 활동을 로깅합니다. + 보기/공유 + 사용 안함 + + CalDAV/CardDAV 동기화 어댑터 + 관련 / 라이센스 + 베타 피드백 + 웹 브라우저를 설치해 주십시오. + 설정 + 뉴스 & 업데이트 + + 외부 링크 + 웹 사이트 + 메뉴얼 + FAQ + 커뮤니티 + 프로젝트 지원 + 기여 방법 + 개인 정보 보호 정책 + DAVx⁵에 오신 것을 환영합니다! + 서버에 연결하여 캘린더와 주소록을 항상 동기화된 상태로 유지하세요. + 모든 계정 동기화 + + 알림 비활성화되어 있습니다. 동기화 에러 알림을 받을 수 없습니다. + 자동 동기화 비활성화 (검증된 인터넷 연결 없음) + 연결 관리 + 데이터 절약 모드가 활성화되어 있습니다. 백그라운드 동기화가 제한됩니다. + 배터리 절약 모드 관리 + 배터리 세이버가 활성화되어 있습니다. 동기화가 제한될 수 있습니다. + 배터리 세이버 관리 + 저장 공간이 부족합니다. 기기에 저장된 변경 사항은 즉시 동기화되지 않으며, 다음 정기 동기화 시에 반영됩니다. + 저장 공간 관리 + 캘린더 공급자를 찾을 수 없음 + \"캘린더 저장소\" 시스템 앱을 비활성화하셨나요? + 연락처 공급자를 찾을 수 없음 + \"연락처 저장소\" 시스템 앱을 비활성화하셨나요? + 관리 앱 + + 서비스 검색 실패 + 컬렉션 목록을 새로 고칠 수 없습니다. + + foreground에서 실행중 + 일부 장치에서는 자동 동기화를 위해 이 작업이 필요합니다. + + 설정 + Debugging + 디버그 정보 보기 + 구성 세부 정보 및 로그 보기/공유 + 상세 로그 + 로깅이 활성화되었습니다. 로그는 디버그 정보의 일부로 확인할 수 있습니다. + 로깅이 비활성화되었습니다. + 배터리 최적화 + 앱이 배터리 최적화에서 제외됨 (권장됨) + 배터리 제한이 적용됨 (권장되지 않음) + 연결 + 프록시 타입 + + 기본값 + No proxy + HTTP + SOCKS (for Orbot) + + 프록시 호스트 이름 + 프록시 포트 + 보안 + 앱 권한 + 동기화에 필요한 사용 권한 검토 + 신뢰할 수 없는 시스템 인증 + 시스템 및 사용자 추가한 CA를 신뢰할 수 없음 + 시스템 및 사용자 추가한 CA를 신뢰할 수 있음(권장) + 이 설정이 활성화되면 시스템 인증서는 신뢰되지 않습니다. 따라서 (서버가 인증서를 갱신할 때도) 모든 인증서를 직접 수동으로 허용해야 합니다. 그렇지 않으면 계정 설정이나 동기화가 작동하지 않습니다. + 신뢰할 수 있는(없는) 인증서 재설정 + 모든 사용자 지정 인증서를 재설정합니다. + 모든 사용자 지정 인증서가 확인되었습니다. + 사용자 인터페이스 + 알림 설정 + 알림 채널 및 해당 설정 관리 + 테마 선택 + + 시스템 디자인 + 밝은 테마 + 어두운 테마 + + 힌트 재설정 + 이전에 해제된 힌트를 다시 사용 + 모든 힌트가 다시 표기 + 통합 + 테스크 앱 + 호환되는 작업 앱을 찾을 수 없습니다. + UnifiedPush (실험적 기능) + 없음 (푸시 비활성화) + 배포자(distributor) 선택 + 푸시 배포자(distributor)가 없음 + 엔드포인트(Endpoint)가 설정되지 않았습니다. + %s 을(를) 통해 푸시 메시지를 수신할 준비가 되었습니다. + FCM (Google Play) + 푸시 메시지는 항상 암호화됩니다. + + CardDAV + CalDAV + Webcal + 이 컬렉션을 동기화하려면 추가적인 권한이 필요합니다. + 권한 관리 + 동기화 + 계정 설정 + 계정 이름 바꾸기 + 저장하지 않은 로컬 데이터는 삭제될 수 있습니다. 이름 변경 후 재동기화가 필요합니다. + 새로운 계정 이름 + 이름 바꾸기 + 계정 이름이 이미 사용되었습니다. + 계정 이름을 바꿀 수 없습니다. + 계정 삭제 + 정말 계정을 삭제하시겠습니까? + 주소록, 캘린더 및 업무 목록의 모든 로컬 복사본이 삭제됩니다. + 이 컬렉션을 동기화합니다. + 읽기전용 + 캘린더 + 연락처 + 일지 + 작업 + 개인만 표시 + 목록 새로고침 + Webcal 구독은 외부 앱과 동기화할 수 있습니다. + Webcal 지원 앱을 찾을 수 없습니다. + ICSx⁵를 설치합니다. + + 계정 추가 + 개인정보처리방침을 참조하십시오.]]> + 일반 로그인 + 제공자별 로그인 + 계속 + 로그인 + 이메일 주소로 로그인 + 이메일 주소 + 올바른 이메일 주소가 필요합니다. + 이 서비스 는 DNS 레코드와 잘 알려진 URL을 통해 탐색됩니다.]]> + 비밀번호 + 비밀번호 숨기기 + 비밀번호 표시 + 로그인 URL 과 사용자 이름 + 사용자 이름 + 기본 URL + 이 서비스 는 DNS 레코드와 잘 알려진 URL을 통해서도 탐색합니다.]]> + 인증서 선택 + 계정 추가 + 계정 이름 + 일부 기기에서는 아포스트로피(\')를 사용할 경우 문제가 발생할 수 있습니다. + Android는 사용자가 만든 이벤트에 대해 계정 이름을 ORGANGER 필드로 사용하므로 전자 메일 주소를 계정 이름으로 사용합니다. 이름이 같은 두 개의 계정을 가질 수 없습니다. + 연락처 분류 방법: + 계정 이름 필요 + 계정 이름이 이미 사용되었습니다. + 계정을 추가할 수 없습니다. + 종료 + 고급 로그인 + 클라이언트 인증서: %s + 인증서를 찾을 수 없음 + 인증서 설치 + Fastmail + Fastmail 계정 + Fastmail로 로그인 + 구글 주소록 / 캘린더 + 구글 계정 + 구글로 로그인 + 클라이언트 ID (선택) + 개인정보처리방침 을 참조하십시오.]]> + Google API 서비스 사용자 데이터 정책을 준수합니다.]]> + 인증 코드를 가져올 수 없습니다. + Nextcloud + Nextcloud로 로그인 + 웹 브라우저에서 Nextcloud 로그인 절차가 시작됩니다. + Nextcloud 서버 주소 + 로그인 + 로그인 URL을 가져올 수 없습니다. + 로그인 데이터를 가져올 수 없습니다. + 구성 탐색 + 잠시 기다려 주십시오. 서버를 쿼리하고 있습니다... + CalDAV 또는 CardDAV 서비스를 찾을 수 없습니다. + 기본 URL이 접근 가능한 CalDAV/CardDAV 주소가 아닌 것 같으며, 서비스 탐색에 실패했습니다. + 저희가 테스트한 서비스 목록 그리고 기본 URL을 참조하시기 바랍니다.]]> + 인증 정보(일반적으로 사용자 이름 및 비밀번호)를 다시 한번 확인해 주십시오. + 더 자세한 기술 정보는 로그에서 확인하실 수 있습니다. + logs 보기 + + 동기화 + 주기적 연락처 동기화 + 직접 선택 + 매 %d 분 + 로컬 변경시 즉시 + 주기적 캘린더 동기화 + 주기적으로 작업 동기화 + + 직접 선택 + 매 15분마다 + 매 30분마다 + 매 1시간마다 + 매 2시간마다 + 매 4시간마다 + 매일 한번 + + WiFi로만 동기화 + 동기화는 WiFi 연결로 제한됩니다. + 연결 유형은 고려되지 않습니다. + WiFi SSID 제한 + 다음에 대해서만 동기화됨 %s + 모든 WiFi 연결이 사용됩니다. + 쉼표로 구분된 허용되는 WIFI 네트워크의 이름(모두 빈칸으로 두세요) + WiFi SSID 제한에 추가 설정이 필요함 + 관리 + VPN은 인터넷 연결을 기반으로 동작 + 검증된 인터넷 연결 없이 VPN만으로는 동기화를 실행하기에 충분하지 않습니다. (권장) + 검증된 인터넷 연결이 없어도 VPN만으로도 동기화를 실행할 수 있습니다. + 인증 + 사용자 이름 + 비밀번호 또는 앱 비밀번호 + 앱 비밀번호를 사용했을 수 있습니다.]]> + 새 비밀번호 + 귀하의 서버에 비밀번호 업데이트. + OAuth로 다시 인증해주세요. + 접근이 차단되었을 때 사용하십시오. + 인증에 성공했습니다. + 클라이언트 인증서 + 인증서를 사용할 수 없거나 선택되지 않았음 + 인증서 설치 + CalDAV + 지난 이벤트 시간 제한 + 모든 이벤트가 동기화 됩니다. + + 지난 이벤트 중 %d일은 무시됩니다. + + 지난 일 수보다 많은 이벤트는 무시됩니다(0일). 모든 이벤트를 동기화하려면 비워 두십시오. + 기본 리마인더 + + 이벤트 %d분 전 기본 리마인더 + + 기본 리마인더이 생성되지 않았습니다 + 리마인더없이 이벤트에 대해 default 리마인더가 생성되어야 하는 경우: 이벤트 시작 전 원하는 시간. default 리마인더을 사용하지 않으려면 비워 두십시오. + 캘린더 색상 관리 + 캘린더 색상은 동기화할 때마다 재설정됩니다. + 다른 앱에서 캘린더 색상을 설정할 수 있습니다. + 이벤트 색상 지원 + 이벤트 색상이 동기화 되었습니다. + 이벤트 색상이 동기화되지 않았습니다. + CardDAV + 연락처 분류 방법 + + 별도의 전자 명함으로 분류 + 연락처 별 항목으로 분류 + + + 주소록 생성 + 서버에서 CardDAV를 통한 주소록 생성을 지원하지 않을 수 있습니다. + 캘린더 생성 + + 가능한 캘린더 항목 + 이벤트 + 작업 + 메모 및 저널 + 서버에서 CalDAV를 통한 캘린더 생성을 지원하지 않을 수 있습니다. + 색상 + 제목 + 저장 위치 + 생성 + + 주소록 + 일정 + 작업 + 컬렉션 삭제 + 이 컬렉션 (%s) 과 모든 데이터는 로컬과 서버에서 영구적으로 삭제됩니다. + 동기화 + 동기화 활성화됨 + 동기화 비활성화됨 + 읽기 전용 + (서버 설정에 의해) 읽기 전용 + (정책상) 읽기 전용 + (로컬에서만) 읽기 전용 + 읽기/쓰기 모두 가능 + 제목 + 설명 + 소유자 + 푸시 지원 + 서버가 푸시 기능을 지원 + 구독 시작: %1$s, 만료일: %2$s + 마지막 동기화: %s + 주소 (URL) + + 디버그 정보 + ZIP 아카이브 + 디버그 정보과 로그를 포함 + 아카이브를 공유하여 컴퓨터로 전송하거나 email로 보내거나 support ticket에 첨부합니다. + 아카이브를 공유 + 이 메시지에 디버그 정보가 첨부되어 있습니다. (수신 앱이 첨부 파일을 지원해야 함) + HTTP 에러 + 서버 에러 + WebDAV 에러 + I/O 에러 + 상세 설명보기 + 디버그 정보가 수집되었습니다. + 관련 리소스 + 관련된 문제 + 원격 리소스: + 로컬 리소스: + Logs + 상세 logs를 사용할 수 있습니다. + logs 보기 + URL 복사 + 개인정보 보호 고지 + 로그 및 디버그 정보에는 개인 정보가 포함될 수 있습니다. 이를 공개적으로 공유할 때 유의하시기 바랍니다. + + 에러가 발생 하였습니다. + HTTP 에러가 발생 하였습니다. + I/O 에러가 발생 하였습니다. + 자세히 + + WebDAV mounts + 사용된 할당량: %1$s / 사용가능한 할당량: %2$s + 공유 콘텐츠 + Unmount + Add WebDAV mount + WebDAV 마운트를 추가하여 클라우드 파일에 직접 액세스하십시오! + WebDAV 마운트 작동 방식은 매뉴얼을 참고해주세요.]]> + 이름 표기 + WebDAV URL + 잘못된 URL + 인증 + 사용자 이름 + 비밀번호 + Add mount + 이 URL에 WebDAV 서비스가 없습니다. + 마운트 지점 제거 + 연결 세부 정보는 사라지지만, 파일은 삭제되지 않습니다. + Accessing WebDAV file + Downloading WebDAV file + Uploading WebDAV file + WebDAV mount + + DAVx⁵ 권한 + 추가 권한 필요 + %s는 너무 오래되었습니다. + 최소 필요 버전: %1$s + 인증에 실패했습니다. (로그인 정보 확인) + 네트워크 혹은 I/O 에러 – %s + HTTP 서버 오류 – %s + Local storage 오류 – %s + 일시적 오류 (최대 재시도 횟수 도달) + 서버로부터 잘못된 연락처를 받았습니다. + 서버에서 잘못된 이벤트를 수신했습니다. + 서버에서 잘못된 작업을 수신했습니다. + 하나 이상의 잘못된 리소스 무시 + 동기화 대기 중 + 원격 데이터가 변경되었습니다 + + 전체 동기화 + 모든 계정 동기화 + 텍스트 동기화 버튼 + 아이콘 동기화 버튼 + 수동으로 동기화하려면 탭하세요. + + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml new file mode 100644 index 0000000..fe6865e --- /dev/null +++ b/app/src/main/res/values-nb/strings.xml @@ -0,0 +1,171 @@ + + + + DAVx⁵-adressebok + Skru på + Hjelp + Feilsøking + Andre viktige beskjeder + Synkronisering + Synkroniseringsfeil + Viktige feil som avbryter synkronisering, som uventede svar fra serveren + Ikke-kritiske synkroniseringsproblem, som enkelte ugyldige filer + Nettverk- og I/O-feil + + Mer informasjon + + Kontakt-tilgang + Kalender-tilgang + OpenTasks-tilganger + + + Bibliotek + Versjon %1$s(%2$d) + Dette programmet kommer uten NOEN FORM FOR GARANTI. Det er fri programvare, og du er velkommen til å redistribuere det under gitte forhold. + + Kan ikke opprette loggfil + + CalDAV/CardDAV -synkroniseringsadapter + Om / Lisens + Tilbakemelding om beta-en + Innstillinger + Nyheter og oppdateringer + Eksterne lenker + Nettside + Manuell + O-S-S + + + Tjenesteoppdagelse mislyktes + Kunne ikke gjenoppfriske innsamlingsliste + + + Innstillinger + Feilretting + Vis feilrettingsinfo + Grundig logging + Logging er skrudd av + Tilkobling + Sikkerhet + Fjern tiltro til systemsertifikater + System og brukertillagte sertifikatsmyntigheter vil ikke bli tiltrodd + System- og bruker -tillagte sertifikatsmyndigheter vil bli tiltrodd (anbefalt) + Tilbakestill (ikke)tiltrodde sertifikater + Tilbakestiller tillit til alle egendefinerte sertifikater + Alle egendefinerte sertifikater har blitt fjernet + Brukergrensesnitt + Varselsinnstillinger + Tilbakestill hint + Skrur på hint som har blitt avslått tidligere + Alle hint vil bli vist igjen + + CardDAV + CalDAV + Webcal + Synkroniser nå + Kontoinnstillinger + Gi konto nytt navn + Gi nytt navn + Brukernavnet er allerede i bruk + Slett konto + Vil du virkeling slette kontoen? + Alle lokale kopier av adressebøker, kalendere og gjøremålslister vil bli slettet. + synkroniser denne samlingen + kun lesbar + kalender + Fant ingen programmer med støtte for Webcal + Installer ICSx⁵ + + Legg til konto + Logg inn + Innlogging med e-postadresse + E-postadresse + Gyldig e-postadresse påkrevd + Passord + Logg inn med nettadresse og brukernavn + Brukernavn + Landings-nettadresse + Legg til konto + Kontonavn + Bruk din e-postadresse som kontonavn fordi Android vil bruke kontonavnet som ORGANISATOR-felt for hendelser du oppretter. Du kan ikke ha to kontoer med samme navn. + Kontaktgruppemetode: + Kontonavn påkrevd + Brukernavnet er allerede i bruk + Oppdagelse av oppsett + Vent, spør tjener… + Fant ikke CalDAV eller CardDAV-tjeneste. + + Synkronisering + Intervall for kontaktsynkronisering + Åpne manuelt + Hvert %d minutt + umiddelbart ved lokale endringer + Kalendersynkroniseringsintervall + Gjøremålssynkroniseringsintervall + + Bare manuelt + Hvert kvarter + Hver halvtime + Hver time + Hver andre time + Hver fjerde time + Én gang om dagen + + Bare synk. over Wi-Fi + Synkronisering er begrenset til Wi-Fi -tilkoblinger + Tilkoblingstypen blir ikke tatt i betraktning + Wi-Fi SSID -begrensning + Vil kun synkronisere over %s + Alle Wi-Fi -tilkoblinger vil bli brukt + Kommainndelte navn (SSID-er) på tillatte Wi-Fi -nettverk (la stå tomt for alle) + Identitetsbekreftelse + Brukernavn + Oppdater passordet i henhold til din tjener. + CalDAV + Tidsgrense for tidligere hendelser + Alle gjøremål vil bli synkronisert + + Gjøremål for mer enn én dag siden vil bli sett bort fra + Gjøremål for mer enn %d dager siden vil bli sett bort fra + + Hendelser som er mer enn dette antallet dager i fortid vil bli ignorert (kan være 0). La stå tomt for å synkronisere alle hendelser. + Velg kalenderfarger + Støtte for fargelegging av hendelser + CardDAV + Kontaktgruppemetode + + Opprett adressebok + Opprett kalender + Mulige kalenderhendelser + Hendelser + Oppgaver + Farge + Tittel + Lagringslokasjon + Opprett + + Slett samling + Synkronisering + Tittel + Beskrivelse + + Feilrettingsinfo + Kopier nettadresse + + En feil har inntruffet + En HTTP-feil har inntruffet. + En I/O-feil har inntruffet. + Vis detaljer + + Identitetsbekreftelse + Brukernavn + Passord + + DAVx⁵-tilganger + Ytterligere tilganger kreves + Nettverk- og I/O-feil - %s + HTTP-tjenerfeil - %s + Feil med lokallagring - %s + Fikk ugyldig kontakt fra server + + + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..ba19da6 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,483 @@ + + + + Account bestaat niet (of niet meer) + DAVx⁵ Adresboek + Verander hier niet van account! Gebruik in plaats daarvan direct de app om accounts te beheren. + Verwijderen + Verwijderen + Annuleren + Inschakelen + Dit veld is verplicht + Hulp + Navigeer omhoog + Opties menu + Delen + Synchronisatie begonnen/in wachtrij geplaatst + Database beschadigd + Alle accounts zijn lokaal verwijderd. + Debuggen + Andere belangrijke berichten + Statusberichten met lage prioriteit + Synchroniseren + Synchronisatiefouten + Belangrijke fouten die het synchroniseren stoppen, zoals onverwachte server antwoorden + Synchronisatie waarschuwingen + Niet-fatale problemen bij het synchroniseren zoals bepaalde ongeldige bestanden + Netwerk en I/O fouten + Timeouts, connectie problemen, etc. (vaak tijdelijk). + + Jouw gegevens. Jouw keuze. + Houd zelf de controle + regelmatige sync-intervallen + Om op gezette tijden te synchroniseren moet %s zonder beperking op de achtergrond kunnen draaien. Anders kan Android het synchroniseren op elk moment onderbreken. + Synchroniseren op gezette tijden is niet nodig.* + %s compatibiliteit + Leverancierspecifieke firmware kan de synchronisatie blokkeren. Als je hier last van hebt, kan dit alleen handmatig worden opgelost. + De vereiste instellingen zijn verricht. Er aan herinneren is niet meer nodig.* + * Niet aanvinken om later herinnerd te worden. Kan teruggezet in app instellingen / %s. + Meer informatie + jtx Board + + Ondersteunt taken + Als de server taken ondersteunt, synchroniseert een geschikte taken-app ze: + OpenTasks + Schijnt niet meer ontwikkeld te worden - niet aanbevolen. + Tasks.org + worden niet ondersteund.]]> + Geen app-store beschikbaar + Ik hoef geen ondersteuning van taken.* + Open-source software + We zijn blij dat de keuze valt op open source software %s. Ontwikkelen, onderhouden en ondersteunen is veel werk. Overweeg daarom bij te dragen (kan op vele manieren) of een donatie. Wij waarderen het zeer! + Hoe bijdragen/doneren + Herinner me er niet aan voor + + %d maand + %d maanden + + Volgende + + Rechten toestaan + %s heeft rechten nodig om goed te werken. + Alle onderstaande + Gebruik dit om alle functies in te schakelen (aanbevolen) + Alle rechten toegekend + Contacten toestaan + Geen contacten synchroniseren (niet aanbevolen) + Contacten synchroniseren mogelijk + Kalender machtigingen + Geen kalenders synchroniseren (niet aanbevolen) + Kalenders synchroniseren mogelijk + Toestemming voor meldingen + Meldingen uitgeschakeld (niet aanbevolen) + Meldingen ingeschakeld + jtx Board-rechten + OpenTasks rechten + Rechten voor taken + Geen taak-sync + Taak-sync mogelijk + Rechten behouden + Rechten kunnen automatisch worden teruggezet (niet aanbevolen) + Rechten worden niet automatisch teruggezet + Klik op App Rechten > vinkje uit bij \"Rechten intrekken\" + Als een schakeloptie niet werkt, gebruik dan App-info / Rechten. + App instellingen + + WiFi SSID rechten + Voor toegang tot de huidige WiFi-naam (SSID), moet aan deze voorwaarden worden voldaan: + Recht van toegang tot exacte locatie + Toegang tot locatie verleend + Toegang tot locatie geweigerd + Toegang tot locatie op de achtergrond + Onbeperkt toestaan + Locatietoestemming ingesteld op: %s + Locatietoestemming niet ingesteld op: %s + %s gebruikt locatiegegevens (alleen WiFi SSID) uitsluitend om de synchronisatie te beperken tot een specifieke WiFi SSID. Dit gebeurt zelfs als de synchronisatie op de achtergrond wordt uitgevoerd. + Alle locatiegegevens (alleen WiFi SSID) worden alleen lokaal gebruikt en worden nergens naartoe verzonden. + Toegang tot locatie altijd ingeschakeld + Toegang tot locatie is ingeschakeld + Toegang tot locatie is uitgeschakeld + + Vertalingen + Bibliotheken + Versie%1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) en bijdragers + Dit programma wordt geleverd met ABSOLUUT GEEN GARANTIE. Het is gratis software, en mag opnieuw worden verspreid onder bepaalde voorwaarden. + + Kon geen logbestand aanmaken + Logt nu alle %s activiteiten + Bekijken/delen + Uitschakelen + + CalDAV/CardDAV Sync adapter + Over / Licentie + Beta terugkoppeling + Webbrowser is vereist + Instellingen + Nieuws & updates + Gereedschap + Externe links + Website + Handleiding + FAQ + Voor organisaties + Community + Ondersteun het project + Hoe bijdragen + Privacybeleid + Welkom bij DAVx⁵! + Maak verbinding met je server en houd je agenda\'s en contactpersonen gesynchroniseerd. + Alle accounts synchroniseren + + Meldingen uitgeschakeld. U krijgt geen meldingen over synchronisatiefouten. + Automatische synchronisatie niet actief (geen geverifieerde internetverbinding). + Verbindingen beheren + Gegevensbesparing ingeschakeld. Synchronisatie op de achtergrond is beperkt. + Beheer van gegevensbesparing + Batterijbesparing ingeschakeld. Synchronisatie kan beperkt zijn. + Batterijbesparing beheren + Weinig opslagruimte. Android zal lokale wijzigingen niet onmiddellijk synchroniseren, maar tijdens de volgende reguliere synchronisatie. + Opslag beheren + Aanbieder voor Kalender ontbreekt + Heb je de systeemapp \"Kalenderopslag\" uitgeschakeld? + Aanbieder voor Contactpersonen ontbreekt + Heb je de systeemapp \"Contactenopslag\" uitgeschakeld? + Apps beheren + + Service herkenning is mislukt + De collectielijst is niet bijgewerkt + + Draait op de voorgrond + Op sommige toestellen is dit nodig voor automatische synchronisatie. + + Instellingen + Debuggen + Debug-info + Configuratiedetails en logbestanden bekijken/delen + Uitgebreid loggen + Loggen is actief. Je kunt de logs bekijken als onderdeel van de debug-info. + Loggen is niet actief + Batterijoptimalisatie + App is vrijgesteld (aanbevolen) + Batterijbeperkingen van toepassing (niet aanbevolen) + Verbinding + Proxy-type + + Systeem standaard + Geen proxy + HTTP + SOCKS (voor Orbot) + + Proxy hostnaam + Proxy poort + Beveiliging + App rechten + De vereiste rechten om te synchroniseren controleren + Wantrouw systeemcertificaten + Door systeem en gebruiker toegevoegde CA certificaten niet vertrouwen + Door systeem en gebruiker toegevoegde CA certificaten vertrouwen (aanbevolen) + Als deze instelling actief is, worden systeemcertificaten niet als betrouwbaar beschouwd. Dit betekent dat je elk certificaat handmatig moet accepteren (ook wanneer de server zijn certificaat vernieuwt) anders werken accountinstelling en synchronisatie niet. + (Niet-)vertrouwde certificaten terugzetten + Herstelt het vertrouwen van alle aangepaste certificaten + Alle aangepaste certificaten zijn gewist + Gebruikersinterface + App-meldingen + Meldingskanalen en hun instellingen beheren + Thema selecteren + + Systeem standaard + Licht + Donker + + Hints opnieuw instellen + Hints die al gezien zijn opnieuw weergeven + Alle hints opnieuw weergeven + Integratie + Taken app + Geen compatibele taken app gevonden + UnifiedPush (experimenteel) + Geen (push uitschakelen) + Kies een distributeur + Geen push distributeur geïnstalleerd + Geen eindpunt geconfigureerd + Klaar om pushberichten te ontvangen via %s + FCM (Google Play) + Pushberichten zijn altijd versleuteld. + + Account is verwijderd + CardDAV + CalDAV + Webcal + Er zijn extra rechten nodig om deze collecties te synchroniseren. + Machtigingen beheren + Nu synchroniseren + Account-instellingen + Accountnaam wijzigen + Niet opgeslagen lokale gegevens kunnen worden verwijderd. Na het hernoemen is opnieuw synchroniseren vereist. + Nieuwe accountnaam + Naam wijzigen + Accountnaam is al in gebruik + Accountnaam is niet gewijzigd + Account verwijderen + Account echt verwijderen? + Alle lokale kopieën van adresboeken, kalenders en takenlijsten worden verwijderd. + deze collectie synchroniseren + alleen-lezen + kalender + contacten + logboek + taken + Alleen persoonlijk tonen + Lijst verversen + Webcal abonnementen kunnen worden gesynchroniseerd met externe apps. + Geen Webcal-app gevonden + ICSx⁵ installeren + + Account toevoegen + privacybeleid.]]> + Algemeen inloggen + Aanbieder-specifieke login + Ga verder + Login + Inloggen met e-mailadres + E-mailadres + Geldig e-mailadres vereist + Diensten worden ontdekt met behulp van DNS-records en bekende URL\'s.]]> + Wachtwoord + Wachtwoord verbergen + Wachtwoord tonen + Wachtwoord (optioneel) + Inloggen met URL en gebruikersnaam + Gebruikersnaam + Gebruikersnaam (optioneel) + Basis-URL + services worden ook ontdekt met behulp van DNS records en bekende URL\'s.]]> + Certificaat selecteren + Account toevoegen + Accountnaam + Het gebruik van apostrofs (\') lijkt problemen te veroorzaken op sommige apparaten. + Gebruik het eigen e-mailadres als accountnaam, want Android gebruikt het als ORGANIZER veld voor gebeurtenissen. Twee accounts met hetzelfde adres kan niet. + Methode voor contact-groepen: + Accountnaam verplicht + Accountnaam is al in gebruik + Account kon niet worden toegevoegd + Afwerken + Geavanceerd inloggen + Geen cliëntcertificaat (optioneel) + Cliëntcertificaat: %s + Geen certificaat gevonden + Certificaat installeren + Fastmail + Fastmail-account + Inloggen met Fastmail + Google Contacten / Kalender + Google account + Inloggen met Google + Client ID (optioneel) + Privacybeleid voor meer informatie.]]> + beleid voor gebruikersgegevens van Google API Services, met inbegrip van de vereisten voor beperkt gebruik.]]> + Kon geen autorisatiecode verkrijgen + Nextcloud + Inloggen met Nextcloud + Hiermee wordt de Nextcloud Flow-aanmelding in een webbrowser gestart. + Nextcloud serveradres + Aanmelden + Kan inlog-URL niet verkrijgen + Kan inlog-URL niet verkrijgen + Configuratie detecteren + Even geduld, verzoek naar server… + Geen CalDAV- of CardDAV-service gevonden. + De basis URL lijkt geen toegankelijke CalDAV/CardDAV URL te zijn en de detectie van de service was niet succesvol. + onze lijst met geteste services en hun basis URL\'s.]]> + Controleer ook de authenticatie (meestal gebruikersnaam en wachtwoord). + Meer technische informatie is beschikbaar in de logboeken. + Details bekijken + + Synchronisatie + Contacten synchronisatie interval + Alleen handmatig + Elke %d minuten + direct bij lokale veranderingen + Kalenders synchronisatie-interval + Taken synchronisatie-interval + + Handmatig + Elke 15 minuten + Elke 30 minuten + Elk uur + Elke 2 uur + Elke 4 uur + Eenmaal daags + + Synchronisatie beperken tot WiFi + Alleen verbinden via WiFi + Type verbinding is niet relevant + Tot bepaalde WiFi-SSID beperken + Synchronisatie alleen via %s + Elke WiFI-SSID toestaan + Door komma\'s gescheiden namen (SSID\'s) van toegestane WiFi-netwerken (laat leeg voor alle) + Beperking WiFi-SSID vereist verdere instellingen + Beheren + VPN vereist onderliggend internet + VPN zonder onderliggende gevalideerde internetverbinding is niet voldoende om synchronisatie uit te voeren (aanbevolen) + VPN zonder onderliggende gevalideerde internetverbinding is voldoende om synchronisatie uit te voeren + Authenticatie + Gebruikersnaam + Wachtwoord of app-wachtwoord + app-wachtwoord.]]> + Nieuw wachtwoord + Gebruik het zelfde wachtwoord als op de server. + Opnieuw autoriseren (OAuth) + Gebruiken wanneer de toegang is ingetrokken + Autorisatie geslaagd + Cliëntcertificaat + Geen certificaat beschikbaar of geselecteerd + Certificaat installeren + CalDAV + Gebeurtenissen in verleden tijd + Worden alle gesynchroniseerd + + Afspraken ouder dan een dag worden genegeerd + Ouder dan %d dagen worden genegeerd + + Gebeurtenissen ouder dan ingevuld aantal dagen worden genegeerd (mag 0 zijn). Veld leeg laten om alle te synchroniseren. + Standaardherinnering + + Standaardherinnering één minut voor het evenement + %d minuten voor aanvang gebeurtenis + + Wordt niet aangemaakt + Vul het gewenste aantal minuten in. Leeg laten om herinneringen uit te schakelen. + Kalender kleuren beheren + Worden bij elke sync teruggezet + Kunnen door andere apps worden ingesteld + Gebeurtenis kleuren ondersteunen + Worden gesynchroniseerd + Worden niet gesynchroniseerd + CardDAV + Methode voor contact-groepen: + + Groepen zijn afzonderlijke vCards + Groepen zijn categorieën per contact + + + Adresboek aanmaken + Het aanmaken van een adresboek via CardDAV wordt mogelijk niet ondersteund door de server. + Kalender aanmaken + Standaard tijdzone (optioneel) + + Mogelijke kalender-items + Gebeurtenissen + Taken + Notities / Dagboek + Het aanmaken van een kalender via CalDAV wordt mogelijk niet ondersteund door de server. + Kleur + Titel + Opslaglocatie + Beschrijving (optioneel) + Aanmaken + + contacten + gebeurtenissen + taken + Collectie verwijderen + Deze collectie (%s) en alle gegevens worden permanent verwijderd, zowel lokaal als op de server. + Synchroniseren + Synchronisatie ingeschakeld + Synchronisatie uitgeschakeld + Alleen-lezen + Alleen-lezen (door server) + Alleen-lezen (volgens beleid) + Alleen-lezen (alleen lokaal) + Lezen/schrijven + Titel + Beschrijving + Eigenaar + Push-ondersteuning + Server adverteert Push-ondersteuning + Ingeschreven op %1$s, vervalt op %2$s + Laatste synchronisatie (%s) + Adres (URL) + + Debug informatie + ZIP archief + Bevat debuginformatie en logbestanden + Deel het archief om over te zetten naar een computer, per e-mail te verzenden of als bijlage bij een supportticket te voegen.. + Archief delen + Debug info als bijlage bij dit bericht (vereist ondersteuning voor bijlagen van de ontvangende app). + HTTP-fout + Serverfout + WebDAV fout + I/O-fout + Het verzoek is door de server afgewezen. + De gevraagde bron bestaat niet (meer). + De server staat het gevraagde type bewerking niet toe. + Er deed zich een probleem aan de serverzijde voor. Neem contact op met uw serverondersteuning. + Er is een onverwachte fout opgetreden. Bekijk foutopsporingsinformatie voor details. + Details bekijken + Debug-info is verzameld + Betrokken bronnen + Gerelateerd aan het probleem + Externe bron: + Lokale bron: + Logboeken + Uitgebreide logboeken zijn beschikbaar + Details bekijken + URL kopiëren + Bron inspecteren + Privacyverklaring + Logboeken en foutopsporingsgegevens kunnen privé-informatie bevatten. Houd hier rekening mee als u ze openbaar deelt. + Kan bron niet bekijken + + Er is een fout opgetreden. + Een HTTP-fout is opgetreden. + Een I/O fout is opgetreden. + Details weergeven + + WebDAV-koppelingen + Quotum gebruikt: %1$s / Beschikbaar: %2$s + Inhoud delen + Ontkoppelen + WebDAV-koppeling toevoegen + Verkrijg directe toegang tot cloudbestanden met een WebDAV-koppeling! + hoe WebDAV-mounts werken.]]> + Weergavenaam + WebDAV-URL + Ongeldige URL + Koppelpunt en weergavenaam + Authenticatie + Gebruikersnaam + Wachtwoord + Gebruikersnaam (optioneel) + Wachtwoord (optioneel) + Koppeling toevoegen + Geen WebDAV-service op deze URL + Verwijder het koppelpunt + Verbindingsgegevens gaan verloren, maar er worden geen bestanden gewist. + WebDAV-bestand openen + WebDAV-bestand downloaden + WebDAV-bestand uploaden + WebDAV-koppeling + + DAVx⁵ rechten + Aanvullende rechten vereist + %ste oud + Minimaal vereiste versie: %1$s + Verificatie mislukt (controleer aanmeldingsgegevens) + Netwerk of I/O error - %s + HTTP-server fout - %s + Lokale opslag fout - %s + Soft error (max. aantal pogingen bereikt) + Ongeldig contact ontvangen van server + Ongeldige gebeurtenis ontvangen van server + Ongeldige taak ontvangen van server + Een of meer ongeldige bronnen negeren + Synchronisatie in afwachting + De gegevens op afstand zijn veranderd + + Alles synchroniseren + Alle accounts synchroniseren + Gelabelde synchronisatieknop + Pictogram synchronisatieknop + Tik om de synchronisatie handmatig uit te voeren. + + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..d9cf246 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,489 @@ + + + + Konto (już) nie istnieje + Książka adresowa DAVx⁵ + Nie zmieniaj konta tutaj! Zamiast tego zarządzaj kontami bezpośrednio za pomocą aplikacji. + Usuń + Usuń + Anuluj + Włącz + To pole jest wymagane + Pomoc + Nawiguj w górę + Menu opcji + Udostępnij + Synchronizacja rozpoczęta/zakolejkowana + Uszkodzona baza danych + Wszystkie konta zostały usunięte lokalnie. + Debugowanie + Inne ważne wiadomości + Komunikaty statusu o niskim priorytecie + Synchronizacja + Błędy synchronizacji + Ważne błędy, które zatrzymują synchronizację, takie jak nieoczekiwane odpowiedzi serwera + Ostrzeżenia synchronizacji + Niekrytyczne problemy z synchronizacją, takie jak niektóre nieprawidłowe pliki + Błędy sieci oraz we/wy + Przekroczenia czasu, problemy z połączeniem itp. (często tymczasowe) + + Twoje dane. Twój wybór. + Przejmij kontrolę. + Regularne przedziały synchronizacji + Dla synchronizacji w regularnych przedziałach, %s musi mieć pozwolenie na pracę w tle. W przeciwnym razie, Android może wstrzymać synchronizację w dowolnym momencie. + Nie potrzebuję regularnych przedziałów synchronizacji.* + %s kompatybilność + Sterowniki sprzętowe, specyficzne dla wybranych dostawców, mogą blokowac synchronziację. Jeśli dotyczy to również Ciebie, możesz rozwiązać ten problem tylko ręcznie. + Wprowadziłem potrzebne ustawienia. Nie przypominaj mi ponownie.* + * Pozostaw nie zaznaczone, aby otrzymać przypomnienie później. Można zresetować w ustawieniach aplikacji wybierając / %s. + Więcej informacji + jtx Board + + Obsługa zadań + Jeśli zadania są wspierane przez Twój serwer, mogą być synchronizowane ze wspieraną aplikacją zadań: + OpenTasks + Wydaje się, że nie jest już rozwijany – nie jest zalecany. + Tasks.org + nie są wspierane.]]> + Sklep aplikacji nie jest dostępny + Nie potrzebuję obsługi zadań.* + Oprogramowanie open-source + Cieszymy się, że używasz %s, czyli oprogramowania typu open-source. Rozwój, utrzymanie i wsparcie to ciężka praca. Prosimy o rozważenie wniesienia swojego wkładu (jest wiele sposobów) lub darowizny. Byłoby to bardzo cenne! + Jak wspomóc/wesprzeć + Nie przypominaj mi przez + + %dmiesiąc + %dmiesiące + %dmiesięcy + %d miesiące/miesięcy + + Dalej + + Uprawnienia + %s wymaga uprawnień do prawidłowego działania + Wszystkie poniższe + Użyj tego aby odblokować wszystkie funkcje (zalecane) + Wszystkie uprawnienia nadane + Uprawnienia kontaktów + Bez synchronizacji kontaktów (nie zalecane) + Synchronizacja kontaktów możliwa + Uprawnienia kalendarza + Bez synchronizacji kalendarza (nie zalecane) + Synchronizacja kalendarza możliwa + Uprawnienie do powiadomień + Powiadomienia wyłączone (niezalecane) + Powiadomienia włączone + Uprawnienia jtx Board + Uprawnienia OpenTasks + Uprawnienia zadań + Brak synchronizacji zadań + Synchronizacja zadań możliwa + Utrzymaj uprawnienia + Uprawnienia mogą zostać automatycznie zresetowane (nie zalecane) + Uprawnienia nie będą automatycznie resetowane + Kliknij Uprawnienia > odznacz \"Usuń uprawnienia jeśli aplikacja nie jest używana\" + Jeśli przełącznik nie działa użyj ustawień dla aplikacji / Uprawnienia. + Ustawienia aplikacji + + Uprawnienia WiFi SSID + Aby móc uzyskać dostęp do obecnej nazwy WiFi (SSID), następujące warunki muszą być spełnione: + Uprawnienie dokładnej lokalizacji + Uprawnienie lokalizacji nadane + Uprawnienie lokalizacji odebrane + Uprawnienie lokalizacji w tle + Zezwól przez cały czas + Uprawnienia lokalizacji ustawione na: %s + Uprawnienia lokalizacji nie ustawione na: %s + %s używa danych o lokalizacji (tylko SSID WiFi ) wyłącznie do ograniczania synchronizacji tylko do wybranych SSID-ów WiFi. Będzie się to działo również gdy synchronizacja przebiega w tle. + Wszystkie dane o lokalizacji (tylko SSID WiFi) są używane tylko lokalnie i nie są nigdzie wysyłane. + Lokalizacja zawsze włączona + Usługa lokalizacji jest włączona + Usługa lokalizacji jest wyłączona + + Tłumaczenia + Biblioteki + Wersja %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) i współpracownicy + Ten program jest ABSOLUTNIE BEZ GWARANCJI. To jest wolne oprogramowanie i mile widziane jest dalsze rozpowszechnianie go zgodnie z warunkami licencji. + + Nie udało się utworzyć pliku logu + Obecnie zapisywane są wszystkie %s aktywności + Zobacz/udostępnij + Wyłącz + + Adapter synchronizacji CalDAV/CardDAV + Informacje/Licencja + Przekaż opinię + Proszę zainstalować przeglądarkę internetową + Ustawienia + Nowości i aktualizacje + Narzędzia + Zewnętrzne odnośniki + Strona WWW + Podręcznik + Często zadawane pytania + Dla organizacji + Społeczność + Wesprzyj ten projekt + Jak wnieść wkład + Polityka prywatności + Witaj w DAVx⁵! + Połącz się ze swoim serwerem i utrzymuj swój kalendarz i kontakty zsynchronizowane. + Synchronizuj wszystkie konta + + Powiadomienia wyłączone. Nie będziesz otrzymywać powiadomień o błędach synchronizacji. + Automatyczna synchronizacja nie jest aktywna (brak zweryfikowanego połączenia z Internetem). + Zarządzaj połączeniami + Włączono oszczędzanie danych. Synchronizacja w tle jest ograniczona. + Zarządzaj oszczędzaniem danych + Oszczędzanie baterii jest włączone. Synchronizacja może być ograniczona. + Zarządzaj oszczędzaniem baterii + Mało miejsca do przechowywania. Android nie zsynchronizuje lokalnych zmian od razu, ale podczas następnej regularnej synchronizacji. + Zarządzaj pamięcią + Brak dostawcy kalendarza + Czy zablokowałeś aplikację systemową \"Przechowywanie kalendarza\" ? + Brak dostawcy kontaktów + Czy zablokowałeś aplikację systemową \"Przechowywanie kontaktów\" ? + Zarządzaj aplikacjami + + Wykrycie serwisu nie powiodło się + Nie można odświeżyć listy kolekcji + + Działa na pierwszym planie + Na niektórych urządzeniach jest to konieczne do automatycznej synchronizacji. + + Ustawienia + Debugowanie + Pokaż informacje do debugowania + Wyświetl/udostępnij szczegóły konfiguracji i logi + Rozszerzone logowanie + Zbieranie logów jest włączone. Możesz zobaczyć logi jako część informacji debugowania. + Logowanie jest wyłączone + Optymalizacja baterii + Aplikacja dodana do wyjątków (zalecane) + Zastosowano ograniczenia baterii (nie zalecane) + Łączność + Typ proxy + + Domyślne ustawienie systemowe + Brak proxy + HTTP + SOCKS (dla Orbota) + + Nazwa hosta proxy + Port proxy + Bezpieczeństwo + Uprawnienia aplikacji + Przejrzyj uprawnienia konieczne do synchronizacji + Nie ufaj certyfikatom systemowym + Certyfikaty systemowe i użytkownika nie są zaufane + Certyfikaty systemowe i użytkownika są zaufane (zalecane) + Jeśli to ustawienie jest aktywne to certyfikaty systemowe nie są uznawane za zaufane. Oznacza to, że będziesz musiał(a) ręcznie zakceptować każdy certyfikat (również gdy serwer odświeży swój certyfikat) lub konfiguracja konta i synchronizacja nie będą działały. + Zresetuj (nie)zaufane certyfikaty + Zresetuj wszystkie niestandardowe certyfikaty + Wszystkie niestandardowe certyfikaty zostały wyczyszczone + Interfejs użytkownika + Ustawienia powiadomień + Zarządzaj kanałami powiadomień i ich ustawieniami + Wybierz motyw + + Domyślne ustawienie systemowe + Jasny + Ciemny + + Zresetuj podpowiedzi + Ponownie włącz wskazówki, które zostały usunięte wcześniej + Wszystkie wskazówki pojawią się ponownie + Integracja + Aplikacja zadań + Nie znaleziono kompatybilnej aplikacji zadań + UnifiedPush (eksperymentalny) + Żaden (wyłącz Push) + Wybierz kolportera + Nie zainstalowano kolportera wiadomości Push + Brak konfiguracji punktu końcowego + Gotowy aby otrzymywać wiadomości Push poprzez %s + FCM (Google Play) + Wiadomości Push są zawsze szyfrowane. + + Konto zostało usunięte + CardDAV + CalDAV + Webcal + Dodatkowe uprawnienia są wymagane aby zsynchronizować te kolekcje. + Zarządzaj uprawnieniami + Synchronizuj teraz + Ustawienia konta + Zmień nazwę konta + Niezapisane dane lokalne mogą zostać usunięte. Po zmianie nazwy wymagana jest powtórna synchronizacja. + Nowa nazwa konta + Zmień nazwę + Nazwa konta jest już zajęta + Nie udało się zmienić nazwy konta + Usuń konto + Naprawdę chcesz usunąć konto? + Wszystkie lokalne kopie książek adresowych, kalendarzy i list zadań zostaną usunięte. + synchronizuj tę kolekcję + tylko do odczytu + kalendarz + kontakty + dziennik + zadania + Pokaż tylko osobiste + Odśwież listę + Subskrypcje Webcal mogą być synchronizowane z zewnętrznymi aplikacjami. + Nie znaleziono aplikacji obsługującej Webcal + Zainstaluj ICSx⁵ + + Dodaj konto + polityką prywatności.]]> + Logowanie ogólne + Logowanie zależne od dostawcy + Kontynuj + Zaloguj + Logowanie za pomocą adresu e‑mail + Adres e‑mail + Wymagany poprawny adres e‑mail + Usługi są wykrywane używając rekordów DNS oraz znanych adresów URL.]]> + Hasło + Ukryj hasło + Pokaż hasło + Hasło (opcjonalnie) + Logowanie za pomocą adresu URL i nazwy użytkownika + Nazwa użytkownika + Nazwa użytkownika (opcjonalnie) + Podstawowy adres URL + usługi są również wykrywane używając rekordów DNS oraz znanych adresów URL.]]> + Wybierz certyfikat + Dodaj konto + Nazwa konta + Użycie znaku apostrofu (\') wydaje się powodować problemy na niektórych urządzeniach. + Użyj swojego adresu e‑mail jako nazwy konta, ponieważ Android będzie używał nazwy konta jako pola ORGANIZATOR dla wydarzeń, które stworzysz. Nie możesz posiadać dwóch kont o takiej samej nazwie. + Metoda grupowania kontaktów: + Wymagana nazwa konta + Nazwa konta jest już zajęta + Konto nie mogło być dodane + Zakończ + Logowanie zaawansowane + Brak certyfikatu klienta (opcjonalnie) + Certyfikat klienta: %s + Nie znaleziono certyfikatu + Zainstaluj certyfikat + Fastmail + Konto Fastmail + Zaloguj się poprzez Fastmail + Kontakty Google / Kalendarz + Konto Google + Zaloguj się za pomocą Google + ID klienta (opcjonalnie) + Polityce prywatności.]]> + Zasadami dotyczącymi danych użytkownika usług interfejsu API Google, w tym wymaganiami dotyczącymi Ograniczonego użytkowania.]]> + Nie udało się uzyskać kodu autoryzacyjnego + Nextcloud + Zaloguj się za pomocą Nextcloud + Spowoduje to rozpoczęcie procesu logowania do Nextcloud w przeglądarce internetowej. + Adres serwera Nextcloud + Zaloguj się + Nie można uzyskać adresu URL logowania + Nie udało się uzyskać danych logowania + Wykrywanie konfiguracji + Proszę czekać, odpytywanie serwera… + Nie można znaleźć usługi CalDAV lub CardDAV. + Podstawowy adres URL prawdopodobnie nie jest dostępnym adresem URL CalDAV/CardDAV, a wykrycie usługi nie powiodło się. + naszą listę przetestowanych usług i ich podstawowych adresów URL.]]> + Sprawdź również dokładnie uwierzytelnianie (zazwyczaj nazwę użytkownika i hasło). + Dalsze informacje techniczne są dostępne w logach. + Otwórz logi + + Synchronizacja + Częstotliwość synchronizacji kontaktów + Tylko ręcznie + Co %d minut oraz natychmiast przy zmianach lokalnych + Częstotliwość synchronizacji kalendarzy + Częstotliwość synchronizacji list zadań + + Tylko ręcznie + Co 15 minut + Co 30 minut + Co godzinę + Co 2 godziny + Co 4 godziny + Raz dziennie + + Synchronizuj tylko przez Wi‑Fi + Synchronizacja jest ograniczona do połączeń Wi‑Fi + Rodzaj połączenia nie jest brany pod uwagę + Ograniczenia SSID Wi‑Fi + Będzie synchronizować tylko w %s + Wszystkie połączenia Wi‑Fi będą używane + Oddzielone przecinkami nazwy (SSID) dozwolonych sieci Wi‑Fi (pozostaw puste dla wszystkich) + Ograniczenie WiFi SSID wymaga dalszych ustawień + Zarządzaj + VPN wymaga podstawowego Internetu + VPN bez sprawdzonego połączenia internetowego nie wystarczy do przeprowadzenia synchronizacji (zalecane) + Do przeprowadzenia synchronizacji wystarczy VPN bez sprawdzonego połączenia internetowego + Uwierzytelnianie + Nazwa użytkownika + Hasło lub hasło aplikacji + hasła aplikacji.]]> + Nowe hasło + Zaktualizuj hasło zgodnie z serwerem + Autoryzuj ponownie (OAuth) + Użyj jeśli dostęp został cofnięty + Poprawna autoryzacja + Certyfikat klienta + Brak dostępnego lub wybranego certyfikatu + Zainstaluj certyfikat + CalDAV + Limit czasowy przeszłych wydarzeń + Wszystkie wydarzenia zostaną zsynchronizowane + + Wydarzenia starsze niż jeden dzień zostaną zignorowane. + Wydarzenia starsze niż %d dni zostaną zignorowane. + Wydarzenia starsze niż %d dni zostaną zignorowane. + Wydarzenia starsze niż %d dni zostaną zignorowane. + + Wydarzenia, które są starsze niż podana liczba dni zostaną zignorowane (może być 0). Zostaw puste, aby synchronizować wszystkie wydarzenia. + Przypomnienie domyślne + + Przypomnienie domyślne na jedną minutę przed wydarzeniem + Przypomnienie domyślne %d minut(y) przed wydarzeniem + Przypomnienie domyślne %d minut(y) przed wydarzeniem + Przypomnienie domyślne %d minut(y) przed wydarzeniem + + Nie utworzono przypomnień domyślnych + Jeżeli domyślne przypomnienia mają być utworzone dla zdarzeń bez przypomnienia: pożądana liczba minut przed zdarzeniem. Pozostaw puste aby wyłączyć domyślne przypomnienia. + Zarządzaj kolorami kalendarza + Kolory kalendarza są resetowane przy każdej synchronizacji + Kolory kalendarza mogą być ustawiane przez inne aplikacje + Obsługa kolorów wydarzeń + Kolory wydarzeń są zsynchronizowane + Kolory wydarzeń nie są zsynchronizowane + CardDAV + Metoda grupowania kontaktów + + Grupy są odrębnymi vCards + Grupy są kategoriami dla pojedynczego kontaktu + + + Stwórz książkę adresową + Tworzenie książki adresowej poprzez CardDAV może nie być wspierane przez ten server. + Utwórz kalendarz + Domyslna strefa czasowa (opcjonalnie) + + Możliwe wpisy kalendarza + Wydarzenia + Zadania + Notatki/dziennik + Tworzenie kalendarza poprzez CalDAV może nie być wspierane przez ten server. + Kolor + Tytuł + Miejsce zapisu + Opis (opjonalnie) + Stwórz + + kontakty + wydarzenia + zadania + Usuń kolekcję + Ta kolekcja (%s) i wszystkie jej dane zostaną bezpowrotnie usunięte, zarówno lokalnie jak i na serwerze. + Synchronizacja + Synchronizacja włączona + Synchronizacja wyłączona + Tylko do odczytu + Tylko do odczytu (przez serwer) + Tylko do odczytu (przez politykę) + Tylko do odczytu (tylko lokalnie) + Odczyt/zapis + Tytuł + Opis + Właściciel + Wsparcie protokołu Push + Serwer zgłasza wsparcie protokołu Push + Zasubskrybowano %1$s, wygasa %2$s + Ostatnia synchronizacja (%s) + Adres (URL) + + Informacje debugowania + Archiwum ZIP + Zawiera informacje o debugowaniu i dzienniki + Udostępnij archiwum, aby przenieść je na komputer, wysłać e-mailem lub dołączyć do zgłoszenia do pomocy technicznej. + Udostępnij archiwum + Informacja debugowania załączona do tej wiadomości (wymaga wsparcia dla załączników w odbierającej aplikacji). + Błąd HTTP + Błąd serwera + Błąd WebDAV + Błąd we/wy + Żądanie zostało odrzucone przez serwer. + Żądany zasób (już) nie istnieje. + Serwer nie zezwala na żądany typ operacji. + Wystąpił problem po stronie serwera. Skontaktuj się proszę ze wsparciem serwera. + Wystapił nieoczekiwany błąd. Przejrzyj informacje debugowania dla dodatkowych szczegółów. + Obejrzyj szczegóły + Informacje debuggowania zostały zebrane + Zaangażowane zasoby + Powiązane z problemem + Zasób zdalny: + Zasób lokalny: + Logi + Szczegółowe logi są dostępne + Otwórz logi + Kopiuj adres URL + Sprawdź zasób + Notatka o prywatności + Logi i informacje debugowania mogą zawierać prywatne dane. Proszę być tego świadomymi w przypadku publicznego udostępniania. + Nie można wyświetlić zasobu + + Wystąpił błąd. + Wystąpił błąd HTTP. + Wystąpił błąd we/wy. + Pokaż szczegóły + + Punkty linkowania WebDAV + Limit użyty: %1$s / dostępny: %2$s + Udostępnij zawartość + Odlinkuj + Dodaj punkt linkowania WebDAV + Uzyskaj bezpośredni dostęp do plików w chmurze, dodając montowanie WebDAV! + jak działają punkty montowania WebDAV .]]> + Nazwa wyświetlana + WebDAV URL + Błędny URL + Punkt montowania i wyświetlana nazwa + Uwierzytelnianie + Nazwa użytkownika + Hasło + Nazwa użytkownika (opcjonalnie) + Hasło (opcjonalnie) + Dodaj link + Brak usługi WebDAV pod tym URL + Usuń punkt montowania + Szczegóły połączenia zostaną utracone, ale żadne pliki nie będą usunięte. + Uzyskiwanie dostępu do pliku WebDAV + Pobieranie pliku WebDAV + Wgrywanie pliku WebDAV + Punkt linkowania WebDAV + + Uprawnienia DAVx⁵ + Wymagane dodatkowe uprawnienia + %s zbyt stary/a + Minimalna wymagana wersja: %1$s + Nieudane uwierzytelnienie (sprawdź dane logowania) + Błąd sieci lub we/wy – %s + Błąd serwera HTTP — %s + Błąd lokalnego storage’u — %s + Błąd programowy (osiągnięto maksymalną liczbę ponownych prób) + Otrzymano błędny kontakt z serwera + Otrzymano błędne wydarzenie z serwera + Otrzymano błędne zadanie z serwera + Zignorowano jeden lub więcej nieważnych zasobów + Oczekująca synchronizacja + Zdalne dane uległy zmianie + + Synchronizuj wszystko + Synchronizuj wszystkie konta + Przycisk Etykietowanej Synchronizacji + Przycisk Synchronizacji Ikon + Naciśnij aby zsynchronizować ręcznie. + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..5a4c4e3 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,482 @@ + + + + A conta não existe (mais) + Lista de contatos do DAVx⁵ + Não mude a conta por aqui! Em vez disso, use o app diretamente para gerenciar as contas. + Apagar + Remover + Cancelar + Ativar + Este campo é necessário + Ajuda + Navegar para cima + Menu de opções + Compartilhar + Sincronização foi iniciada/enfileirada + O banco de dados está corrompido + Todas as contas foram removidas localmente. + Depuração + Outras mensagens importantes + Mensagens de estado de baixa prioridade + Sincronização + Erros de sincronização + Erros importantes que interrompem a sincronização, como respostas inesperadas do servidor + Alertas de sincronização + Problemas não fatais de sincronização, como certos arquivos inválidos + Erros de E/S e de rede + Tempos limite atingidos, problemas de conexão, etc. (geralmente temporários) + + Seus dados. Sua escolha. + Assuma o controle. + Intervalos periódicos de sincronização + Para sincronizar em intervalos periódicos, o %s deve ter permissão para executar-se em segundo plano. Caso contrário, o Android pode pausar a sincronização a qualquer momento. + Eu não preciso de sincronização periódica.* + Compatibilidade com o %s + Firmware de fabricantes específicas podem bloquear a sincronização. Se for atingido, você pode resolver isso manualmente. + Fiz as configurações necessárias. Não me lembre novamente.* + * Deixe desmarcado para ser lembrado depois. Pode ser reconfigurado nas configurações do app / %s + Mais informações + jtx Board + + Suporte a tarefas + Se seu servidor ter suporte a tarefas, elas podem ser sincronizadas com um app de tarefas compatível: + OpenTasks + Parece não ser mais desenvolvido – não é recomendado. + Tasks.org + alguns recursos.]]> + Nenhuma loja de apps disponível + Não preciso de suporte a tarefas.* + Software de código aberto + Estamos felizes que você usa o %s, que é software de código aberto. O desenvolvimento, a manutenção, e o suporte são um trabalho díficil. Considere contribuir (há varias formas) ou uma doação. Seria muito apreciado! + Como contribuir/doar + Não me lembre por + + %dmês + %d de meses + %d meses + + Avançar + + Permissões + O %s requer permissões para funcionar corretamente. + Todas as abaixo + Use isso para ativar todos os recursos (recomendado) + Todas as permissões foram concedidas + Permissões de contatos + Sem sincronização dos contatos (não é recomendado) + A sincronização dos contatos é possível + Permissões de calendário + Sem sincronização do calendário (não é recomendado) + A sincronização do calendário é possível + Permissão de notificação + Notificações desativadas (não é recomendado) + Notificações ativadas + Permissões do jtx Board + Permissões do OpenTasks + Permissões do Tasks + Sem sincronização de tarefas + A sincronização de tarefas é possível + Manter permissões + As permissões podem ser reconfiguradas automaticamente (não é recomendado) + As permissões não serão reconfiguradas automaticamente + Clique em Permissões > desmarque \"Gerenciar o app fora do uso\" + Se uma opção não funciona, use as configurações do app / Permissões. + Configurações do app + + Permissões de SSID do Wi-Fi + Para poder acessar o nome da rede Wi-Fi atual (o SSID), essas condições devem ser cumpridas: + Permissão de localização precisa + A permissão de localização foi concedida + A permissão de localização foi negada + Permissão de localização em segundo plano + Permitir o tempo todo + A permissão de localização está configurada para: %s + A permissão de localização não está configurada para: %s + O %s usa dados de localização (somente o SSID do Wi-Fi) para restringir a sincronização para somente um SSID de Wi-Fi. Isso pode acontecer até mesmo quando a sincronização está sendo executada em segundo plano. + Todos os dados de localização (que são somente o SSID do Wi-Fi) são usados apenas localmente e não são enviados para quaisquer lugares. + Localização sempre ativada + O serviço de localização está ativado + O serviço de localização está desativado + + Traduções + Bibliotecas + Versão %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e contribuidores + Este programa é distribuído SEM QUALQUER GARANTIA. É software livre e pode ser redistribuído sob algumas condições. + + Não foi possível criar o arquivo de registros + Agora registrando todas as atividades do %s + Visualizar/compartilhar + Desativar + + Adaptador de sincronização do CalDAV/CardDAV + Sobre / Licença + Retorno da beta + Instale um navegador da web + Configurações + Novidades e atualizações + Ferramentas + Links externos + Site + Manual + Perguntas frequentes + Para organizações + Comunidade + Apoie o projeto + Como contribuir + Política de privacidade + Boas-vindas ao DAVx⁵! + Conecte-se ao seu servidor e mantenha seus calendários e contatos sincronizados. + Sincronizar todas as contas + + As notificações estão desativadas. Você não será notificado sobre erros de sincronização. + A sincronização automática não está ativa (sem conexão verificada à internet). + Gerenciar conexões + A economia de dados está ativada. A sincronização em segundo plano está restrita. + Gerenciar economia de dados + A economia de bateria está ativada. A sincronização pode ser restrita. + Gerenciar economia de bateria + Há pouco espaço de armazenamento. O Android não sincronizará alterações locais imediatamente, mas sim na próxima sincronização periódica. + Gerenciar armazenamento + O provedor de calendários está ausente + Você desativou o app do sistema chamado \"Armazenamento de calendários\"? + O provedor de contatos está ausente + Você desativou o app do sistema chamado \"Armazenamento de contatos\"? + Gerenciar apps + + A detecção de serviço falhou + Não foi possível recarregar a lista de coleções + + Executando em primeiro plano + Em alguns dispositivos, isto é necessário para a sincronização automática. + + Configurações + Depuração + Mostrar informações de depuração + Visualizar/compartilhar registros e detalhes da configuração + Registro verboso + A coleta de registro está ativa. Você pode visualizar os registros nas informações de depuração. + A coleta de registros está desativada + Otimização de bateria + O app está isento (recomendado) + O app não está isento (não recomendado) + Conexão + Tipo da proxy + + Padrão do sistema + Sem proxy + HTTP + SOCKS (pro Orbot) + + Nome do servidor da proxy + Porta da proxy + Segurança + Permissões do app + Revise as permissões necessárias para a sincronização + Desconfiar dos certificados do sistema + ACs do sistema e adicionadas pelo usuário não serão confiadas + ACs do sistema e adicionadas pelo usuário serão confiadas (recomendado) + Se essa configuração está ativa, os certificados do sistema não são tratados como confiáveis. Isso significa que você terá que manualmente aceitar cada certificado (e também quando o servidor renova o seu certificado) ou a configuração da conta e a sincronização não funcionarão. + Reconfigurar certificados + Reconfigura a confiança de todos os certificados personalizados + Todos os certificados personalizados foram limpos + Interface do usuário + Configurações de notificações + Gerencie canais de notificação e suas configurações + Escolher tema + + Padrão do sistema + Claro + Escuro + + Reconfigurar dicas + Reativa as dicas que foram ignoradas anteriormente + Todas as dicas serão mostradas novamente + Integração + App de tarefas + Nenhum app compatível de tarefas encontrado + UnifiedPush (experimental) + Nenhum (desativar push) + Escolha um distribuidor + Nenhum distribuidor de push instalado + Nenhum servidor configurado + Pronto para receber mensagens push pelo %s + FCM (Google Play) + As mensagens push são sempre criptografadas. + + A conta foi removida + CardDAV + CalDAV + Webcal + São necessárias permissões adicionais para sincronizar essas coleções. + Gerenciar permissões + Sincronizar agora + Configurações da conta + Renomear conta + Dados locais que não foram salvos podem ser ignorados. Uma nova sincronização é necessária após uma renomeação. + Nome novo da conta + Renomear + O nome da conta já foi utilizado + Não foi possível renomear a conta + Apagar conta + Realmente apagar a conta? + Todas as cópias locais das listas de contatos, calendários e listas de tarefas serão apagadas. + sincronizar esta coleção + somente leitura + calendário + contatos + diário + tarefas + Mostrar somente pessoais + Recarregar lista + Inscrições de Webcal podem ser sincronizadas com apps externos. + Nenhum app compatível com Webcal encontrado + Instalar ICSx⁵ + + Adicionar conta + política de privacidade.]]> + Autenticação genérica + Autenticação específica ao provedor + Continuar + Entrar + Entrar com endereço de e-mail + Endereço de e-mail + Um endereço de e-mail válido é necessário + Os serviços são descobertos usando registros de DNS e URLs well-known.]]> + Senha + Ocultar senha + Mostrar senha + Senha (opcional) + Entrar com URL e nome de usuário + Nome do usuário + Nome do usuário (opcional) + URL base + serviços também são descobertosusando registros de DNS e URLs well-known.]]> + Selecionar certificado + Adicionar conta + Nome da conta + O uso de apóstrofos (\') pode causar problemas em alguns dispositivos. + Use o seu endereço de e-mail como o nome da conta pois o Android usará o nome como o campo ORGANIZER pata os eventos que cria. Você não pode ter duas contas com o mesmo nome. + Método de agrupamento de contatos: + O nome da conta é necessário + O nome da conta já foi utilizado + A conta não pôde ser adicionada + Concluir + Autenticação avançada + Sem certificado de cliente (opcional) + Certificado de cliente: %s + Nenhum certificado encontrado + Instalar certificado + Fastmail + Conta do Fastmail + Entrar com Fastmail + Google Contatos / Agenda + Conta do Google + Entrar com Google + ID do cliente (opcional) + política de privacidade para detalhes.]]> + Política de Dados de Usuário dos Google API Services, incluindo os requisitos de Uso Limitado.]]> + Não foi possível obter o código de autorização + Nextcloud + Entrar com Nextcloud + Isso iniciará o processo de autenticação do Nextcloud num navegador da web. + Endereço do servidor do Nextcloud + Entrar + Não foi possível obter a URL de autenticação + Não foi possível obter os dados de autenticação + Detecção de configuração + Aguarde, consultando o servidor… + Não foi possível encontrar o serviço de CalDAV ou CardDAV. + O URL base não parece ser um URL acessível de CalDAV/CardDAV e a detecção de serviço não foi bem-sucedida. + nossa lista de serviços testados e seus URLs base.]]> + Certifique-se da autenticação (normalmente nome de usuário e senha). + Mais informações técnicas estão disponíveis nos registros. + Visualizar registros + + Sincronização + Intervalo de sincronização dos contatos + Apenas manualmente + A cada %d minutos e imediatamente em alterações locais + Intervalo de sincronização dos calendários + Intervalo de sincronização das tarefas + + Apenas manualmente + A cada 15 minutos + A cada 30 minutos + A cada hora + A cada 2 horas + A cada 4 horas + Todo dia + + Sincronizar apenas por Wi-Fi + A sincronização está restrita a apenas conexões de Wi-Fi + O tipo de conexão não está sendo considerado + Restrição de SSID do Wi-Fi + Sincronizará apenas em %s + Todas as conexões Wi-Fi serão utilizadas + Nomes das redes Wi-Fi permitidas (SSIDs) separados por vírgulas (deixe em branco para todas) + A restrição de SSID de Wi-Fi requer configuração adicional + Gerenciar + Exigir conexão base verificada para VPNs + Uma VPN sem conexão base verificada não é suficiente para executar a sincronização (recomendado) + Uma VPN sem conexão base verificada é suficiente para executar a sincronização + Autenticação + Nome do usuário + Senha ou senha de app + senha de app.]]> + Senha nova + Atualize a senha de acordo com o seu servidor. + Autorizar novamente (OAuth) + Use caso o acesso for revogado + A autorização foi bem-sucedida + Certificado de cliente + Nenhum certificado disponível ou selecionado + Instalar certificado + CalDAV + Limite de tempo para eventos passados + Todos os eventos serão sincronizados + + Eventos que ocorreram a mais de um dia atrás serão ignorados + Eventos que ocorreram a mais de %d de dias atrás serão ignorados + Eventos que ocorreram a mais de %d dias atrás serão ignorados + + Os eventos que ocorreram antes desse número de dias serão ignorados (pode ser 0). Deixe em branco para sincronizar todos os eventos. + Lembrete padrão + + Lembrete padrão um minuto antes do evento + Lembrete padrão %d de minutos antes do evento + Lembrete padrão %d minutos antes do evento + + Nenhum lembrete padrão será criado + Se lembretes padrão devem ser criados para eventos sem um: o número de minutos desejado antes do evento. Deixe em branco para desativar os lembretes padrão. + Gerenciar cores do calendários + As cores dos calendários serão reconfiguradas a cada sincronização + As cores dos calendários podem ser configuradas por outros apps + Suporte a cores de eventos + As cores de eventos serão sincronizadas + As cores dos eventos não serão sincronizadas + CardDAV + Método de agrupamento dos contatos + + Os grupos são vCards separados + Os grupos são categorias por contato + + + Criar lista de contatos + A criação de listas de contatos pelo CardDAV pode não ser suportada pelo servidor. + Criar calendário + Fuso horário padrão (opcional) + + Possíveis itens do calendário + Eventos + Tarefas + Anotações / diário + A criação de calendários pelo CalDAV pode não ser suportada pelo servidor. + Cor + Título + Localização de armazenamento + Descrição (opcional) + Criar + + contatos + eventos + tarefas + Apagar coleção + Esta coleção (%s) e todos os seus dados serão removidos para sempre, tanto localmente como no servidor. + Sincronização + A sincronização está ativada + A sincronização está desativada + Somente leitura + Somente leitura (pelo servidor) + Somente leitura (pela política) + Somente leitura (apenas localmente) + Ler/gravar + Título + Descrição + Proprietário + Suporte a push + O servidor anuncia suporte a push + Inscrito em %1$s, vence às %2$s + Última sincronização (%s) + Endereço (URL) + + Informações de depuração + Arquivo ZIP + Contém informações de depuração e registros + Compartilhe o arquivo para transferi-lo para um computador, ou envie-o por e-mail, anexando ele a um ticket de suporte. + Compartilhar arquivo + Informações de depuração anexadas à mensagem (requer suporte a anexos no app destinatário) + Erro de HTTP + Erro do servidor + Erro de WebDAV + Erro de E/S + A solicitação foi negada pelo servidor. + O recurso solicitado não existe (mais). + O servidor não permite o tipo de operação solicitada. + Ocorreu um problema no lado do servidor. Contate o suporte do seu servidor. + Ocorreu um erro inesperado. Visualize as informações de depuração para detalhes. + Visualizar detalhes + As informações de depuração foram coletadas + Recursos envolvidos + Relacionados ao problema + Recurso remoto: + Recurso local: + Registros + Registros verbosos estão disponíveis + Visualizar registros + Copiar URL + Comunicado de privacidade + Os registros e as informações de depuração podem conter informações privadas. Tenha isso em mente ao compartilhá-os publicamente. + + Ocorreu um erro. + Ocorreu um erro de HTTP. + Ocorreu um erro de E/S. + Mostrar detalhes + + Montagens WebDAV + Cota utilizada: %1$s / disponível: %2$s + Compartilhar conteúdo + Desmontar + Adicionar montagem WebDAV + Acesse diretamente seus arquivos da nuvem adicionando uma montagem WebDAV! + como as montagens WebDAV funcionam.]]> + Nome de exibição + URL do WebDAV + URL inválido + Ponto de montagem e nome de exibição + Autenticação + Nome do usuário + Senha + Nome do usuário (opcional) + Senha (opcional) + Adicionar montagem + Nenhum serviço de WebDAV neste URL + Remover ponto de montagem + Os detalhes da conexão serão perdidos, mas nenhum arquivo será apagado. + Acessando arquivo do WebDAV + Baixando arquivo do WebDAV + Enviando arquivo do WebDAV + Montagem WebDAV + + Permissões do DAVx⁵ + São necessárias permissões adicionais + %s é muito antigo + Versão mínima necessária: %1$s + Falha na autenticação (certifique-se das credenciais) + Erro de rede ou E/S – %s + Erro do servidor de HTTP – %s + Erro do armazenamento local – %s + Erro suave (número máximo de tentativas atingido) + Contato inválido foi recebido do servidor + Evento inválido foi recebido do servidor + Tarefa inválida foi recebida do servidor + Ignorando um ou mais recursos inválidos + Sincronização pendente + Os dados remotos mudaram + + Sincronizar tudo + Sincronizar todas as contas + Toque para executar a sincronização manualmente. + + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..03766a8 --- /dev/null +++ b/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,452 @@ + + + + A conta não existe (mais) + Livro de endereços do DAVx⁵ + Não mude a conta por aqui! Em vez disso, use o app diretamente para gerenciar as contas. + Excluir + Remover + Cancelar + Ativar + Este campo é necessário + Ajuda + Navegar para cima + Menu de opções + Partilhar + Sincronização começou/enfileirada + Base de dados corrompida + Todas as contas foram removidas localmente. + Depuração + Outras mensagens importantes + Mensagens de status de baixa prioridade + Sincronização + Erros de sincronização + Erros importantes que interrompem a sincronização, como respostas inesperadas do servidor + Avisos de sincronização + Problemas de sincronização não graves, como determinados arquivos inválidos + Erros de rede e E/S + Tempos de espera, problemas de conexão, etc. (geralmente temporários) + + Seus dados. Sua escolha. + Assuma o controle. + Intervalos de sincronização regulares + Para sincronização em intervalos regulares, o %s deve ter permissão para executar em segundo plano. Caso contrário, o Android poderá pausar a sincronização a qualquer momento. + Eu não preciso de intervalos de sincronização regulares.* + Compatibilidade %s + Fiz as configurações necessárias. Não me lembre novamente.* + * Deixe desmarcado para ser lembrado mais tarde. Pode ser redefinido nas configurações do aplicativo / %s. + Mais informações + jtx Board + + Suporte a tarefas + Se tarefas são suportadas por seu servidor, elas podem ser sincronizadas com um app suportado de tarefas: + OpenTasks + Parece não ser mais desenvolvido -- não recomendado. + Tasks.org + não são suportados.]]> + Nenhuma loja de aplicativos disponível + Não preciso de suporte a tarefas.* + Software Livre + Estamos felizes por usar o %s, que é um software de código aberto. Desenvolvimento, manutenção e suporte são um trabalho árduo. Considere contribuir (existem várias maneiras) ou fazer uma doação. Seria muito apreciado! + Como contribuir/doar + Não me lembrar por + + %d mês + %d de meses + %d meses + + Próximo + + Permissões + %s requer permissões para trabalhar corretamente. + Todos os abaixo + Use isto para ativar todas as funcionalidades (recomendado) + Todas as permissões concedidas + Permissões de contatos + Nenhum contato sincronizado (não recomendado) + Possível sincronização de contatos + Permissões de calendário + Nenhum calendário sincronizado (não recomendado) + Possível sincronização de calendário + Permissão de notificação + Notificações desativadas (não recomendado) + Notificações ativadas + Permissões do jtx Board + Permissões do OpenTasks + Permissões das tarefas + Sem sincronização de tarefas + Sincronização de tarefas possível + Manter as permissões + As permissões podem ser redefinidas automaticamente (não recomendado) + As permissões não serão redefinidas automaticamente + Toque em Permissões > desmarque \"Pausar atividade no app quando não usado\" + Se uma opção não funcionar, use as configurações / permissões do aplicativo. + Configurações do aplicativo + + Permissões WiFi SSID + Para que seja possível acessar o nome do WiFi atual (SSID), essas condições tem que ser compridas: + Permissão de localização precisa + Permissão de localização concedida + Permissão de localização negada + Permissão de acesso à localização em segundo plano + Permitir o tempo todo + Permissão de localização está definida como:%s + Permissão de localização não está definida como:%s + O %susa dados de localização (somente os SSIDs de Wi-Fi) para restringir a sincronização em uma rede Wi-Fi específica. Isso pode acontecer mesmo quando a sincronização é executada em segundo plano. + Todos os dados de localização (que são somente SSIDs de Wi-Fi) são usados somente localmente e não são enviados para qualquer servidor. + Localização sempre ativa + Serviço de localização está ativado + Serviço de localização está desativado + + Traduções + Bibliotecas + Versão %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e contribuidores + Este programa é distribuído SEM NENHUMA GARANTIA. Ele é software livre e pode ser redistribuído sob algumas condições. + + Não foi possível criar o arquivo de log + Registrando todas as atividades de %s + Ver/partilhar + Desativar + + Sincronização de CalDAV/CardDAV + Sobre / Licença + Comentários sobre a versão beta + Instale um navegador Web + Configurações + Novidades e atualizações + Ferramentas + Links externos + Site na Web + Manual + Perguntas fequentes + Comunidade + Apoie o projeto + Como contribuir + Política de privacidade + Bem-vindo ao DAVx⁵! + Conecte-se ao seu servidor e mantenha seus calendários e contatos sincronizados. + Sincronizar todas as contas + + As notificações estão desativadas. Você não será notificado sobre erros de sincronização. + Sincronização automática inativa (sem conexão à internet verificada). + Gerenciar conexões + A economia de dados está ativada. A sincronização em segundo plano está restrita. + Gerenciar a economia de dados + A economia de bateria está ativada. A sincronização pode estar restrita. + Gerenciar a economia de bateria + Pouco espaço de armazenamento. O Android não sincronizará as mudanças locais imediatamente, mas sim na próxima sincronização regular. + Gerenciar armazenamento + Provedor de calendários ausente + Você desativou o app do sistema chamado \"Armazenamento de calendários\"? + Provedor de contatos ausente + Você desativou o app do sistema chamado \"Armazenamento de contatos\"? + Gerenciar apps + + Falha na detecção do serviço + Não foi possível atualizar a lista da coleção + + Executando em primeiro plano + Em alguns dispositivos, isto é necessário para a sincronização automática. + + Configurações + Depuração + Mostrar informações de depuração + Ver/compartilhar configurações de configuração e registros + Registro de atividades detalhado + O registro está ativo. Você pode ver os registros como parte das informações de depuração. + Registro de atividades desativado + Otimização da bateria + O app está isento (recomendado) + As restrições de bateria se aplicam (não recomendado) + Conexão + Tipo de proxy + + Padrão do sistema + Sem proxy + HTTP + SOCKS (para Orbot) + + Nome do host da proxy + Porta do proxy + Segurança + Permissões do aplicativo + Revise as permissões necessárias para sincronização + Desconfiar dos certificados de sistema + ACs adicionadas pelo usuário e pelo sistema não serão confiáveis + ACs adicionadas pelo usuário e pelo sistema serão confiáveis (recomendado) + Se essa configuração está ativa, os certificados do sistema não são tratados como confiáveis. Isso significa que você terá que manualmente aceitar cada certificado (e quando o servidor renova o seu certificado) ou a configuração da conta e a sincronização não funcionarão. + Redefinir certificados não-confiáveis + Restaura a confiança de todos os certificados personalizados + Todos os certificados personalizados foram restaurados + Interface de usuário + Configurações das notificações + Gerenciar os canais de notificação e suas configurações + Escolha um tema + + Padrão do sistema + Claro + Escuro + + Restaurar sugestões + Restaura as sugestões que foram descartadas anteriormente + Todas as sugestões serão exibidas novamente + Integração + App de tarefas + Nenhum app de tarefas compatível encontrado + UnifiedPush (experimental) + Nenhum (desativar push) + Escolha um distribuidor + Nenhum distribuidor push instalado + Nenhum servidor configurado + Pronto para receber mensagems push pelo %s + + CardDAV + CalDAV + WebCal + Permissões adicionais são necessárias para sincronizar essas coleções. + Gerenciar permissões + Sincronizar agora + Configurações da conta + Renomear conta + Dados locais que não foram salvos podem ser ignorados. Uma nova sincronização é necessária após uma renomeação. + Nome novo da conta + Renomear + O nome da conta já foi utilizado + Não foi possível renomear a conta + Excluir conta + Deseja excluir a conta? + Todas as cópias locais dos livros de endereços, calendários e listas de tarefas serão excluídas. + sincronizar esta coleção + Somente leitura + calendário + contatos + jornal + tarefas + Mostrar somente pessoal + Recarregar lista + Inscrições WebCAL podem ser sincronizadas com apps externos. + Não foi encontrado um aplicativo capaz de lidar com Webcal + Instalar ICSx⁵ + + Adicionar conta + política de privacidade.]]> + Login genérico + Login de provedor específico + Continuar + Autenticar + Autenticação com endereço de e-mail + Endereço de e-mail + É necessário um e-mail válido + Serviços são descobertos através do DNS e URLs conhecidas.]]> + Senha + Ocultar senha + Mostrar senha + Autenticação com usuário e URL + Usuário + URL base + serviços são também descobertos usando DNS e URLs conhecidas.]]> + Selecionar certificado + Adicionar conta + Nome da conta + O uso de apóstrofos (\') pode causar problemas em certos dispositivos. + Use seu endereço de e-mail como nome da conta porque o Android irá usar esse nome como campo AGENDA nos eventos que você criar. Não é possível ter duas contas com o mesmo nome. + Método do grupo Contato: + É necessário um nome de conta + O nome da conta já foi utilizado + A conta não pôde ser adicionada + Concluir + Login avançado + Certificado do cliente: %s + Nenhum certificado encontrado + Instalar certificado + Google Contatos / Agenda + Conta Google + Fazer login com o Google + ID do cliente (opcional) + Política de Privacidade para detalhes.]]> + Google API Services User Data Policy, incluindo com os requisitos de Uso Limitado.]]> + Não foi possível obter o código de autorização + Nextcloud + Fazer login com Nextcloud + Isto iniciará o processo de login do Nextcloud em um navegador web. + Endereço do servidor Nextcloud + Fazer login + Não foi possível obter a URL de login + Não foi possível obter os dados de login + Detecção de configuração + Aguarde, procurando servidor… + Não foi possível encontrar o serviço CalDAV ou CardDAV. + A URL base não parece ser uma URL de CalDAV/CardDAV accesível e a detecção de serviço não foi sucedida. + nossa lista de serviços testados e suas URLs base.]]> + Tenha certeza dos dados de autenticação (normalmente nome de usuário e senha) + Mais informações técnicas estão disponíveis nos logs. + Visualizar logs + + Sincronização + Intervalo sinc. de contatos + Apenas manualmente + A cada %d minutos + imediatamente nas alterações locais + Intervalo sinc. de calendários + Intervalo sinc. de tarefas + + Apenas manualmente + A cada 15 minutos + A cada 30 minutos + A cada hora + A cada 2 horas + A cada 4 horas + Uma vez por dia + + Sincronizar apenas por Wi-Fi + Sincronização restrita a conexões Wi-Fi + O tipo de conexão não é considerado + Restrição de WiFi SSID + Sincronizar apenas com %s + Todas as conexões WiFi serão usadas + Nomes separados por vírgula (SSIDs) das redes WiFi (deixe em branco para todas) + A restrição de SSID de WiFi requer configuração adicional + Gerenciar + VPN requer conexão à internet + VPN sem uma conexão à internet validada não é suficiente para executar uma sincronização (recomendado) + VPN sem uma conexão à internet validada é suficiente para executar uma sincronização + Autenticação + Nome do usuário + Senha nova + Atualize a senha de acordo com seu servidor + Certificado do cliente + Nenhum certificado disponível ou selecionado + Instalar certificado + CalDAV + Limite de tempo para eventos passados + Todos os eventos serão sincronizados + + Os eventos que ocorreram a mais de um dia serão ignorados + Eventos que ocorreram a mais de %d dias serão ignorados + Eventos que ocorreram a mais de %d dias serão ignorados + + Os eventos que ocorreram antes desse número de dias serão ignorados (pode ser 0). Deixe em branco para sincronizar todos os eventos. + Lembrete padrão + + Lembrete padrão um minuto antes do evento + Lembrete padrão %d minutos antes do evento + Lembrete padrão %d minutos antes do evento + + Nenhum lembrete padrão está criado + Se lembretes padrão devem ser criados para eventos sem lembrete: o número desejado de minutos antes do evento. Deixe em branco para desativar os lembretes padrão. + Gerenciar cores dos calendários + Cores dos calendários são redefinidas quando uma sincronização é feita + Cores dos calendários podem ser definidas por outros apps + Suporte para cor de evento + Cores de eventos são sincronizadas + Cores de eventos não são sincronizadas + CardDAV + Método do grupo Contato + + Grupos são vCards separados + Grupos são categorias por contato + + + Criar livro de endereços + A criação de livro de endereços por CardDAV pode não ser suportada pelo servidor. + Criar calendário + + Possíveis itens de calendário + Eventos + Tarefas + Notas / Diário + A criação de calendários por CalDAV pode não ser suportada pelo servidor. + Cor + Título + Local de armazenamento + Descrição (opcional) + Criar + + contatos + tarefas + Excluir coleção + Esta coleção (%s) e todos os seus dados serão removidos permanentemente, tanto localmente como no servidor. + Sincronização + Sincronização ativada + Sincronização desativada + Somente leitura + Somente leitura (pelo servidor) + Somente-leitura (pela política) + Somente leitura (somente localmente) + Leitura/escrita + Título + Descrição + Proprietário + Suporte à Push + Servidor anuncia suporte à Push + Inscrito em %1$s, expira às %2$s + Última sincronização (%s) + Endereço (URL) + + Informações de depuração + Arquivo ZIP + Contém informações de debug e logs + Compartilhe o arquivo para transferir ele para um computador, para enviar por e-mail ou para anexar ele à um ticket de suporte. + Partilhar arquivo + Informações de depuração anexadas a esta mensagem (requer suporte a anexo pelo aplicativo receptor). + Erro HTTP + Erro do servidor + Erro do WebDAV + Erro de E/S + Veja detalhes + Informações sobre depuração foram coletadas + Recursos envolvidos + Relacionado ao problema + Recurso remoto: + Recurso local: + Registros + Registros descritivos disponíveis + Visualizar logs + Copiar URL + + Ocorreu um erro. + Ocorreu um erro de HTTP. + Ocorreu um erro de leitura/gravação. + Mostrar detalhes + + Pastas WebDAV + Quota utilizada: %1$s / disponível: %2$s + Partilhar conteúdo + Desmontar + Adicionar uma pasta WebDAV + Acesse diretamente seus arquivos da nuvem adicionando uma pasta WebDAV! + Nome de exibição + URL do WebDAV + URL inválida + Autenticação + Nome do usuário + Palavra passe + Adicionar pasta + Nenhum serviço WebDAV nesta URL + Remover pasta + Detalhes de conexão serão perdidos, mas nenhum arquivo será excluído. + Acessando arquivo do WebDAV + Baixando arquivo do WebDAV + Enviando arquivo do WebDAV + Pasta WebDAV + + Permissões do DAVx⁵ + É necessário permissões adicionais + %s muito antigo + Versão mínima exigida: %1$s + Falha de autenticação (verifique as credenciais) + Erro de rede ou E/S – %s + Erro no servidor HTTP – %s + Erro de armazenamento local – %s + Erro simples (número de tentativas máximo atingido) + Contato inválido recebido do servidor + Evento inválido recebido do servidor + Tarefa inválida recebida do servidor + Ignorando um ou mais recursos inválidos + Sincronização em espera + Dados remotos podem ter mudado + + Sincronizar tudo + Sincronizar todas as contas + + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..15c8610 --- /dev/null +++ b/app/src/main/res/values-ro/strings.xml @@ -0,0 +1,478 @@ + + + + Contul nu (mai) există + Agenda DAVx⁵ + Nu schimba contul aici! Utilizează direct aplicația pentru a gestiona conturile în schimb. + Șterge + Elimină + Anulează + Activează + Acest câmp este obligatoriu + Ajutor + Navigare în sus + Meniul Opțiuni + Distribuie + Sincronizare începută/pusă în coadă + Bază de date deteriorată + Toate conturile au fost eliminate local. + Depanare + Alte mesaje importante + Mesaje de stare cu prioritate redusă + Sincronizare + Erori de sincronizare + Erori importante care opresc sincronizarea, cum ar fi răspunsurile neașteptate ale serverului + Avertismente de sincronizare + Probleme de sincronizare non-fatale, cum ar fi anumite fișiere nevalide + Erori de rețea și I/O + Expirare, probleme de conexiune etc. (adesea temporare) + + Datele tale. Alegerea ta. + Preia controlul. + Intervale regulate de sincronizare + Pentru sincronizare la intervale regulate, %s trebuie să aibă voie să ruleze în fundal. În caz contrar, Android poate întrerupe sincronizarea în orice moment. + Nu am nevoie de intervale regulate de sincronizare.* + Compatibilitate %s + Firmware-ul specific vendorului poate bloca sincronizarea. Dacă ești afectat, poți rezolva acest lucru manual. + Am făcut setările necesare. Nu-mi mai aminti.* + * Lasă nebifat pentru a fi reamintit mai târziu. Poate fi resetat în setările aplicației / %s. + Mai multe informații + Placă de bază jtx + + Suport pentru sarcini + Dacă sarcinile sunt acceptate de server, acestea pot fi sincronizate cu o aplicație de sarcini acceptată: + OpenTasks + Nu pare a mai fi dezvoltat – nu este recomandat. + Tasks.org + nu sunt acceptate.]]> + Nu există un magazin de aplicații disponibil + Nu am nevoie de suport pentru sarcini.* + Software cu sursă deschisă + Ne bucurăm că utilizezi %s, care este un software open-source. Dezvoltarea, întreținerea și suportul sunt o muncă grea. Ia în considerare contribuția (există mai multe moduri) sau o donație. Ar fi foarte apreciat! + Cum să contribui/donezi + Nu-mi aminti + + %d lună + %d luni + %d luni + + Înainte + + Permisiuni + %s necesită permisiuni pentru a funcționa corect. + Toate cele de mai jos + Utilizează aceasta pentru a activa toate funcțiile (recomandat) + Toate permisiunile sunt acordate + Permisiuni Contacte + Fără sincronizare de contacte (nu este recomandat) + Este posibilă sincronizarea contactelor + Permisiuni pentru calendar + Fără sincronizare calendar (nu este recomandat) + Sincronizarea calendarului este posibilă + Permisiune de notificare + Notificări dezactivate (nu este recomandat) + Notificări activate + Permisiuni pentru jtx Board + Permisiuni OpenTasks + Permisiuni pentru sarcini + Nicio sincronizare a sarcinilor + Este posibilă sincronizarea sarcinilor + Păstrează permisiunile + Permisiunile pot fi resetate automat (nu este recomandat) + Permisiunile nu vor fi resetate automat + Clic pe Permisiuni > debifează „Elimină permisiunile dacă aplicația nu este utilizată” + Dacă un comutator nu funcționează, utilizează setările/permisiunile aplicației. + Setările aplicației + + Permisiuni SSID WiFi + Pentru a putea accesa numele actual WiFi (SSID), trebuie îndeplinite următoarele condiții: + Permisiune de locație precisă + Permisiunea de locație acordată + Permisiunea de locație refuzată + Permisiunea de locație în fundal + Permite tot timpul + Permisiunea locației setată la: %s + Permisiunea de locație nu este setată la: %s + %s folosește datele locației (doar WiFi SSID) numai pentru a restricționa sincronizarea la un anumit SSID WiFi. Acest lucru se va întâmpla chiar și atunci când sincronizarea rulează în fundal. + Toate datele locației (doar WiFi SSID) sunt folosite doar local și nu sunt trimise nicăieri. + Locația este întotdeauna activată + Serviciul de localizare este activat + Serviciul de localizare este dezactivat + + Traduceri + Biblioteci + Versiune %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (inginerie web bitfire GmbH) și contribuitori + Acest program vine cu ABSOLUT NICIO GARANȚIE. Este software gratuit și ești binevenit să îl redistribui în anumite condiții. + + Nu s-a putut crea fișierul jurnal + Acum se înregistrează toate activitățile %s + Vizualizare/distribuire + Dezactivează + + Adaptor de sincronizare CalDAV/CardDAV + Despre / Licență + Feedback beta + Instalează un browser web + Setări + Știri și actualizări + Instrumente + Link-uri externe + Pagină web + Manual + Întrebări frecvente + Pentru organizații + Comunitate + Susține proiectul + Cum să contribui + Politica de confidențialitate + Bun venit la DAVx⁵! + Conectează-te la server și păstrează calendarele și contactele sincronizate. + Sincronizează toate conturile + + Notificări dezactivate. Nu vei fi notificat despre erorile de sincronizare. + Sincronizarea automată nu este activă (fără conexiune la internet verificată). + Gestionează conexiunile + Economizorul de date este activat. Sincronizarea în fundal este restricționată. + Gestionează economizorul de date + Economisirea bateriei este activată. Sincronizarea poate fi restricționată. + Gestionează economisirea bateriei + Spațiu de depozitare redus. Android nu va sincroniza modificările locale imediat, ci în timpul următoarei sincronizări obișnuite. + Gestionează stocarea + Furnizorul de calendar lipsește + Ai dezactivat aplicația de sistem „Stocare Calendar”? + Furnizorul de contacte lipsește + Ai dezactivat aplicația de sistem „Stocare Contacte”? + Gestionează aplicațiile + + Detectarea serviciului a eșuat + Lista de colecții nu a putut fi actualizată + + Rulează în prim-plan + Pe unele dispozitive, acest lucru este necesar pentru sincronizarea automată. + + Setări + Depanare + Afișează informațiile de depanare + Vizualizează/partajează detaliile de configurare și jurnalele + Jurnalizare detaliată + Înregistrarea este activă. Poți vizualiza jurnalele ca parte a informațiilor de depanare. + Înregistrarea este dezactivată + Optimizarea bateriei + Aplicația este exclusă (recomandat) + Se aplică restricții pentru baterie (nu este recomandat) + Conexiune + Tip proxy + + Implicit + Fără proxy + HTTP + SOCKS (pentru Orbot) + + Nume gazdă proxy + Port proxy + Securitate + Permisiunile aplicației + Examinează permisiunile necesare pentru sincronizare + Nu avea încredere în certificatele de sistem + CA de sistem și de utilizator nu vor fi de încredere + CA de sistem și de utilizator vor fi de încredere (recomandat) + Dacă această setare este activă, certificatele de sistem nu sunt considerate ca fiind de încredere. Aceasta înseamnă că va trebui să accepți manual fiecare certificat (de asemenea, atunci când serverul își reînnoiește certificatul) sau configurarea contului și sincronizarea nu va funcționa. + Resetează certificatele de (ne)încredere + Resetează încrederea tuturor certificatelor personalizate + Toate certificatele personalizate au fost șterse + Interfață de utilizator + Setări de notificare + Gestionează canalele de notificare și setările acestora + Selectează tema + + Ca în sistem + Luminoasă + Întunecată + + Resetează sugestiile + Reactivează sugestiile care au fost respinse anterior + Toate sugestiile vor fi afișate din nou + Integrare + Aplicația de sarcini + Nu a fost găsită nicio aplicație de sarcini compatibilă + UnifiedPush (experimental) + Nimic (dezactivare Push) + Alege un distribuitor + Nu este instalat un distribuitor push + Niciun punct final configurat + Gata să primească mesaje push peste %s + FCM (Google Play) + Mesajele push sunt întotdeauna criptate. + + Contul a fost eliminat + CardDAV + CalDAV + Webcal + Sunt necesare permisiuni suplimentare pentru a sincroniza aceste colecții. + Gestionează permisiunile + Sincronizează acum + Setările contului + Redenumește contul + Datele locale nesalvate pot fi respinse. Resincronizarea este necesară după redenumire. + Nume cont nou + Redenumește + Numele contului este deja luat + Nu s-a putut redenumi contul + Șterge contul + Chiar ștergi contul? + Toate copiile locale ale agendelor, calendarelor și listelor de sarcini vor fi șterse. + sincronizează această colecție + numai pentru citire + calendar + contacte + jurnal + sarcini + Afișează numai personal + Actualizează lista + Abonamentele Webcal pot fi sincronizate cu aplicații externe. + Nu a fost găsită nicio aplicație compatibilă cu Webcal + Instalează ICSx⁵ + + Adaugă contul + Politica de confidențialitate.]]> + Autentificare generică + Autentificare specifică furnizorului + Continuă + Autentificare + Conectează-te cu adresa de e-mail + Adresa de e-mail + Este necesară o adresă de e-mail validă + Serviciile sunt descoperite folosind înregistrări DNS și adrese URL bine-cunoscute.]]> + Parolă + Ascunde parola + Afișează parola + Parolă (opțional) + Conecteează-te cu adresa URL și numele de utilizator + Nume de utilizator + Nume de utilizator (opțional) + Adresa URL de bază + serviciile sunt de asemenea descoperite folosind înregistrări DNS și adrese URL bine-cunoscute.]]> + Selectează certificatul + Adaugă contul + Nume de cont + Utilizarea apostrofelor (\') pare să cauzeze probleme pe unele dispozitive. + Utilizează adresa de e-mail ca nume de cont, deoarece Android va folosi numele contului ca câmp ORGANIZATOR pentru evenimentele pe care le creezi. Nu poți avea două conturi cu același nume. + Metoda de grupare a contactelor: + Numele contului este necesar + Numele contului este deja luat + Contul nu a putut fi adăugat + Finalizează + Autentificare avansată + Fără certificat de client (opțional) + Certificat de client: %s + Nu a fost găsit niciun certificat + Instalare certificat + Fastmail + Cont Fastmail + Conectează-te cu Fastmail + Contacte Google / Calendar + Cont Google + Conectează-te cu Google + ID client (opțional) + Politica de confidențialitate pentru detalii.]]> + Politica privind datele utilizatorilor serviciilor API Google, inclusiv cerințele de utilizare limitată.]]> + Nu s-a putut obține codul de autorizare + Nextcloud + Conectare cu Nextcloud + Aceasta va porni fluxul de conectare Nextcloud într-un browser web. + Adresa serverului Nextcloud + Conectare + Nu s-a putut obține adresa URL de conectare + Nu s-au putut obține datele de conectare + Detectarea configurației + Se interoghează serverul… + Nu s-a putut găsi serviciul CalDAV sau CardDAV. + Adresa URL de bază nu pare să fie o adresă URL CalDAV/CardDAV accesibilă, iar detectarea serviciului nu a avut succes. + lista de servicii testate și adresele lor URL de bază.]]> + Verifică, de asemenea, și autentificarea (de obicei, numele de utilizator și parola). + Informații tehnice suplimentare sunt disponibile în jurnale. + Vezi jurnalele + + Sincronizare + Interval de sincronizare a contactelor + Doar manual + La fiecare %d minute + imediat la modificări locale + Interval de sincronizare a calendarelor + Interval de sincronizare a sarcinilor + + Doar manual + La fiecare 15 minute + La fiecare 30 de minute + La fiecare oră + La fiecare 2 ore + La fiecare 4 ore + O dată pe zi + + Sincronizare numai prin WiFi + Sincronizarea este limitată la conexiunile WiFi + Tipul de conexiune nu este luat în considerare + Restricție SSID WiFi + Se va sincroniza numai prin %s + Toate conexiunile WiFi vor fi utilizate + Nume separate prin virgulă (SSID) ale rețelelor WiFi permise (lasă necompletat pentru toate) + Restricția SSID WiFi necesită setări suplimentare + Gestionează + VPN necesită internetul de bază + VPN fără conexiune validată la Internet nu este suficient pentru a rula sincronizarea (recomandat) + VPN fără conexiune validată la Internet este suficient pentru a rula sincronizarea + Autentificare + Nume de utilizator + Parolă sau parola aplicației + parola aplicației.]]> + Parolă nouă + Actualizează parola în funcție de server. + Autorizează din nou (OAuth) + Utilizează atunci când accesul a fost revocat + Autorizare cu succes + Certificat de client + Niciun certificat disponibil sau selectat + Instalare certificat + CalDAV + Limită de timp pentru evenimentele din trecut + Toate evenimentele vor fi sincronizate + + Evenimentele cu mai mult de o zi în trecut vor fi ignorate + Evenimentele cu peste %d zile în trecut vor fi ignorate + Evenimentele cu peste %d zile în trecut vor fi ignorate + + Evenimentele care depășesc acest număr de zile în trecut vor fi ignorate (poate fi 0). Lasă necompletat pentru a sincroniza toate evenimentele. + Memento implicit + + Memento implicit cu un minut înainte de eveniment + Memento implicit cu %d minute înainte de eveniment + Memento implicit cu %d minute înainte de eveniment + + Nu sunt create mementouri implicite + Dacă vor fi create memento-uri implicite pentru evenimente fără memento: numărul dorit de minute înainte de eveniment. Lasă necompletat pentru a dezactiva memento-urile implicite. + Gestionează culorile calendarului + Culorile calendarului sunt resetate la fiecare sincronizare + Culorile calendarului pot fi setate de alte aplicații + Suport pentru culoarea evenimentului + Culorile evenimentelor sunt sincronizate + Culorile evenimentelor nu sunt sincronizate + CardDAV + Metoda de grupare a contactelor + + Grupurile sunt vCard-uri separate + Grupurile sunt categorii per-contact + + + Creează agendă de adrese + Crearea agendei prin CardDAV poate să nu fie acceptată de server. + Creează un calendar + Fus orar implicit (opțional) + + Posibile intrări din calendar + Evenimente + Sarcini + Note/jurnal + Crearea calendarului prin CalDAV poate să nu fie acceptată de server. + Culoare + Titlu + Locația de stocare + Descriere (opțional) + Crează + + contacte + evenimente + sarcini + Șterge colecția + Această colecție (%s) și toate datele sale vor fi șterse definitiv, atât local, cât și de pe server. + Sincronizare + Sincronizarea este activată + Sincronizarea este dezactivată + Numai citire + Numai citire (de pe server) + Numai citire (după politică) + Numai citire (doar local) + Citire/scriere + Titlu + Descriere + Proprietar + Suport Push + Serverul informează despre suportul Push + Abonat la %1$s, expiră la %2$s + Ultima sincronizare (%s) + Adresă (URL) + + Informații de depanare + Arhivă ZIP + Conține informații de depanare și jurnale + Partajează arhiva pentru a o transfera pe un computer, pentru a o trimite prin e-mail sau pentru a o atașa la un bilet de asistență. + Partajează arhiva + Informații de depanare atașate la acest mesaj (necesită suport pentru atașamentele aplicației care primește). + Eroare HTTP + Eroare de server + Eroare WebDAV + Eroare I/O + Vezi detaliile + Au fost colectate informații de depanare + Resurse implicate + Legat de problema + Resursa de la distanță: + Resursa locală: + Jurnale + Jurnalele detaliate sunt disponibile + Vezi jurnalele + Notificare de confidențialitate + Jurnalele și informațiile de depanare pot conține informații private. Fii conștient de acest lucru atunci când îl publici. + + A avut loc o eroare. + A apărut o eroare HTTP. + A apărut o eroare I/O. + Afișează detaliile + + Montări WebDAV + Cotă utilizată: %1$s / disponibilă: %2$s + Partajează conținutul + Demontează + Adaugă o montare WebDAV + Accesează direct fișierele din cloud adăugând o montare WebDAV! + cum funcționează montările WebDAV.]]> + Numele afișat + URL WebDAV + URL greșit + Punctul de montare și numele de afișare + Autentificare + Nume de utilizator + Parolă + Nume de utilizator (opțional) + Parolă (opțional) + Adaugă montare + Niciun serviciu WebDAV la această adresă URL + Elimină punctul de montare + Detaliile conexiunii se vor pierde, dar niciun fișier nu va fi șters. + Se accesează fișierul WebDAV + Se descarcă fișierul WebDAV + Se actualizează fișierul WebDAV + Montare WebDAV + + Permisiuni DAVx⁵ + Sunt necesare permisiuni suplimentare + %s prea vechi + Versiunea minimă necesară: %1$s + Autentificare eșuată (verifică datele de conectare) + Eroare de rețea sau I/O – %s + Eroare de server HTTP – %s + Eroare de stocare locală – %s + Eroare soft (încercări maxime atinse) + S-a primit contact nevalid de la server + S-a primit eveniment nevalid de la server + S-a primit sarcină nevalidă de la server + Ignorarea uneia sau mai multor resurse nevalide + Sincronizare în așteptare + Datele de la distanță s-au schimbat + + Sincronizează tot + Sincronizează toate conturile + Eticheta butonului de sincronizare + Pictograma butonului de sincronizare + Atinge pentru a rula sincronizarea manual. + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..92e124c --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,489 @@ + + + + Аккаунт (больше) не существует + Адресная книга DAVx⁵ + Не меняйте аккаунт здесь! Вместо этого используйте приложение для управления учетными записями. + Удалить + Удалить + Отмена + Включить + Это поле является обязательным + Помощь + Перейти наверх + Меню параметров + Поделиться + Синхронизация начата/завершена + База данных повреждена + Все учетные записи были удалены локально. + Отладка + Другие важные сообщения + Низкоприоритетные сообщения о состоянии + Синхронизация + Ошибки синхронизации + Важные ошибки, которые останавливают синхронизацию, например, неожиданные ответы сервера. + Предупреждения синхронизации + Некритичные проблемы с синхронизацией, такие как некоторые неверные файлы + Ошибки сети и ввода/вывода + Таймауты, проблемы с подключением и т. д. (часто временные) + + Ваши данные. Ваш выбор. + Возьмите под контроль. + Регулярные интервалы синхронизации + Для обеспечения синхронизации с регулярными интервалами необходимо разрешить работу %s в фоновом режиме. В противном случае, Android может приостановить синхронизацию в любое время. + Мне не нужна синхронизация с регулярными интервалами.* + Совместимость %s + Прошивка конкретного производителя может блокировать синхронизацию. Если вы столкнулись с этой проблемой, ее можно решить только самостоятельно. + Я выполнил необходимые настройки. Больше не напоминать.* + * Чтобы получить предупреждение позже, снимите флажок. Можно сбросить в настройках приложения / %s. + Дополнительная информация + jtx Board + + Поддержка задач + Если задачи поддерживаются вашим сервером, их можно синхронизировать при помощи соответствующего приложения: + OpenTasks + По всей видимости, больше не разрабатывается (не рекомендуется) + Tasks.org + не поддерживаются.]]> + Магазин приложений недоступен + Мне не нужна поддержка задач.* + ПО с открытым исходным кодом + Мы рады, что вы используете %s, программное обеспечение с открытым исходным кодом. Разработка, сопровождение и поддержка - это тяжелая работа. Пожалуйста, подумайте о том, чтобы внести свой вклад (существует множество способов) или сделать пожертвование. Будем очень признательны! + Как внести свой вклад/пожертвовать + Не напоминайте мне об этом в течение + + %d месяц + %d месяца + %d месяцев + %d месяца + + Далее + + Разрешения + Для правильной работы %s требуются разрешения. + Все нижеперечисленное + Используйте для включения всех опций (рекомендуется) + Все разрешения предоставлены + Разрешения для контактов + Контакты не синхронизируются (не рекомендуется) + Синхронизация контактов возможна + Разрешения для календаря + Календарь не синхронизируется (не рекомендуется) + Синхронизация календаря возможна + Разрешение на уведомление + Уведомления отключены (не рекомендуется) + Уведомления включены + Разрешения jtx Board + Разрешения OpenTasks + Разрешения Tasks + Не синхронизируются задачи + Синхронизация задач возможна + Сохранять разрешения + Разрешения могут быть сброшены автоматически (не рекомендуется) + Разрешения не будут сброшены автоматически + Выберите Разрешения > снимите флажок \"Удалить разрешения, если приложение не используется\". + Если переключатель не работает, используйте Настройки приложения / Разрешения. + Настройки приложения + + Разрешения WiFi SSID + Для получения доступа к названию текущей сети WiFi (SSID), должны быть выполнены следующие условия: + Разрешение на точное местоположение + Разрешение на определение местоположения предоставлено + Разрешение на определение местоположения не предоставлено + Разрешение на фоновое определение местоположения + Разрешать всегда + Геолокация задана: %s + Геолокация не задана: %s + %s использует данные о местоположении (только WiFi SSID) только для того, чтобы ограничить синхронизацию только определенным WiFi SSID. Это будет происходить, даже если синхронизация выполняется в фоновом режиме. + Все данные о местоположении (только WiFi SSID) используются только локально и никуда не передаются. + Определение местоположения всегда включено + Служба определения местоположения включена + Служба определения местоположения отключена + + Переводы + Библиотеки + Версия %1$s (%2$d) + © Рикки Хирнер (Ricki Hirner), Бернхард Штокманн (Bernhard Stockmann) (bitfire web engineering GmbH) и контрибьюторы + Эта программа поставляется БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ. Это свободное программное обеспечение и вы можете распространять его при соблюдении определенных условий. + + Не удалось создать файл лога + Сейчас логируется вся активность %s + Просмотр/обмен + Отключить + + Адаптер синхронизации CalDAV/CardDAV + О программе / Лицензия + Отзыв о бета-тестировании + Пожалуйста, установите браузер + Настройки + Новости и обновления + Инструменты + Внешние ссылки + Сайт + Руководство + FAQ + Для организаций + Сообщество + Поддержать проект + Как внести свой вклад + Политика конфиденциальности + Добро пожаловать в DAVx⁵! + Подключитесь к вашему серверу и синхронизируйте календари и контакты. + Синхронизировать все аккаунты + + Уведомления отключены. Вы не будете получать уведомления об ошибках синхронизации. + Автоматическая синхронизация недоступна (нет подключения к интернету). + Управление подключениями + Экономия трафика включена. Фоновая синхронизация ограничена. + Экономия трафика + Включена экономия заряда батареи. Синхронизация может быть ограничена. + Управлять экономией заряда батареи + Недостаточно памяти для хранения данных. Android будет синхронизировать локальные изменения не сразу, а во время следующей регулярной синхронизации. + Управление хранилищем + Отсутствует провайдер календаря + Вы отключили системное приложение \"Хранилище календаря\"? + Отсутствует провайдер контактов + Вы отключили системное приложение \"Хранилище контактов\"? + Управление приложениями + + Не удалось обнаружить службу + Не удалось обновить список коллекций + + Запущен в приоритетном режиме + Для автосинхронизации на ряде устройств. + + Настройки + Отладка + Показать отладочную информацию + Просмотр/обмен информацией о конфигурации и логами + Подробное логирование + Логирование активно. Вы можете просматривать журналы как часть отладочной информации. + Логирование отключено + Оптимизация батареи + Приложение исключено (рекомендуется) + Применяются ограничения по использованию батареи (не рекомендуется) + Соединение + Тип прокси + + Определен системой + Без прокси + HTTP + SOCKS (для Orbot) + + Имя хоста прокси + Порт прокси + Безопасность + Разрешения приложения + Проверка разрешений, необходимых для синхронизации + Сертификаты системы + Не доверять системным и пользовательским CA + Доверять системным и пользовательским CA (рекомендуется) + Если этот параметр активен, системные сертификаты считаются не надежными. Это означает, что вам придется вручную принимать каждый сертификат (в том числе когда сервер обновляет свой сертификат), иначе настройка и синхронизация учетных записей работать не будут. + Сброс сертификатов + Отменяет доверие ко всем пользовательским сертификатам + Все пользовательские сертификаты были удалены + Интерфейс пользователя + Настройки уведомлений + Управление каналами уведомлений и их настройками + Выбор темы + + Определена системой + Светлая + Темная + + Включить подсказки + Включить подсказки, которые были отключены ранее + Все подсказки будут показаны снова + Интеграция + Приложение Tasks + Не найдено совместимое приложение для задач + UnifiedPush (экспериментально) + Нет (отключить push) + Выберите дистрибьютора + Push-дистрибьютор не установлен + Конечная точка не сконфигурирована + Готов получать push-сообщения %s + FCM (Google Play) + Push-сообщения всегда зашифрованы. + + Аккаунт удален + CardDAV + CalDAV + WebСal + Для синхронизации этих коллекций требуются дополнительные разрешения. + Управление разрешениями + Синхронизировать + Настройки аккаунта + Переименовать аккаунт + Несохраненные локальные данные могут быть потеряны. После переименования необходима повторная синхронизация. + Новое название аккаунта + Переименовать + Название аккаунта уже используется + Не удалось переименовать аккаунт + Удалить аккаунт + Вы действительно хотите удалить аккаунт? + Все локальные копии адресных книг, календарей и задач будут удалены. + синхронизировать эту коллекцию + только для чтения + календарь + контакты + журнал + задачи + Показать только личные + Обновить список + Подписки Webcal можно синхронизировать с внешними приложениями. + Не найдено приложение, поддерживающее WebCal + Установить ICSx⁵ + + Добавить аккаунт + Политику конфиденциальности.]]> + Общий логин + Авторизация для специфического поставщика + Продолжить + Войти + Войти c адресом email + Адрес email + Требуется действительный адрес email + Сервисы обнаруживаются по записям DNS и известным URL-адресам.]]> + Пароль + Скрыть пароль + Показать пароль + Пароль (необязательно) + Войти с URL и именем пользователя + Имя пользователя + Имя пользователя (необязательно) + Базовый URL + сервисы также обнаруживаются по записям DNS и известным URL.]]> + Выберите сертификат + Добавить аккаунт + Название аккаунта + Использование апострофов (\'), как оказалось, вызывает проблемы на некоторых устройствах. + Используйте свой адрес email в качестве названия аккаунта, поскольку Android будет использовать его в качестве поля ОРГАНИЗАТОР для создаваемых вами событий. Не допускается наличие двух аккаунтов с одинаковыми названиями. + Метод группировки контактов: + Название аккаунта обязательно + Название аккаунта уже используется + Не удалось добавить аккаунт + Завершить + Расширенный вход + Без клиентского сертификата (необязательно) + Сертификат клиента: %s + Сертификат не найден + Установить сертификат + Fastmail + Аккаунт Fastmail + Войти с Fastmail + Google Контакты / Календарь + Google аккаунт + Войти с Google + ID клиента (необязательно) + Политику конфиденциальности.]]> + Политику в отношении пользовательских данных Google API Services, включая требования Ограниченного использования.]]> + Не удалось получить код авторизации + Nextcloud + Войти с Nextcloud + Это запустит процесс авторизации в Nextcloud в браузере. + Адрес сервера Nextcloud + Войти + Не удалось получить URL для авторизации + Не удалось получить данные для авторизации + Обнаружение конфигурации + Ожидайте, выполняется запрос к серверу… + Не удалось найти службу CalDAV или CardDAV. + Судя по всему, базовый URL не является допустимым URL CalDAV/CardDAV, и обнаружение службы не увенчалось успехом. + нашему списку проверенных сервисов и их базовых URL.]]> + Кроме того, проверьте правильность авторизации (обычно это имя пользователя и пароль). + Дополнительную техническую информацию можно найти в логах. + Просмотр логов + + Синхронизация + Интервал синхронизации контактов + Вручную + Каждые %d минут и немедленно при локальных изменениях + Интервал синхронизации календарей + Интервал синхронизации задач + + Только вручную + Каждые 15 минут + Каждые 30 минут + Каждый час + Каждые 2 часа + Каждые 4 часа + Раз в день + + Синхронизировать только через WiFi + Разрешить синхронизацию только через WiFi + Не учитывать тип соединения + Ограничение WiFi SSID + Будет синхронизироваться только %s + Будут использоваться все WiFi-подключения + Имена (SSID) разрешенных сетей WiFi, разделенные запятыми (оставьте пустым для всех) + Ограничение WiFi SSID требует дополнительных настроек + Управлять + VPN требует наличия основного интернета + VPN без основного интернета недостаточно для выполнения синхронизации (рекомендуется) + VPN без основного интернета достаточно для выполнения синхронизации + Аутентификация + Имя пользователя + Пароль или пароль приложения + пароль приложения.]]> + Новый пароль + Обновить пароль + Авторизовать снова (OAuth) + Использовать, когда доступ был отозван + Авторизация успешна + Сертификат клиента + Сертификат отсутствует или не выбран + Установить сертификат + CalDAV + Ограничение по времени для прошедших событий + Будут синхронизироваться все события + + События старше одного дня будут игнорироваться + События старше %d дней будут игнорироваться + События старше %d дней будут игнорироваться + События старше %d дней будут игнорироваться + + События, произошедшие ранее указанного количества дней, будут игнорироваться (может быть 0). Оставьте пустым, если хотите синхронизировать все события. + Напоминание по умолчанию + + Напоминание по умолчанию за 1 минуту до события + Напоминание по умолчанию за %d минуты до события + Напоминание по умолчанию за %d минут до события + Напоминание по умолчанию за %d минут до события + + Напоминания по умолчанию не создаются + Для событий без напоминания введите желаемое количество минут для получения уведомления. Оставьте пустым, чтобы не использовать напоминания по умолчанию. + Управление цветами календаря + Цвета календаря сбрасываются после каждой синхронизации + Цвета календаря могут быть установлены другими приложениями. + Поддержка цвета событий + Цвета событий синхронизируются + Цвета событий не синхронизируются + CardDAV + Метод группировки контактов + + Группы являются отдельными vCards + Группы являются категориями контактов + + + Создать адресную книгу + Создание адресной книги через CardDAV может не поддерживаться сервером. + Создать календарь + Часовой пояс по умолчанию (необязательно) + + Возможные записи календаря + События + Задачи + Заметки / журнал + Создание календаря через CalDAV может не поддерживаться сервером. + Цвет + Название + Место хранения + Описание (необязательно) + Создать + + контакты + события + задачи + Удалить коллекцию + Эта коллекция (%s) и все ее данные будут удалены навсегда, как локально, так и на сервере. + Синхронизация + Синхронизация включена + Синхронизация отключена + Только для чтения + Только для чтения (со стороны сервера) + Только для чтения (в соответствии с политикой) + Только для чтения (локально) + Чтение/запись + Название + Описание + Владелец + Поддержка push + Сервер анонсирует поддержку push + Подписан на %1$s, истекает %2$s + Последняя синхронизация (%s) + Адрес (URL) + + Отладочная информация + ZIP-архив + Содержит отладочную информацию и логи + Поделитесь архивом, чтобы перенести его на компьютер, отправить по электронной почте или прикрепить к запросу в службу поддержки. + Поделиться архивом + Отладочная информация, прикреплена к данному сообщению (требует поддержки вложений со стороны принимающего приложения). + Ошибка HTTP + Ошибка сервера + Ошибка WebDAV + Ошибка ввода/вывода + Запрос был отклонен сервером. + Запрошенный ресурс (больше) не существует. + Сервер не разрешает запрошенный тип операции. + Возникла проблема на стороне сервера. Пожалуйста, свяжитесь со службой поддержки вашего сервера. + Произошла неожиданная ошибка. Просмотрите отладочную информацию, чтобы узнать подробности. + Просмотр + Отладочная информация собрана + Задействованные ресурсы + Связанная с этим проблема + Удаленный ресурс: + Локальный ресурс: + Логи + Доступны подробные логи + Просмотр логов + Скопировать URL + Проверить ресурс + Предупреждение о конфиденциальности + Журналы и отладочная информация могут содержать конфиденциальную информацию. Пожалуйста, помните об этом, когда делитесь ими. + Невозможно просмотреть ресурс + + Произошла ошибка. + Произошла ошибка HTTP + Произошла ошибка ввода/вывода. + Показать детали + + Точки монтирования WebDAV + Использованная квота: %1$s / доступно: %2$s + Поделиться контентом + Отмонтировать + Добавление точки монтирования WebDAV + Прямой доступ к вашим облачным файлам с помощью точек монтирования WebDAV! + как работают точки монтирования WebDAV.]]> + Отображаемое имя + WebDAV URL + Некорректный URL + Точка монтирования и отображаемое имя + Аутентификация + Имя пользователя + Пароль + Имя пользователя (необязательно) + Пароль (необязательно) + Добавить точку монтирования + Служба WebDAV отсутствует на данном URL + Удалить точку монтирования + Информация о подключении будет потеряна, но никакие файлы не будут удалены. + Доступ к файлу WebDAV + Загрузка файла WebDAV + Выгрузка файла WebDAV + Точка монтирования WebDAV + + Разрешения DAVx⁵ + Требуются дополнительные разрешения + Приложение %s устарело + Минимально необходимая версия: %1$s + Ошибка аутентификации (проверьте учетные данные) + Ошибка сети или ввода/вывода – %s + Ошибка HTTP-сервера – %s + Ошибка локального хранилища – %s + Ошибка (достигнуто максимальное количество повторных попыток) + Получен неверный контакт с сервера + Получено недействительное событие от сервера + Получена недействительная задача от сервера + Игнорирование одного или нескольких недействительных ресурсов + Ожидается синхронизация + Удаленные данные изменились + + Синхронизировать все + Синхронизировать все аккаунты + Ярлык кнопки синхронизации + Значок кнопки синхронизации + Нажмите для запуска синхронизации вручную. + + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..bb69bf0 --- /dev/null +++ b/app/src/main/res/values-sk/strings.xml @@ -0,0 +1,194 @@ + + + + DAVx⁵ adresár + Povoliť + Pomoc + Ladenie + Ďalšie dôležité správy + Synchronizácia + Chyby synchronizácie + Významné chyby ktoré spôsobia koniec synchronizácie ako napríklad neočakávané odozvy servera + Varovania synchronizácie + Nefatálne synchronizačné chyby ako napríklad neplatné súbory + Sieťové a V/V chyby + Vypršanie času, problémy spojenia, atď. (často dočasné) + + Viac informácii + + + + Knižnice + Verzia %1$s (%2$d) + Tento program sa poskytuje BEZ AKEJKOĽVEK ZÁRUKY. Je to slobodný softvér a môžete ho ďalej šíriť pri splnení určitých podmienok. + + Nie je možné vytvoriť súbor protokolu + + CalDAV/CardDAV Sync Adaptér + O programe / Licencia + Odozva na beta-verziu + Prosím, nainštalujte webový prehliadač + Nastavenia + Novinky & aktualizácie + Externé odkazy + Webové sídlo + Manuál + FAQ + Zásady bezpečnosti + + + Zisťovanie služby zlyhalo + Nie je možné obnoviť zoznam kolekcií + + + Nastavenia + Ladenie + Zobraziť ladiace informácie + Zvýšené protokolovanie + Protokolovanie je zakázané + Spojenie + Zabezpečenie + Nedôverovať systémovým certifikátom + Nebude sa dôverovať systémovým a požívateľom pridaným certifikátom + Bude sa dôverovať systémovým a používateľom pridaným certifikátom (doporučuje sa) + Vynulovanie ne(dôveryhodných) certifikátov + Vynuluje dôveru pre všetky užívateľské certifikáty + Všetky užívateľské certifikáty boli vyčistené + Používateľské rozhranie + Nastavenia notifikácií + Spravovať notifikačné kanály a ich nastavenia + Vynulovať náznaky + Znovu povolí náznaky odstránené skôr + Všetky náznaky budú zobrazené znovu + + CardDAV + CalDAV + Webcal + Teraz synchronizovať + Nastavenia používateľského účtu + Premenovať používateľský účet + Premenovať + Meno účtu sa už používa + Odstrániť účet + Skutočne si želáte odstrániť účet? + Všetky miestne kópie adresárov, kalendárov a zoznamov úloh budú vymazané. + synchronizovať túto zbierku + len na čítanie + kalendár + Nenašla sa žiadna aplikácia schopná používať Webcal + Nainštalovať ICSx⁵ + + Pridať účet + Prihlásiť sa + Prihlásiť sa e-mailovou adresou + E-mailová adresa + Vyžaduje sa platná e-mailová adresa + Heslo + Prihlásiť sa s použitím URL a používateľského mena + Používateľské meno + Základné URL + Zvoliť certifikát + Pridať účet + Meno používateľského účtu + Použite vašu e-mailovú adresu ako meno používateľského účtu pretože Android používa meno účtu v poli ORGANIZÁTOR pre udalosti ktoré vytvoríte. Nie je možné mať dva používateľské účty s rovnakým menom. + Spôsob práce so skupinami + Vyžaduje sa meno používateľského účtu + Meno účtu sa už používa + Zisťuje sa konfigurácia + Čakajte, prosím, zasiela sa dopyt na server... + Nie je možné nájsť služby CalDAV ani CardDAV. + + Synchronizácia + Synchr. interval pre kontakty + Iba manuálne + Každú %d minút + okamžite pri miestnych zmenách + Synchr. interval pre kalendáre + Synchr. interval pre úlohy + + Iba manuálne + Každých 15 minút + Každých 30 minút + Každú hodinu + Každé 2 hodiny + Každé 4 hodiny + Raz za deň + + Synchronizovať iba cez WiFi + Synchronizácia je obmedzená na WiFi pripojenie + Typ pripojenia sa neberie do úvahy + Obmedzenie na WiFi SSID + Synchronizuje sa iba cez %s + Použije sa akékoľvek WiFi pripojenie + Čiarkou oddelený zoznam mien (SSID) povolených WiFi sietí (ponechať prázdne pre všetky) + Overenie + Meno používateľa + Aktualizujte heslo podľa vášho servera. + CalDAV + Uplynul časový limit pre udalosť + Budú sa synchronizovať všetky udalosti + + Udalosti staršie ako jeden deň budú ignorované + Udalosti staršie ako %d dni budú ignorované + Udalosti staršie ako %d dní budú ignorované + Udalosti staršie ako %d dní budú ignorované + + Udalosti ktoré sú staršie ako tento počet dní budú ignorované (0 je povolená). Ponechajte prázdne aby ste synchronizovali všetky udalosti. + Prednastavená pripomienka + + Prednastavená pripomienka 1 minútu pred udalosťou + Prednastavená pripomienka minút pred udalosťou: %d + Prednastavená pripomienka minút pred udalosťou: %d + Prednastavená pripomienka minút pred udalosťou: %d + + Neboli vytvorené prednastavené pripomienky + Ak majú byť vytvorené prednastavené pripomienky pre udalosti bez pripomienok: počet minút pred udalosťou. Ponechajte prázdne na zrušenie prednastavených pripomienok. + Spravovať farby kalendára + Podpora farieb pre udalosť + CardDAV + Spôsob práce so skupinami kontaktov + + Skupiny sú osobitné vKarty + Skupiny sú kategórie na kontakt + + + Vytvoriť adresár + Vytvoriť kalendár + Možné kalendárové položky + Udalosti + Úlohy + Poznámky / denník + Farba + Názov + Umiestnenie úložiska + Vytvoriť + + Zmazať kolekciu + Synchronizácia + Názov + Popis + + Ladiace informácie + Kopírovať URL + + Vyskytla sa chyba. + Vyskytla sa HTTP chyba. + Vyskytla sa V/V chyba. + Zobraziť detaily + + Overenie + Meno používateľa + Heslo + + Oprávnenia DAVx⁵ + Vyžadujú sa dodatočné oprávnenia + Overenie zlyhalo (skontroluje prihlasovacie údaje) + Sieťová alebo V/V chyba – %s + Chyba HTTP servera – %s + Chyba miestneho úložiska – %s + Kontakt prijatý zo servera je neplatný + Udalosť prijatá zo servera nie je platná + Úloha prijatá zo servera nie je platná + Ignoruje sa jeden alebo viac neplatných zdrojov + + + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..6067be7 --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,211 @@ + + + + Račun ne obstaja (več) + DAVx⁵ imenik + Odstrani + Preklic + Omogoči + To polje je obvezno + Pomoč + Deli + Okvarjena baza podatkov + Vsi računi so bili lokalno odstranjeni. + Razhroščevalnik + Ostale pomembne nastavitve + Statusno sporočilo nizke prioritete + Sinhronizacija + Napake v sinhronizaciji + Pomembne napake, ki zaustavijo sinhronizacijo (npr. nepričakovani odgovori strežnika) + Opozorila med sinhronizacijo + Neusodni problemi v sinhronizaciji npr. določene neveljavne datoteke + Omrežje in I/O napake + Pavze, povezave v povezavi z omrežjem, itd. (ponavadi začasno) + + Tvoji podatki. Tvoja izbira. + Prevzemi nadzor + Ne potrebujem enakomernih intervalov sinhroniziranja + %szdružljivost + Več informacij + Tasks.org + Nobene trgovine z aplikacijami ni na voljo + Odprtokodna programska oprema + Kako prispevati ali donirati + + Dovoljenja + Vse spodaj + Uporabi to da omogočiš vse funkcije (priporočeno) + Vsa dovoljenja odobrena + Dovoljenje za dostop do imenika + Ne sinhroniziraj imenika (ni priporočeno) + Možnost sinhronizacije imenika + Dovoljenje za dostop do koledarja + Ne sihroniziraj koledarja (ni priporočeno) + Možnost sinhronizacije koledarja + Dovoljenje za prikaz obvestil + Obvestila onemogočena (ni priporočeno) + Obvestila omogočena + jtx Board dovoljenja + + + Knjižnice + Verzija %1$s (%2$d) + Ta program ne vsebuje NIČ garancije. To je brezplačna programska oprema in jo lahko pod določenimi pogoji delite naprej. + + Ni bilo mogoče ustvariti zapisnika + + CalDAV/CardDAV sinhronizacijski adapter + O aplikaciji / licenca + Beta povratne aplikacije + Nastavitve + Novice & posodobitve + Zunanje povezave + Spletna stran + Priročnik + Pogosta vprašanja + + + Zaznava storitve ni uspela + Zbirke ni bilo mogoče osvežiti + + + Nastavitve + Razhroščevalnik + Prikaži informacije razhroščevalnika + Podrobno zapisovanje procesov + Zapisovanje je onemogočeno + Povezava + Varnost + Nezaupaj sistemskim cerfitikatom + Sistemski in od uporabnika dodani certifikati ne bodo zaupani + Sistemski in od uporabnika dodani certifikati bodo zaupani(priporočeno) + Ponastavi (ne)zaupane certifikate + Ponastavi zaupanje vse lastnih certifikatov + Vsi lastni certifikati so bili odstranjeni + Uporabniški vmesnik + Nastavitve opozoril + Uredi kanale opozoril in njihove nastavitve + Ponastavi namige + Ponovno omogoči namige, ki si bilo predhodno izključeni + Vsi namigi bodo ponovno prikazani + + CardDAV + CalDAV + Webcal + Sinhroniziraj zdaj + Nastavitve računa + Preimenuj račun + Preimenuj + Ima računa že obstaja + Izbriši račun + Ali res želite izbrisati račun? + Vse lokalne kopije imenika, koledarjev in seznamov opravil bodo izbrisane. + sinhroniziraj to zbirko + samo za branje + koledar + dnevnik + Pokaži samo osebno + Nobena Webcal sposobna aplikacija ni bila najdena + Namesti ICSx⁵ + + Dodaj račun + Prijava + Prijava z email naslovom + Email naslov + Potreben je veljaven email naslov + Geslo + Prijava z URL in uporabniškim imenom + Uporabniško ime + URL osnova + Izberi certifikat + Dodaj račun + Ime računa + Uporabi email naslov kot ime računa, ker bo Android uporabil to ime računa kot organizacijsko povelj za dogodke, ki jih ustvariš. Dveh računov z istim imenom ni mogoče imeti. + Metoda skupine kontaktov: + Zahtevano je ime računa + Ima računa že obstaja + Zaznava konfiguracije + Prosim počakajte, povezava s strežnikom je v teku... + CalDAV ali CardDAV storitve ni bilo mogoče najti. + + Sinhronizacija + Kontakti interval sinhronizacije + Samo ročno + Vsakih %d minut + takoj po lokalnih spremembah + Koladar interval sinhronizacije + Naloge interval sinhronizacij + + Samo ročno + Vsakih 15 minut + Vsakih 30 minut + Vsako uro + Vsaki 2 uri + Vsake 4 ure + Enkrat na dan + + Sinhronizacija samo preko Wifi + Sinhronizacije je omejena na Wifi omrežja + Tip povezave ni upoštevan + WiFI SSIF omejitev + Bo sinhroniziralo samo preko %s + Vse WiFi povezave bodo uporabljene + Z vejico ločena imena (SSID) dovoljenih WiFi omrežij (pusti prazno za vse) + Avtentikacija + Uporabniško ime + Posodobi geslo ustrezajoč strežniku. + CalDAV + Pretekli dogodek časovna omejitev + Vsi dogodki bodo sinhronizirani + + Dogodki starejši od enega dne v preteklosti bodo prezrti + Dogodki starejši od %d dni v preteklosti bodo prezrti + Dogodki starejši od %d dni v preteklosti bodo prezrti + Dogodki starejši od %d dni v preteklosti bodo prezrti + + Dogodki, ki so v preteklosti več kot ta številka dni bodo prezrti (lahko je 0). Pusti prazno za sinhronizacijo vseh dogodkov. + Uredi barve koledarjev + Podpora barva dogodka + CardDAV + Metoda skupine kontaktov + + Ustvari imenik + Ustvari koledar + Mogoči koledarski vnosi + Dogodki + Naloge + Beležnice / dnevnik + Barva + Naslov + Lokacija shrambe + Ustvari + + Izbriši zbirko + Sinhronizacija + Naslov + Opis + + Informacije razhroščevalnika + Kopiraj URL + + Zgodila se je napaka + Zgodila se je HTTP napaka. + I/O napaka se je zgodila. + Pokaži podrobnosti + + Avtentikacija + Uporabniško ime + Geslo + + DAVx⁵ dovoljenja + Dodatna dovoljenja so zahtevana + Avtentikacija ni uspela (preverite podatke prijave) + Omrežna ali I/O napaka -- %s + HTTP strežniška napaka -- %s + Napaka lokalne shrambe -- %s + S strežnika so bili prejeti neveljavni kontakti + S strežnika so bili prejeti neveljavni dogodki + S strežnika so bili prejeti neveljavni dogodki + Eden ali več neveljavnih virov bo ignoriranih + + + diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..9a8d510 --- /dev/null +++ b/app/src/main/res/values-sr/strings.xml @@ -0,0 +1,282 @@ + + + + Налог не постоји (више) + ДАВдроид адресар + Уклони + Поништи + Укључи + Ово поље је обавезно + Помоћ + Подели + База података је корумпирана + Сви налози су уклоњени локално. + Тражење грешака + Остале важне поруке + Статусне поруке ниског приоритета + Синхронизација + Грешке синхронизације + Важне грешке које заустављају синхронизацију попут неочекиваних одговора сервера + Упозорења синхронизације + Не критични проблеми са синхронизацијом попут одређених неисправних датотека + Мрежне и У/И грешке + Истекла времена, проблеми са повезивањем, итд. (често привремено) + + Ваши подаци. Ваш избор. + Преузмите контролу. + Регуларни интервали синхронизације + За синхронизацију у регуларним интервалима, %s мора бити дозвољено да се извршава у позадини. У супротном, Андроид може зауставити синхронизацију у било ком тренутку. + Не требају ми регуларни интервали синхронизације.* + %s компатибилност + Изменио сам потребна подешавања. Не подсећај ме више.* + * Остави непотврђено да би био подсетнут касније. Може бити ресетовано у подешавањима / %s. + Још информација + + Подршка за задатке + Ако су задаци подржани од стране вашег сервера, они могу бити синхронизовани са подржаном апликацијом: + OpenTasks + Изгледа да се више не развија - не препоручује се. + Tasks.org + Ниједна продавница апликација није доступна + Не треба ми подршка за задатке.* + Софтвер отвореног кода + Како допринети/донирати + Следеће + + Дозволе + %s захтева дозволе да би исправно радила. + Све испод + Користите ово да би сте омогућили све функционалности (пропоручено) + Све дозволе су дате + Дозволе за контакте + Без синхронизације контаката (није препоручено) + Могућа је синхронизација контакта + Дозволе за календар + Без синхронизације календара (није препоручено) + Могућа је синхронизација календара + Дозволе за обавештења + Обавештења су онемогућена (није препоручено) + Обавештења су омогућена + Дозволе за OpenTasks + Дозволе за задатке + Без синхронизације задатака + Могућа је синхронизација задатака + Задржи дозволе + Дозволе могу бити аутоматски поништене (није препоручено) + Дозволе неће бити аутоматски поништене + Изаберите Дозволе > искључите \"Уклони дозволе ако се апликација не користи\" + Ако опција не функционише, користите подешавања апликације / Дозволе. + Подешавања апликације + + WiFi SSID дозволе + Дозвола прецизне локације + Дата је дозвола за локацију + Одбијена је дозвола за локацију + Дозволе за локацију у позадини + Дозволи сво време + Локација је увек омогућена + Услуга локације је омогућена + Услуга локације је онемогућена + + Преводи + Библиотеке + Издање %1$s (%2$d) + Овај програм НЕМА НИКАКВЕ ГАРАНЦИЈЕ. Бесплатан је софтвер којег можете слободно да делите под одређеним условима. + + Није се могла направити датотека записа + Сада се записују све %s активности + Прегледај/подели + Онемогући + + КалДАВ/КардДАВ адаптер синхронизације + О програму/лиценца + Повратне информације бета издања + Молим вас инсталирајте прегледач интернета + Поставке + Новости и ажурирања + Алати + Вањске везе + Веб-сајт + Приручник + ЧПП + Заједница + Политика приватности + Синхронизуј све налоге + + Обавештења су онемогућена. Нећете бити обавештени о проблемима са синхронизацијом. + Управљајте складиштем + + Откривање услуге није успело + Не могох да освежим списак збирки + + На неким уређајима је ово неопходно за аутоматску синхронизацију. + + Поставке + Тражење грешака + Прикажи податке за исправљање грешака + Исцрпна евиденција + Оптимизација батерије + Повезивање + Врста проксија + + Системски предефинисан + Без проксија + HTTP + SOCKS (за Orbot) + + Назив прокси домаћина + Порт проксија + Безбедност + Дозволе апликације + Прегледај дозволе неопходне за синхронизацију + Посумњај у системске сертификате + Системски и кориснички додати сертификати неће бити поуздани + Системски и кориснички додати сертификати ће бити поуздани (препоручљиво) + Ресетуј (не)поуздане сертификате + Ресетуј поуздање свих прилагођених сертификата + Сви прилагођени сертификати су уклоњени + Корисничко сучеље + Подешавања обавештења + Управљај каналима обавештења и њиховим подешавањима + Изабери тему + + Према систему + Светла + Тамна + + Ресетуј савете + Поновно приказивање претходно одбачених савета + Сви савети ће поново бити приказани + Интеграција + Апликација за задатке + + КардДАВ + КалДАВ + Вебкал + Синхронизуј одмах + Поставке налога + Преименуј налог + Преименуј + Назив налога је већ заузет + Није било могуће преименовати налог + Обриши налог + Заиста обрисати налог? + Све локалне копије адресара, календара и листи задатака ће бити обрисане. + синхронизуј ову збирку + само-за-читање + календар + журнал + Прикажи само личне + Нема апликације за Вебкал + Инсталирај ICSx⁵ + + Додај налог + Пријава + Пријавите се адресом е-поште + Адреса е-поште + Исправна адреса е-поште је обавезна + Лозинка + Пријавите се УРЛ-ом и корисничким именом + Корисничко име + Корени УРЛ + Изабери сертификат + Додај налог + Назив налога + Користите вашу е-адресу за назив налога јер Андроид користи назив налога за поље ОРГАНИЗАТОР за догађаје које направите. Не можете имати два налога истог назива. + Режим група контаката: + Назив налога је обавезан + Назив налога је већ заузет + Сертификат није пронађен + Инсталирај сертификат + Гугл контакти / календар + Гугл налог + ИД клијента (опционо) + Откривање конфигурације + Сачекајте, шаљем упит серверу… + Не могох да нађем КалДАВ или КардДАВ услугу. + Прикажи записе + + Синхронизација + Интервал синх. контаката + Само ручно + Сваких %d минута + одмах по локалним изменама + Интервал синх. календара + Интервал синх. задатака + + Само ручно + Сваких 15 минута + Сваких 30 минута + Сваког сата + Свака 2 сата + Свака 4 сата + Једном дневно + + Само преко бежичног + Синхронизовање само преко бежичних мрежа + Тип везе није узет у обзир + Ограничења ССИД-а бежичних + Синхронизовање само преко %s + Коришћење свих бежичних мрежа + Имена (ССИД) дозвољених мрежа. одвојена зарезом (оставите празно за све мреже) + Управљај + Аутентификација + Корисничко име + Ажурирајте лозинку за ваш сервер. + Инсталирај сертификат + КалДАВ + Ограничење догађаја у прошлости + Сви догађаји се синхронизују + + Догађаји старији од једног дана ће бити занемарени + Догађаји старији од %d дана ће бити занемарени + Догађаји старији од %d дана ће бити занемарени + + Догађаји старији од овог броја дана ће бити занемарени (може бити 0). Оставите празно за синхронизацију свих догађаја. + Предефинисани подсетник + Управљај бојама календара + Подршка за боју догађаја + Боје догађаја су синхронизоване + Боје догађаја нису синхронизоване + КардДАВ + Режим група контаката + + Направи адресар + Направи календар + Догађаји + Задаци + Боја + Наслов + Локација складишта + Направи + + Обриши збирку + Синхронизација + Наслов + Опис + + Подаци за исправљање грешака + Прикажи детаље + Записи + Прикажи записе + Копирај УРЛ + + Десила се грешка. + Десила се ХТТП грешка. + Десила се У/И грешка. + Прикажи детаље + + Име за приказ + Аутентификација + Корисничко име + Лозинка + + ДАВдроид дозволе + Потребне су додатне доволе + Аутентификација није успела (проверите акредитиве за пријаву) + Мрежна или У/И грешка – %s + Грешка ХТТП сервера – %s + Грешка локалног складишта – %s + + Синхронизуј све налоге + + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..d8d2dc7 --- /dev/null +++ b/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,465 @@ + + + + Kontot finns inte (längre) + DAVx⁵ Adressbok + Ändra inte kontot här! Använd appen direkt för att hantera konton istället + Radera + Ta bort + Avbryt + Aktivera + Detta fält är obligatoriskt + Hjälp + Navigera upp + Inställningsmeny + Dela + Synkronisering startad/köad + Databasen är korrupt + Alla konton har blivit borttagna lokalt. + Felsökning + Andra viktiga meddelanden + Statusmeddelanden med låg prioritet + Synkronisering + Synkroniseringsfel + Viktiga fel som stoppar synkronisering, såsom oväntade serversvar + Synkroniseringsvarningar + Icke allvarliga synkroniseringsfel såsom vissa felaktiga filer + Nätverk och I/O fel + Tidsgräns eller anslutningsproblem etc. (ofta temporärt) + + Din data. Ditt val. + Ta kontroll + Regelbundna synkroniseringsintervall + För att programmet skall kunna köra regelbunden synkronisering %s måste det tillåtas att köra i bakgrunden. Annars kan Android pausa synkroniseringen när som helst. + Jag behöver inte regelbunden synkronisering.* + %s kompatibilitet + Jag har gjort de nödvändiga inställningarna. Påminn mig inte igen.* + * Lämna omarkerat för att bli påmind senare.. Kan återställas i appens inställningar / %s. + Mer information + jtx Bord + + Tasks stöd + Om ärenden stöds av din server kan de synkroniseras med en ärendeapp som stöds: + OpenTasks + Verkar inte utvecklas längre - rekommenderas ej. + Tasks.org + stöds inte.]]> + Ingen appbutik tillgänglig + Jag behöver inte stöd för tasks.* + Öppen källkod mjukvara + Vi är glada att du använder %s, som är mjukvara med öppen källkod. Utveckling, underhåll och support är hårt arbete. Överväg att bidra (det finns många sätt) eller en donation. Det skulle vara mycket uppskattat! + Hur man kan bidra/donera + Påminn mig inte på + + %d månad + %d månader + + Nästa + + Behörigheter + %s behöver behörighet för att kunna fungera + Allt nedanstående + Använd detta för att aktivera alla funktioner (rekommenderat) + Alla behörigheter godkända + kontaktbehörigheter + Synkronisera inte kontakter (ej rekommenderat) + Synkronisering av kontakter möjlig + Kalenderbehörigheter + Synkronisera inte kalendern (ej rekommenderat) + Synkronisering av kalender möjlig + Notifieringsbehörigheter + Notifieringar avaktiverade (ej rekommenderat) + Notifieringar aktiva + jtx Board-behörigheter + OpenTasks-behörigheter + Tasks behörighet + Ingen synkronisering av uppgifter + Uppgiftssynkronisering möjlig + Behåll behörigheter + Behörigheter kan återställas automatiskt (rekommenderas inte) + Behörigheterna återställs inte automatiskt + Klicka på Behörigheter > avmarkera \"Ta bort behörigheter om appen inte används\" + Om en brytare inte fungerar, använd app-inställningar->behörigheter + App-inställningar + + WiFi SSID-behörigheter + För att kunna komma åt det aktuella WiFi-namnet (SSID) måste dessa villkor vara uppfyllda: + Behörighet för exakt plats + Behörighet för platsdata beviljad + Behörighet för platsdata nekad + Behörighet för platsdata i bakgrunden + Tillåt hela tiden + Platsbehörighet satt till: %s + Platsbehörighet inte satt till: %s + %s använder platsdata (bara WiFi SSID) endast för att hindra synkronisering mot ett specifikt WiFi SSID. Detta händer även när synkroniseringen körs i bakgrunden. + All platsdata (bara WiFi SSID) används bara lokalt och skickas ingenstans. + Plats alltid påslagen + Platstjänster är påslagna + Platstjänster är avstängda + + Översättningar + Bibliotek + Version %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) och bidragsgivare + Detta program levereras med ABSOLUT INGEN GARANTI. Det är fri programvara, och du kan vidaredistribuera den under vissa förutsättningar. + + Kunde inte skapa loggfil + Loggar nu alla %s aktiviteter + Visa/dela + Inaktivera + + CalDAV/CardDAV Synk Adapter + Om / Licens + Betafeedback + Vänligen installera en webbläsare + Inställningar + Nyheter & uppdateringar + Verktyg + Externa länkar + Websida + Manual + FAQ + Gemenskap + Stöd projektet + Hur man kan bidra + Integritetspolicy + Välkommen till DAVx⁵! + Anslut till din server och håll dina kalendrar och kontakter synkroniserade + Synkronisera alla konton + + Notifieringar avstängda. Du kommer inte bli informerad om synkroniseringsfel. + Automatisk synkronisering ej aktiv (ingen verifierad internetanslutning) + Hantera anslutningar + Begränsad data-synkronisering aktiverad. Begränsad bakgrundssynkronisering. + Hantera begränsad data-synkronisering + Batterisparning aktiverad. Det kan hända att synkronisering begränsas. + Hantera batterisparare + Lagringsutrymmet är lågt. Android kommer inte synka lokala ändringar direkt, utan vänta till nästa regelbundna synkronisering. + Hantera lagring + Kalenderdistributör saknas + Har du stängt av \"kalenderlagrings\"-systemappen? + Kontaktdistributör saknas + Har du stängt av \"kontaktlagrings\"-systemappen + Hantera appar + + Servicedetektering misslyckades + Det gick inte att uppdatera samlingslistan + + Arbetar i förgrunden + På vissa enheter är detta nödvändigt för automatisk synkronisering. + + Inställningar + Felsökning + Visa felsökningsinformation + Visa/dela konfigurations detaljer och loggar + Omfattande loggning + Loggning är aktiv. Du kan visa loggarna som en del av felsökningsinfon. + Loggning är avstängd + Batterioptimering + Appen är undantagen (rekommenderas) + Batteribegränsningar tillämpas (rekommenderas inte) + Anslutning + Proxy typ + + Systemstandard + Ingen proxy + HTTP + SOCKS (för Orbot) + + Proxy hostnamn + Proxy port + Säkerhet + App behörigheter + Granska behörigheter som krävs för synkronisering + Misstro systemcertifikat + System- och användartillagda certifikatutfärdare kommer inte att vara betrodda + System- och användartillagda certifikatutfärdare kommer att vara betrodda (rekommenderat) + Om den här inställningen är aktiv anses inte system certifikaten vara tillförlitliga. Det betyder att du måste acceptera alla certifikat manuellt (även när servern förnyar sitt certifikat) annars fungerar inte konto inställningar och synkronisering. + Återställ (o)betrodda certifikat + Återställer förtroendet för alla anpassade certifikat + Alla anpassade certifikat har rensats + Användargränssnitt + Aviseringsinställningar + Hantera aviseringskanaler och deras inställningar + Välj tema + + Systemstandard + Ljus + Mörk + + Återställ tips + Återaktiverar tips som har avvisats tidigare + Alla tips kommer visas igen + Integrering + Tasks appen + Ingen kompatibel task-app hittades + UnifiedPush (experimentellt) + Ingen (inaktivera push) + Välj en leverantör + Ingen push leverantör installerad + Ingen slutpunkt konfigurerad + Redo att ta emot push meddelanden över %s + Push-meddelanden är alltid krypterade. + + CardDAV + CalDAV + Webcal + Ytterligare behörigheter krävs för att synkronisera dessa samlingar. + Hantera behörigheter + Synkronisera nu + Kontoinställningar + Byt namn på kontot + Lokala data som inte har sparats kan avvisas. Omsynkronisering krävs efter byte av namn. + Nytt kontonamn + Byt namn + Kontonamn är upptaget + Kunde inte ändra namn på kontot + Ta bort kontot + Vill du verkligen ta bort kontot? + Alla lokala kopior av adressböcker, kalendrar och uppgiftslistor kommer att raderas. + synkronisera denna samling + skrivskyddad + kalender + kontakter + Journal + uppgifter + Visa endast personligt + Uppdatera lista + Webcal-prenumerationer kan synkroniseras med externa appar. + Ingen Webcal-kompatibel app hittades + Installera ICSx⁵ + + Lägg till konto + sekretesspolicy.]]> + Generisk inloggning + Leverantörs-specifik inloggning + Fortsätt + Logga in + Logga in med e-postadress + E-postadress + Giltig e-postadress krävs + Tjänster är upptäckta genom DNS-uppslag och välkända URL:er.]]> + Lösenord + Dölj lösenord + Visa lösenord + Logga in med URL och användarnamn + Användarnamn + Bas-URL + tjänster upptäcks även genom DNS uppslag och välkända URL:er.]]> + Välj certifikat + Lägg till konto + Kontonamn + Användning av apostrof (\') verkar orsaka problem på vissa enheter. + Använd din e-postadress som kontonamn eftersom Android kommer att använda kontonamnet som fält för ARRANGÖR för händelser du skapar. Du kan inte ha två konton med samma namn. + Kontaktgruppsmetod: + Konto namn krävs + Kontonamn är upptaget + Konto kunde inte läggas till + Klart + Avancerad inloggning + Klientcertifikat: %s + Inget certifikat funnet + Installera certifikat + Fastmail + Fastmail konto + Logga in med Fastmail + Google Kontakter / Kalender + Google-konto + Logga in med Google + Klient-ID (valfritt) + Integritetspolicy för mer detaljer.]]> + Google API Services User Data Policy, inklusive kraven för \"Limited Use\".]]> + Kunde inte hämta autentiseringskod + Nextcloud + Logga in med Nextcloud + Detta påbörjar en Nextcloud-inloggning i din webbläsare. + Nextcloud serveradress + Logga in + Kunde inte hämta login-URL + Kunde inte hämta login-data + Konfigurationsdetektering + Vänligen vänta, server förfrågan... + Det gick inte att hitta CalDAV eller CardDAV-tjänsten. + Bas-URL:en verkar inte vara en åtkomlig CalDAV-/CardDAV-URL och upptäckt av tjänster misslyckades. + vår lista av testade tjänster och deras bas URL:er.]]> + Vänligen dubbelkolla också autentisering (vanligtvis användarnamn och lösenord) + Ytterligare teknisk information finns i loggarna. + Visa loggar + + Synkronisering + Intervall för kontaktsynkronisering + Bara manuellt + Var %d minut + omedelbart på lokala förändringar + Intervall för kalendersynkronisering + Intervall för uppgiftssynkronisering + + Endast manuellt + Var 15:e minut + Var 30:e minut + Varje timme + Var 2:e timme + Var 4:e timme + En gång om dagen + + Synkronisera endast via WiFi + Synkronisering är begränsad till WiFi-anslutningar + Anslutningstyp beaktas inte + WiFi SSID-begränsning + Synkroniserar endast över %s + Alla WiFi-anslutningar kommer att användas + Kommaseparerade namn (SSID) för tillåtna WiFi-nätverk (lämna tomt för alla) + WiFi SSID-begränsning kräver ytterligare inställningar + Hantera + VPN kräver bakomliggande internetanslutning. + VPN utan bakomliggande bekräftad internetanslutning är inte tillräckligt för att köra synkronisering (rekommenderat) + VPN utan bakomliggande bekräftad internetanslutning är tillräckligt för att köra synkronisering + Autentisering + Användarnamn + Lösenord eller app-lösenord + app lösenord.]]> + Nytt lösenord + Uppdatera lösenordet enligt din server. + Auktorisera igen (OAuth) + Använd när åtkomst har återkallats + Auktorisering framgångsrik + Klientcertifikat + Inget certifikat tillgängligt eller valt + Installera certifikat + CalDAV + Tidsgräns för tidigare händelser + Alla händelser kommer att synkroniseras + + Händelser mer än en dag i det förflutna kommer att ignoreras + Händelser mer än %d dag i det förflutna kommer att ignoreras + + Händelser som är fler än detta antal dagar i det förflutna kommer att ignoreras (kan vara 0). Lämna tomt för att synkronisera alla händelser. + Standardpåminnelse + + Standard påminnelse en minut före händelsen + Standard påminnelse %d minut före händelsen + + Inga standardpåminnelser är skapade + Om standardpåminnelser ska skapas för händelser utan påminnelse: önskat antal minuter före händelsen. Lämna tomt för att inaktivera standardpåminnelser. + Hantera kalenderfärger + Kalenderfärger nollställs vid varje synkronisering + Kalenderfärger kan sättas av andra appar. + Stöd för händelsefärger + Händelsefärger synkroniseras + Händelsefärger synkroniseras inte + CardDAV + Kontaktgruppsmetod + + Grupper är separata vCards + Grupper är per-kontaktkategorier + + + Skapa adressbok + Att skapa adressbok över CardDAV kanske inte stöds av servern. + Skapa kalender + + Möjliga kalenderposter + Händelser + Tasks + Anteckningar / journal + Att skapa kalender över CalDAV kanske inte stöds av servern. + Färg + Titel + Lagringsplats + Skapa + + kontakter + händelser + uppgifter + Ta bort samling + Denna samling (%s) och all dess data kommer att tas bort permanent, både lokalt och på servern. + Synkronisering + Synkronisering aktiverad + Synkronisering inaktiverad + Skrivskyddad + Skrivskyddad (av server) + Skrivskyddad (enligt policy) + Skrivskyddad (endast lokalt) + Läs/skriv + Titel + Beskrivning + Ägare + Push-stöd + Servern annonserar push-stöd + Prenumerat på %1$s, går ut %2$s + Senaste synk (%s) + Adress (URL) + + Felsökningsinformation + ZIP arkiv + Innehåller felsökningsinformation och loggar + Dela arkivet för att överföra det till en dator, för att skicka det via e-post eller för att bifoga det till ett supportärende. + Dela arkiv + Felsökningsinformation bifogad i det här meddelandet (kräver stöd för bilagor från den mottagande appen). + HTTP-fel + Server-fel + WebDAV-fel + I/O-fel + Visa detaljer + Felsökningsinformation har samlats in + Inblandade resurser + Relaterat till problemet + Fjärrresurs: + Lokal resurs: + Loggar + Utförliga loggar finns tillgängliga + Visa loggar + Kopiera URL + Integritetspolicy + Loggar och felsökningsinformation kan innehålla privat information. Var medveten om detta när du delar offentligt. + + Ett fel har uppstått. + Ett HTTP-fel har uppstått. + Ett I/O-fel har uppstått. + Visa detaljer + + WebDAV-fästen + Använd kvot: %1$s / tillgängligt: %2$s + Dela innehåll + Avmontera + Lägg till WebDAV-fäste + Direkt åtkomst till dina filer i molnet genom att lägga till en WebDAV montering! + hur WebDAV-fästen fungerar .]]> + Visningsnamn + WebDAV URL + Felaktig URL + Autentisering + Användarnamn + Lösenord + Lägg till fäste + Ingen WebDAV-tjänst på denna URL + Ta bort monteringspunkt + Anslutningsdetealjer kommer att gå förlorade men inga filer tas bort. + Åtkomst till WebDAV-fil + Laddar ner WebDAV-fil + Laddar upp WebDAV-fil + WebDAV-fäste + + DAVx⁵-behörigheter + Ytterligare behörigheter krävs + %s för gammal + Lägsta obligatoriska version: %1$s + Autentisering misslyckades (kontrollera inloggningsuppgifterna) + Nätverks- eller I/O-fel - %s + HTTP server fel - %s + Lokalt lagringsfel - %s + Mjukt fel (max antal återanslutningar nådda) + Fick ogiltig kontakt från servern + Fick ogiltig händelse från servern + Fick ogiltigt ärende från servern + Ignorerar en eller flera ogiltiga resurser + Synk väntar + Fjärrdata har ändrats + + Synkronisera alla + Synkronisera alla konton + Märkt synk knapp + Ikon synk knapp + Tryck för att köra synkronisering manuellt. + + diff --git a/app/src/main/res/values-szl/strings.xml b/app/src/main/res/values-szl/strings.xml new file mode 100644 index 0000000..c6e40e5 --- /dev/null +++ b/app/src/main/res/values-szl/strings.xml @@ -0,0 +1,207 @@ + + + + Ksiōnżka adresowo DAVx⁵ + Włōncz + Pōmoc + Debugowanie + Inksze ważne wiadōmości + Synchrōnizacyjo + Błyndy synchrōnizacyje + Ważne błyndy, co zastawiajōm synchrōnizacyjo, jak niyôczekowane ôdpowiedzi serwera + Ôstrzeżynia synchrōnizacyje + Niyôstudne problymy synchrōnizacyje, jak niykere niynoleżne zbiory + Błyndy necu i wchodu/wychodu + Braki ôdpowiedzi, problymy połōnczynio, itp. (z wiynksza tymczasowe) + + Twoje dane. Twōj wybōr. + Przejmij kōntrola. + Regularne interwały synchrōnizacyje + Żeby regularnie synchrōnizować, %s musi mieć zwolo na fungowanie na zadku. W inkszym przipadku Android może w kożdyj chwili zastawić synchrōnizacyjo. + Niy potrzebuja regularnyj synchrōnizacyje.* + Zgodność %s + Już mōm wymogane sztelōnki. Niy spōminej mi wiyncyj.* + * Ôstow niyzaznaczōne, żeby spōmniało ô sobie niyskorzij. Idzie to zmiynić we sztelōnkach aplikacyje / %s + Wiyncyj informacyji + Sparcie Zadań + Niy je dostympny żodyn sklep ze aplikacyjami + Niy potrzebuja sparcio zadań.* + Ôprogramowanie Open-Source + Sōm my radzi, że używosz %s, ôprogramowanie open-source. Tworzynie, utrzimanie i sparcie to ciynżko robota. Pōmyśl nad pōmocōm (sōm rozmajte spusoby) abo dowkōm. Fest by nos to ucieszyło! + Jak pōmōc/dociepnōńć sie + + + + Przekłady + Bibliotyki + Wersyjo %1$s (%2$d) + Tyn program przichodzi BEZ ŻODNYJ GWARANCYJE. To je wolne ôprogramowanie i możesz je rozkludzać pod ôkryślōnymi warōnkami. + + Niy szło stworzić zbioru dziynnika + + Adapter synchrōnizacyje CalDAV/CardDAV + Ô DAVx⁵ / Licyncyjo + Przekoż ôpinijo + Zainstaluj przeglōndarka internetowo + Sztelōnki + Nowości i aktualizacyje + Zewnyntrzne linki + Strōna WWW + Ryncznie + Pytania i ôdpowiedzi + Polityka prywatności + + + Niy szło ôdświyżyć serwisu + Niy szło ôdświyżyć listy kolekcyje + + + Sztelōnki + Debugowanie + Pokoż informacyje do debugowanio + Rozwlykłe zapisowanie + Zapisowanie je zastawiōne + Łōnczność + Bezpieczyństwo + Skasuj certyfikaty systymowe + CA systymowe i używocza niy bydōm przidane + CA systymowe i używocza bydōm przidane (rekōmyndowane) + Zresetuj (niy)zaufane certyfikaty + Zresetuj wszyjske niysztandardowe certyfikaty. + Wszyjske niysztandardowe certyfikaty były wysnożōne + Interfejs używocza + Sztelōnki powiadōmiyń + Zarzōndzej kanałami powiadōmiyń i jejich sztelōnkami + Zresetuj podpowiedzi + Włōncz zaś skazōwki, co wcześnij były wychrōniōne + Wszyjske skazōwki pokożōm sie zaś + + CardDAV + CalDAV + Webcal + Synchrōnizuj teroz + Sztelōnki kōnta + Przemianuj kōnto + Przemianuj + Miano kōnta je już zajynte + Skasuj kōnto + Naprowda chcesz skasować kōnto? + Wszyjske lokalne kopije ksiōnżek adresowych, kalyndorzōw i list zadań bydōm skasowane. + Synchrōnizuj kolekcyjo + ino do ôdczytu + kalyndorz + Niy szło znojś aplikacyje, co ôbsuguje Webcal + Zainstaluj ICSx⁵ + + Przidej kōnto + Wloguj + Logowanie ze pōmocōm adresy e-mail + Adresa e-mail + Wymogano noleżno adresa e-mail + Hasło + Logowanie ze pōmocōm adresy URL i miana używocza + Miano używocza + Bazowy URL + Ôbier certyfikat + Przidej kōnto + Miano kōnta + Użyj swojij adresy e-mail za miano kōnta, bo Android bydzie używoł miana kōnta za pola ÔRGANIZATŌR dlo zdarzyń, co je stworzisz. Niy możesz posiadać dwōch kōnt ze takim samym mianym. + Spusōb grupowanio kōntaktōw: + Wymogane miano kōnta + Miano kōnta je już zajynte + Wykrywanie kōnfiguracyje + Czekej, ôdpytowanie serwera… + Niy idzie znojść usugi CalDAV abo CardDAV. + + Synchrōnizacyjo + Frekwyncyjo synchrōnizacyje kōntaktōw + Ino ryncznie + Co %d minut jak tyż zaroz przi zmianach lokalnych + Frekwyncyjo synchrōnizacyje kalyndorzōw + Frekwyncyjo synchrōnizacyje list zadań + + Ino ryncznie + Co 15 minut + Co 30 minut + Co godzina + Co 2 godziny + Co 4 godziny + Roz dziynnie + + Synchrōnizuj ino bez WiFi + Synchrōnizacyjo je ôgraniczōno do połōnczyń WiFi + Zorta połōnczynio niy mo znaczynio + Ôgraniczynia WiFi SSID + Bydzie synchrōnizować ino bez %s + Wszyjske połōnczynia WiFi bydōm używane + Ôddzielōne kōmami miana (SSID) przizwolōnych necōw WiFi (ôstow prōzne dlo wszyjskich) + Autoryzowanie + Miano używocza + Zaktualizuj hasło zgodnie ze serwerym. + CalDAV + Limit czasowy przeszłych zdarzyń + Wszyjske zdarzynia bydōm zsynchrōnizowane + + Zdarzynia starsze aniżeli jedyn dziyń bydōm zignorowane. + Zdarzynia starsze aniżeli %d dni bydōm zignorowane. + Zdarzynia starsze aniżeli %d dni bydōm zignorowane. + + Zdarzynia, co sōm starsze aniżeli podano wielość dni, bydōm zignorowane (może być 0). Ôstow prōzne, coby synchrōnizować wszyjske zdarzynia. + Wychodne spōmniynie + + Wychodne spōmniynie minuta przed zdarzyniym + Wychodne spōmniynie %d minuty przed zdarzyniym + Wychodne spōmniynie %d minut przed zdarzyniym + + Niy były stworzōne żodne wychodne spōmniynia + Jeźli wychodne spōmniynia bydōm tworzōne do zdarzyń bez spōmniynio: liczba minut przed zdarzyniym. Ôstow prōzne, żeby zastawić wychodne spōmniynia. + Zarzōndzej farbami kalyndorza + Sparcie farbōw zdarzyń + CardDAV + Spusōb grupy kōntaktōw + + Grupy to sōm ôddzielne VCards + Grupy to sōm kategoryje co kōntakt + + + Stwōrz ksiōnżka adresowo + Stwōrz kalyndorz + Możliwe wpisy kalyndorza + Zdarzynia + Zadania + Zopiski / dziynnik + Farba + Tytuł + Położynie przechowowanio + Stwōrz + + Skasuj kolekcyjo + Synchrōnizacyjo + Tytuł + Ôpis + + Informacyje debugowe + Skopiuj URL + + Trefiōł sie błōnd. + Trefiōł sie błōnd HTTP. + Trefiōł sie błōnd I/O. + Pokoż informacyje + + Autoryzowanie + Miano używocza + Hasło + + Uprawniynia DAVx⁵ + Wymogane ekstra uprawniynia + Autoryzacyjo sie niy podarziła (dej pozōr na dane logowanio) + Feler necu abo I/O – %s + Feler serwera HTTP – %s + Feler lokalnego przechowowanio – %s + Dostany kōntakt ze serwera je niynoleżny + Dostane zdarzynie ze serwera je niynoleżne + Dostane zadanie ze serwera je niynoleżne + Ignorowanie jednego abo wiyncyj niynoleżnych zasobōw + + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..64ce385 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,113 @@ + + + + Yardım + Hata ayıklama + Senkronizasyon + + Daha fazla bilgi + Sonraki + + Kişiler izinleri + Takvim izinleri + OpenTasks izinleri + + + Bu uygulama HİÇ BİR GARANTİ ile gelmemektedir. Bedava bir yazılımdır ve belli koşullar altında dağıtabilirsiniz. + + + CalDAV/CardDAV Senkronizasyon Adaptörü + Hakkında / Lisans + Ayarlar + Haberler & güncellemeler + Harici bağlantılar + Web sitesi + SSS + + + Servis keşfi başarısız + Kolleksiyon listesi yenilenemedi + + + Ayarlar + Hata ayıklama + Hata ayıklama bilgilerini göster + Uzun jurnalleme + Güvenlik + Kullanıcı arayüzü + İpuçlarını sıfırla + Daha önceden azat edilen ipuçlarını yeniden etkinleştirir + Tüm ipuçları artık gösterilecek + + CalDAV + Şimdi senkronize et + Hesap ayarları + Hesabı sil + Hesap gerçekten silinsin mi? + Rehber, takvim ve iş listelerinin tüm yerel kopyaları silinecektir. + salt-okunur + + Hesap ekle + Giriş + Eposta adresi ile giriş yap + Eposta adresi + Geçerli eposta adresi zorunludur + Parola + URL ve kullanıcı adı ile giriş yap + Kullanıcı adı + Baz URL + Hesap ekle + Hesap adı + Hesap ismi olarak e-posta adresini kullan çünkü Android hesap ismini yarattığın olaylarda DÜZENLEYEN alanında kullanacaktır. Aynı isimde iki faklı hesabın olamaz. + Hesap adı zorunludur + Konfigürasyon keşfi + Lütfen bekle, sunucu sorgulanıyor… + CalDAV veya CardDAV servisi bulunamadı. + Jurnallere bak + + Senkronizasyon + Kişiler senk. aralığı + Sadece elle + Her %d dakika + yerel değişikliklerde hemen + Takvimler senk. aralığı + İşler senk. aralığı + Sadece WiFi üzerinden senkronize et + Senkronizasyon WiFi bağlantıları ile kısıtlıdır + Bağlantı tipi göz önünde bulundurulmaz + WiFi SSID kısıtlaması + Doğrulama + Kullanıcı adı + Parola + Parolayı sunucunuza göre güncelleyin. + CalDAV + Geçmiş olay zaman sınırı + Tüm olaylar senkronize edilecek + + %d günden daha eski olaylar göz ardı edilecektir + %d günden daha eski olaylar göz ardı edilecektir + + Bu sayıdan daha eski olan olaylar yok sayılacaktır (0 olabilir). Tüm olayları senkronize etmek için boş bırak. + Takvim renklerini yönet + + Rehber yarat + Yarat + + Koleksiyonu sil + Senkronizasyon + + Hata ayıklama bilgisi + Jurnallere bak + + Bir hata oluştu. + Bir HTTP hatası oluştu. + Bir I/O hatası oluştu. + Detayları göster + + Kullanıcı adı + Parola + + DAVx⁵ izinleri + Ek izinler zorunludur + + + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..44083f8 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,263 @@ + + + + Обліківки не існує (більше) + Адресна книга DAVx⁵ + Видалити + Видалити + Скасувати + Увімкнути + Це поле обовʼязкове + Допомога + Поширити + База даних пошкоджена + Усі облікові записи видалено локально. + Зневадження + Інші важливі повідомлення + Повідомлення з низьким пріоритетом + Синхронізація + Помилки синхронізації + Важливі помилки, що заважають синхронізації, наприклад несподівані + Попередження синхронізації + Не критичні проблеми синхронізації, наприклад деякі файли хибні + Помилка мережі та вводу/виводу + Спливання часу відклику, проблеми зв\'язку, і т.п. (часто тимчасові) + + Ваша дата. Ваш вибір. + Візьміть під свій контроль. + Інтервали регулярної синхронізації + Мені не потрібні інтервали регулярної синхронізації.* + Детальніше + jtx Board + Підтримка завдань + OpenTasks + Мені не потрібна підтримка завдань.* + ПЗ з відкритим кодом + Як співпрацювати/пожертвувати + Далі + + Дозволи + Використовуйте це, щоб дозволити всі можливості (рекомендується) + Усі дозволи надано + Дозволи контактів + Дозволи календаря + Без синхронізації календаря (не рекомендується) + Можлива синхронізація календаря + Дозволи jtx Board + Дозволи OpenTasks + Дозволи завдань + Можлива синхронізація завдань + Налаштування застосунку + + Дозволи SSID WiFi + + Переклади + Бібліотеки + Версія %1$s (%2$d) + Цей програмний засіб постачається АБСОЛЮТНО БЕЗ БУДЬ-ЯКИХ ГАРАНТІЙ. Це вільне програмне забезпечення, і ви можете поширювати її, за деякими умовами. + + Не вдалося створити файл звіту + Вимкнути + + Адаптер синхронізації CalDAV/CardDAV + Про / Ліцензія + Beta відгук + Будь ласка, встановіть веб-браузер + Налаштування + Новини та оновлення + Зовнішні посилання + Веб сайт + Посібник + Питання/Відповіді + Політика конфіденційності + Синхронізувати всі обліківки + + + Не вдалося виявити сервіси + Не вдалося оновити перелік колекції + + + Налаштування + Зневадження + Показати інформацію зневадження + Детальний журнал + Звітування призупинено + З\'єднання + Безпека + Дозволи застосунку + Не довіряти системним сертифікатам + Не довіряти системним та доданим користувачем сертифікатам + Довіряти системним та доданим користувачем сертифікатам (рекомендується) + Скидання (не)довірених сертифікатів + Скинути довіру до всіх призначених користувачу сертифікатів + Всі сертифікати, що призначені користувачу очищено + Інтерфейс користувача + Налаштування сповіщення + Керування каналами інформування та їх налаштуванням + Вибрати тему + + Системна + Світла + Темна + + Скинути підказки + Включення підказок, які раніше були вимкнуті + Всі підказки будуть показані знову + Застосунок завдань + Не знайдено сумісного застосунку завдань + + CardDAV + CalDAV + Webcal + Синхронізувати зараз + Налаштування облікового запису + Перейменувати обліковий запис + Перейменувати + Ім\'я запису вже зайняте + Не вдалося перейменувати облікувку + Видалити запис + Дійсно видалити обліковий запис? + Всі локальні копії адресних книг, календарів та завдань будуть вилучені. + синхронізувати дану колекцію + лише читання + календар + контакти + журнал + завдання + Не знайдено додатку з підтримкою Webcal + Встановити ICSx⁵ + + Додати запис + Увійти + Увійти за допомогою електронної пошти + Адреса пошти + Потребує валідну електронну адресу + Пароль + Приховати пароль + Показати пароль + Увійти за допомогою URL та імені користувача + Ім\'я користувача + Ім\'я користувача (необов\'язково) + Базовий URL + Обрати сертифікат + Додати запис + Назва запису + Використовуйте вашу електронну адресу як ім\'я облікового запису, так як Android буде використовувати ім\'я облікового запису в полі ORGANIZER для подій, які ви створюватимете. Ви не можете мати два облікових записи з однаковими іменами. + Метод групування контактів: + Потребує назви облікового запису + Ім\'я запису вже зайняте + Не знайдено сертифікат + Встановити сертифікат + Nextcloud + Виявлення конфігурації + Будь ласка, зачекайте, запит до серверу… + Не вдалося знайти CalDAV чи CardDAV сервіс. + + Синхронізація + Інтервал синхронізації контактів + Лише вручну + Кожних %d хвилин, а також негайно при внесенні локальних змін + Інтервал синхронізації календарів + Інтервал синхронізації завдань + + Вручну + Кожні 15 хвилин + Кожні 30 хвилин + Щогодинно + Кожні 2 години + Кожні 4 години + Щоденно + + Синхронізувати лише через Wi-Fi + Виконувати синхронізацію лише через Wi-Fi + Не враховувати тип з\'єднання + Обмеження WiFi SSID + Синхронізувати лише через %s + Може використовуватись всі Wi-Fi з\'єднання + Назви (SSID) дозволених Wi-Fi мереж, розділені комами (залиште порожнім для всіх) + Керувати + Автентифікація + Ім\'я користувача + Новий пароль + Оновити пароль, згідно налаштувань Вашого сервера. + Встановити сертифікат + CalDAV + Інтервал синхронізації + Всі події будуть синхронізовані + + Події старші одного дня будуть проігноровані + Події старші %d днів будуть проігноровані + Події старші %d днів будуть проігноровані + Події старші %d днів будуть проігноровані + + Події старші вказаного часу будуть проігноровані (може бути 0). Залиште порожнім, аби синхронізувати всі події. + Нагадування за замовчуванням + + Типово нагадувати за хвилину до події + Типово нагадувати за %d хвилини до події + Типово нагадувати за %d хвилин до події + Типово нагадувати за %d хвилин до події + + Нагадування за замовчуванням не створюються + Якщо нагадування за замовчуванням створюються для подій без нагадування: бажана кількість хвилин до події. Залиште поле порожнім, щоб вимкнути нагадування за замовчуванням. + Керування кольорами + Підтримка кольорів подій + CardDAV + Метод групування контактів + + Групи-це окремі записи + Групи є в категоріями в контактах + + + Створити адресну книгу + Створити календар + Можливі записи календаря + Події + Завдання + Нотатки/Журнал + Колір + Заголовок + Розташування сховища + Опис (необов\'язково) + Створити + + контакти + завдання + Видалити колекцію + Синхронізація + Заголовок + Опис + + Інформація зневадження + Архів ZIP + Помилка HTTP + Помилка сервера + Помилка WebDAV + Помилка I/O + Переглянути деталі + Скопіювати URL + + Трапилась помилка. + Трапилась помилка HTTP. + Трапилась помилка I/O. + Показати подробиці + + Автентифікація + Ім\'я користувача + Пароль + Ім\'я користувача (необов\'язково) + + Дозволи DAVx⁵ + Потребує додаткові дозволи + Помилка аутентифікації (перевірте обліковий запис) + Помилка мережі та вводу/виводу — %s + Помилка сервера HTTP — %s + Помилка локального сховища — %s + Отримано помилковий контакт від сервера + Отримано помилкову подію від сервера + Отримано помилкове завдання від сервера + Ігнорування одного або більше хибних джерел + + Синхронізувати всі обліківки + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..e670769 --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,328 @@ + + + + Tài khoản không tồn tại (nữa) + Sổ địa chỉ của DAVx⁵ + Xóa + Hủy + Bật + Trường này là bắt buộc + Trợ giúp + Chia sẻ + Cơ sở dữ liệu đã bị lỗi + Tất cả tài khoản đã bị xóa ở cục bộ. + Gỡ lỗi + Thông báo quan trọng khác + Thông báo trạng thái ưu tiên thấp + Đồng bộ hoá + Lỗi đồng bộ hoá + Các lỗi quan trọng mà chúng dừng việc đồng bộ hoá như các câu trả lời không mong đợi của máy chủ + Cảnh báo đồng bộ hoá + Các vấn đề đồng bộ hoá không nghiêm trọng như các tệp không hợp lệ cụ thể + Lỗi mạng và I/O + Hết thời gian chờ, vấn đề kết nối, v.v. (thường là tạm thời) + + Dữ liệu của bạn. Lựa chọn của bạn. + Giành quyền kiểm soát. + Khoảng thời gian thông thường giữa mỗi lần đồng bộ + Để đồng bộ hoá tại những khoảng thời gian thông thường, %s phải được cho phép chạy trong nền. Nếu không, Android có thể sẽ tạm dừng đồng bộ hoá bất cứ lúc nào. + Tôi không cần khoảng thời gian đồng bộ thông thường.* + Sự tương thích với %s + Tôi đã làm xong các cài đặt được yêu cầu. Đừng nhắc lại tôi nữa.* + * Không đánh dấu để được nhắc lại sau. Có thể được đặt lại trong cài đặt ứng dụng / %s. + Thêm thông tin + jtx Board + + Hỗ trợ công việc + Nếu máy chủ của bạn hỗ trợ công việc, chúng có thể được đồng bộ hoá bằng một ứng dụng công việc được hỗ trợ: + OpenTasks + Không có cửa hàng ứng dụng nào có sẵn + Tôi không cần hỗ trợ công việc.* + Phần mềm mã nguồn mở + Chúng tôi rất vui khi thấy bạn sử dụng %s, nó là phần mềm mã nguồn mở. Việc phát triển, duy trì và hỗ trợ là những công việc rất nặng nhọc. Vui lòng cân nhắc việc đóng góp (có nhiều cách) hoặc quyên góp. Chúng tôi sẽ rất cảm kích! + Cách đóng góp/quyên góp + + Quyền + %s yêu cầu các quyền để hoạt động đúng. + Tất cả quyền ở dưới + Sử dụng tuỳ chọn này để bật tất cả tính năng (được khuyến nghị) + Đã cấp tất cả quyền + Quyền danh bạ + Không đồng bộ danh bạ (không được khuyến nghị) + Có thể đồng bộ danh bạ + Quyền lịch + Không đồng bộ lịch (không được khuyến nghị) + Có thể đồng bộ lịch + Quyền của jtx Board + Quyền OpenTasks + Quyền Tasks + Không đồng bộ công việc + Có thể đồng bộ công việc + Giữ các quyền + Các quyền có thể sẽ bị tự động đặt lại (không được khuyến nghị) + Các quyền sẽ không bị tự động đặt lại + Nhấn Quyền > bỏ chọn \"Thu hồi quyền nếu chưa dùng ứng dụng\" + Nếu công tắc không hoạt động, hãy sử dụng cài đặt ứng dụng / Quyền. + Cài đặt ứng dụng + + Quyền WiFi SSID + Để có thể truy cập tên mạng WiFi hiện tại (SSID), những điều kiện này phải được đáp ứng: + Quyền vị trí chính xác + Đã cấp quyền vị trí + Đã từ chối quyền vị trí + Quyền vị trí trong nền + Luôn cho phép + Vị trí luôn được bật + Dịch vụ vị trí đã bật + Dịch vụ vị trí đã tắt + + Bản dịch + Thư viện + Phiên bản %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) và những người đóng góp + Chương trình này KHÔNG CÓ SỰ ĐẢM BẢO NÀO. Nó là phần mềm tự do, và bạn có thể tuỳ ý phân phối lại nó dưới các điều kiện cụ thể. + + Không thể tạo tệp nhật ký + Bây giờ đang ghi lại tất cả %s hoạt động + Xem/chia sẻ + Tắt + + Đồng bộ CalDAV/CardDAV + Giới thiệu / Giấy phép + Phản hồi về bản beta + Vui lòng cài đặt một trình duyệt web + Cài đặt + Tin tức & cập nhật + Công cụ + Liên kết ngoài + Trang web + Hướng dẫn + Câu hỏi thường gặp + Cộng đồng + Chính sách riêng tư + Đồng bộ tất cả tài khoản + + + Dò tìm dịch vụ thất bại + Không thể làm mới danh sách bộ sưu tập + + Đang chạy ở trước + Trên một số thiết bị, điều này là cần thiết để đồng bộ hoá tự động. + + Cài đặt + Gỡ lỗi + Hiện thông tin gỡ lỗi + Ghi nhật ký chi tiết + Ghi nhật ký đã tắt + Tối ưu hoá pin + Kết nối + Loại proxy + + Mặc định hệ thống + Không có proxy + HTTP + SOCKS (cho Orbot) + + Tên máy chủ proxy + Cổng proxy + Bảo mật + Quyền ứng dụng + Xem xét các quyền được yêu cầu để đồng bộ hoá + Bỏ tin tưởng các chứng chỉ hệ thống + Các chứng chỉ CA của hệ thống và được người dùng thêm sẽ không được tin tưởng + Các chứng chỉ CA của hệ thống và được người dùng thêm sẽ được tin tưởng (được khuyến nghị) + Đặt lại các chứng chỉ đã (bỏ) tin tưởng + Đặt lại sự tin tưởng của tất cả chứng chỉ tuỳ chỉnh + Đã xoá tất cả chứng chỉ tuỳ chỉnh + Giao diện người dùng + Cài đặt thông báo + Quản lý các kênh thông báo và cài đặt của chúng + Chọn chủ đề + + Mặc định hệ thống + Sáng + Tối + + Đặt lại các gợi ý + Bật lại các gợi ý đã bị bỏ qua trước đó + Tất cả gợi ý sẽ được hiện lại + Tích hợp + Ứng dụng công việc + Không tìm thấy ứng dụng công việc tương thích + + CardDAV + CalDAV + Webcal + Đồng bộ hoá ngay + Cài đặt tài khoản + Đổi tên tài khoản + Đổi tên + Tên tài khoản đã được sử dụng + Không thể đổi tên tài khoản + Xoá tài khoản + Thực sự xoá tài khoản? + Tất cả bản sao cục bộ của các sổ địa chỉ, lịch và danh sách công việc sẽ bị xoá. + đồng bộ hoá bộ sưu tập này + chỉ đọc + lịch + nhật ký + Chỉ hiện cá nhân + Không tìm thấy ứng dụng nào có khả năng xử lý Webcal + Cài đặt ICSx⁵ + + Thêm tài khoản + Đăng nhập + Đăng nhập bằng địa chỉ email + Địa chỉ email + Yêu cầu địa chỉ email hợp lệ + Mật khẩu + Đăng nhập bằng URL và tên người dùng + Tên người dùng + URL cơ sở + Chọn chứng chỉ + Thêm tài khoản + Tên tài khoản + Sử dụng địa chỉ email của bạn làm tên tài khoản vì Android sẽ sử dụng tên tài khoản làm trường ORGANIZER cho các sự kiện bạn tạo. Bạn không thể có hai tài khoản với cùng một tên. + Phương pháp nhóm danh bạ: + Yêu cầu tên tài khoản + Tên tài khoản đã được sử dụng + Không tìm thấy chứng chỉ nào + Cài đặt chứng chỉ + Dò tìm thiết lập + Vui lòng đợi, đang truy vấn máy chủ… + Không thể tìm dịch vụ CalDAV hoặc CardDAV. + Xem nhật ký + + Đồng bộ hoá + Khoảng thời gian giữa mỗi lần đồng bộ danh bạ + Chỉ thủ công + Mỗi %d phút + ngay lập tức khi có thay đổi cục bộ + Khoảng thời gian giữa mỗi lần đồng bộ lịch + Khoảng thời gian giữa mỗi lần đồng bộ công việc + + Chỉ thủ công + Mỗi 15 phút + Mỗi 30 phút + Mỗi tiếng + Mỗi 2 tiếng + Mỗi 4 tiếng + Một lần một ngày + + Chỉ đồng bộ khi có WiFi + Việc đồng bộ hoá bị giới hạn chỉ có kết nối WiFi + Loại kết nối không được cân nhắc + Giới hạn WiFi SSID + Sẽ chỉ đồng bộ qua %s + Tất cả kết nối WiFi sẽ được sử dụng + Tên được chia tách bởi dấu phẩy (SSID) của các mạng WiFi được cho phép (để trống để cho phép tất cả) + Giới hạn WiFi SSID yêu cầu cài đặt sâu hơn + Quản lý + Xác thực + Tên người dùng + Cập nhật mật khẩu theo như máy chủ của bạn. + Cài đặt chứng chỉ + CalDAV + Giới hạn thời gian cho sự kiện trong quá khứ + Tất cả sự kiện sẽ được đồng bộ hoá + + Các sự kiện cách đây hơn %d ngày trong quá khứ sẽ bị bỏ qua + + Các sự kiện cách đây hơn số ngày này trong quá khứ sẽ bị bỏ qua (có thể là 0). Để trống để đồng bộ hoá tất cả sự kiện. + Lời nhắc mặc định + + Lời nhắc mặc định %d phút trước sự kiện + + Chưa có lời nhắc mặc định nào được tạo + Nếu các lời nhắc mặc định sẽ được tạo cho các sự kiện không cỏ lời nhắc: số phút được mong muốn trước sự kiện. Để trống để tắt lời nhắc mặc định. + Quản lý màu lịch + Màu lịch được đặt lại tại mỗi lần đồng bộ + Màu lịch cỏ thể được các ứng dụng khác đặt + Hỗ trợ màu sự kiện + Màu sự kiện được đồng bộ + Màu sự kiện không được đồng bộ + CardDAV + Phương pháp nhóm danh bạ + + Các nhóm là các tệp vCard riêng + Các nhóm là các hạng mục cho từng liên hệ + + + Tạo sổ địa chỉ + Tạo lịch + Các mục của lịch có thể có + Sự kiện + Công việc + Ghi chú / nhật ký + Màu + Tiêu đề + Vị trí kho lưu trữ + Tạo + + Xoá bộ sưu tập + Đồng bộ hoá + Tiêu đề + Mô tả + + Thông tin gỡ lỗi + Tệp nén ZIP + Chứa thông tin gỡ lỗi và nhật ký + Chia sẻ tệp nén để truyền sang máy tính, để gửi đi bằng email hoặc để đính kèm vào yêu cầu hỗ trợ. + Chia sẻ tệp nén + Đã đính kèm thông tin gỡ lỗi vào tin nhắn này (yêu cầu ứng dụng nhận có hỗ trợ tệp đính kèm). + Lỗi HTTP + Lỗi máy chủ + Lỗi WebDAV + Lỗi I/O + Xem chi tiết + Đã thu thập thông tin gỡ lỗi + Tài nguyên có liên quan + Có liên quan đến vấn đề + Tài nguyên trên mạng: + Tài nguyên cục bộ: + Nhật ký + Có nhật ký chi tiết + Xem nhật ký + Sao chép URL + + Đã xảy ra lỗi. + Đã xảy ra lỗi HTTP. + Đã xảy ra lỗi I/O. + Hiện chi tiết + + Nơi gắn WebDAV + Hạn mức đã sử dụng: %1$s / có sẵn: %2$s + Chia sẻ nội dung + Bỏ gắn + Thêm nơi gắn WebDAV + Truy cập trực tiếp các tệp trên đám mây bằng cách thêm nơi gắn WebDAV! + Tên hiển thị + URL WebDAV + URL không hợp lệ + Xác thực + Tên người dùng + Mật khẩu + Thêm nơi gắn + Không có dịch vụ WebDAV tại URL này + Xóa điểm gắn + Chi tiết kết nối sẽ bị mất, nhưng các tệp sẽ không bị xóa. + Đang truy cập tệp WebDAV + Đang tải xuống tệp WebDAV + Đang tải lên tệp WebDAV + Nơi gắn WebDAV + + Quyền của DAVx⁵ + Yêu cầu quyền bổ sung + %s quá cũ + Phiên bản tối thiểu được yêu cầu: %1$s + Xác thực thất bại (hãy kiểm tra thông tin đăng nhập) + Lỗi mạng hoặc I/O – %s + Lỗi máy chủ HTTP – %s + Lỗi kho lưu trữ cục bộ – %s + Đã nhận liên hệ không hợp lệ từ máy chủ + Đã nhận sự kiện không hợp lệ từ máy chủ + Đã nhận công việc không hợp lệ từ máy chủ + Đang bỏ qua một hoặc nhiều tài nguyên không hợp lệ + + Đồng bộ tất cả tài khoản + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..b591b3f --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,476 @@ + + + + 帳號(已)不存在 + DAVx⁵ 通訊錄 + 別在這裡更改帳戶!請直接使用應用程式管理帳戶。 + 刪除 + 移除 + 取消 + 啟用 + 此為必填欄位 + 幫助 + 向上導航 + 選項選單 + 分享 + 同步已開始或排入佇列 + 資料庫損毀 + 所有帳號已在本地刪除 + 除錯 + 其他重要訊息 + 低優先的狀態訊息 + 同步 + 同步錯誤 + 導致同步停止的嚴重錯誤,如異常的伺服器回應 + 同步警告 + 可忽略的同步問題,比如一些無效檔案 + 網路和輸入輸出錯誤 + 逾時、連線問題等等(通常為暫時性) + + 您的資料,您的選擇 + 權力在握 + 定期同步間隔 + 為了定期進行同步,必須允許 %s 在背景運行,否則 Android 可能會隨時暫停同步。 + 我不需要定期同步間隔* + %s 相容性 + 特定廠商的韌體可能會阻止同步。如果您受到影響,您只能手動解決這一問題。 + 所需設定已完成,不用再提醒我* + * 取消勾選則稍後會再次提醒,可於設定中重置 / %s + 更多資訊 + jtx Board + + 待辦事項支援 + 如果你的服務器支持任務,它們可以與支援任務的app同步: + OpenTasks + 似乎已不再繼續開發 - 不建議使用。 + Tasks.org + 不被支援。]]> + 沒有應用商店可用 + 我不需要任務支援。* + 開源軟體 + 我們很高興您使用 %s 開源軟體。開發、維護和支持是艱苦的工作。請考慮透過多種方式提供貢獻或捐款。不勝感激! + 如何貢獻或捐款 + 不要提醒時長 + + %d 個月 + + 繼續 + + 權限 + %s需要權限才能正常工作 + 以下所有 + 使用它來啟用所有功能(推薦) + 已授予所有權限 + 通訊錄權限 + 無聯絡人同步(不推薦) + 可同步聯絡人 + 行事曆權限 + 無日曆同步(不推薦) + 可同步日曆 + 通知權限 + 已關閉通知(不推薦) + 已啟用通知 + jtx Board 權限 + OpenTasks 權限 + Tasks 權限 + 無任務同步 + 可同步任步 + 保持權限 + 權限可能會被自動重設(不推薦) + 權限不會被自動重設 + 點選權限 > 取消勾選「若應用程式未使用則移除權限」 + 如果開關無法使用,請前往應用程式設定 / 權限。 + 應用程式設定 + + WiFi SSID 權限 + 要能夠存取目前的 WiFi 名稱(SSID),必須符合以下條件: + 精確位置權限 + 已授予位置權限 + 已拒絕位置權限 + 背景位置權限 + 永遠允許 + 位置權限已設定為:%s + 位置權限未設定為:%s + %s使用位置資料(僅限 WiFi SSID)僅用來限制同步至特定的 WiFi SSID。即使同步在背景執行時,也會套用此限制。 + 所有位置資料(僅限 WiFi SSID)皆僅在本機使用,不會傳送至任何地方。 + 位置永遠啟用 + 位置服務已啟用 + 位置服務已停用 + + 翻譯 + 函式庫 + 版本號 %1$s(%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) 與其他貢獻者 + 我們「完全不保證」本程式無瑕疵。這是個自由軟體,歡迎您在符合公用授權條款的情況下任意散布它。 + + 無法創建事項記錄文檔 + 現在正在記錄所有 %s 活動 + 檢視/分享 + 停用 + + CalDAV/CardDAV 同步器 + 關於我們 / 授權條款 + 為測試版本給回饋意見 + 請安裝一個瀏覽器程式 + 設定 + 新聞 & 更新 + 工具 + 外部連結 + 我們的網站 + 使用説明書 + 常見問答 + 適用於組織 + 社群 + 支持此項目 + 如何貢獻 + 隱私權政策 + 歡迎使用 DAVx⁵! + 連線到您的伺服器,並保持行事曆與聯絡人同步。 + 同步所有帳戶 + + 通知已停用。您將不會收到同步錯誤的通知。 + 自動同步未啟用(沒有已驗證的網際網路連線)。 + 管理連線 + 已啟用數據節省模式。背景同步受到限制。 + 管理數據節省模式 + 已啟用省電模式。同步可能會受到限制。 + 管理省電模式 + 儲存空間不足。Android 不會立即同步本機變更,而會在下次的定期同步時進行。 + 管理儲存空間 + 缺少行事曆提供者 + 您是否已停用「行事曆儲存空間」系統應用程式? + 缺少聯絡人提供者 + 您是否已停用「聯絡人儲存空間」系統應用程式? + 管理應用程式 + + 未發現遠端服務 + 無法更新清單 + + 在前景執行 + 在某些裝置上,這對自動同步是必要的。 + + 設定 + 除錯 + 顯示除錯訊息 + 檢視/分享組態詳細資料與日誌 + 詳細除錯記錄 + 記錄功能已啟用。您可以在除錯資訊中檢視日誌。 + 日誌記錄已停用 + 電池最佳化 + 排除本應用程式(建議) + 套用電池限制(不建議) + 網路連線 + 代理類型 + + 系統預設 + 無代理 + HTTP + SOCKS(用於 Orbot) + + 代理主機名稱 + 代理連接埠 + 安全性 + 應用程式權限 + 檢視同步所需的權限 + 不信任系統憑證 + 系統憑證和使用者自訂憑證將不被信任 + 系統憑證和使用者自訂憑證將被信任 (推薦設定) + 若啟用此設定,系統憑證將不被視為可信任。這表示您必須手動接受每一張憑證(包含伺服器更新憑證時),否則帳戶設定與同步將無法運作。 + 重新開啟之前關閉的提示 + 重設對所有自訂憑證的信任 + 所有自訂憑證已清除 + 使用介面 + 通知設定 + 管理通知頻道和設定 + 選擇主題 + + 系統預設 + 淺色 + 深色 + + 重新開啟提示 + 重新啟用之前取消的提示 + 所有提示將再次顯示 + 整合 + 待辦事項 應用程式 + 找不到相容的待辦事項應用程式 + UnifiedPush(實驗性) + 無(停用推播) + 選擇分發服務 + 未安裝推播分發服務 + 未設定端點 + 已準備好透過 %s 接收推播訊息 + FCM (Google Play) + 推播訊息一律加密。 + + 帳戶已被移除 + CardDAV聯絡人檔案 + CalDav行事曆檔案 + Webcal網際網絡行事曆 + 需要額外的權限才能同步這些收藏。 + 管理權限 + 立即同步 + 帳號設定 + 重新命名帳號 + 未儲存的本機資料可能會被捨棄。重新命名後需要再次同步。 + 新帳戶名稱 + 重新命名 + 這個賬號名稱已經被取過了 + 無法重新命名帳號 + 刪除帳號 + 確定要刪除帳號? + 這台裝置上這個帳號的通訊錄、行事曆和工作清單將被刪除。 + 同步這個行事曆或工作清單 + 唯讀 + 行事曆 + 聯絡人 + 日誌 + 待辦事項 + 只顯示個人 + 重新整理清單 + Webcal 訂閱可與外部應用程式同步。 + 未找到支援Webcal的APP + 安裝ICSx⁵ + + 新增帳號 + 隱私權政策。]]> + 一般登入 + 特定提供者登入 + 繼續 + 登入 + 用 Email 地址登入 + Email 地址 + 請輸入有效的 Email 地址 + 服務會透過 DNS 紀錄與 well-known URL 自動探索。]]> + 密碼 + 隱藏密碼 + 顯示密碼 + 密碼(可選) + 用網址和帳號登入 + 使用者帳號 + 使用者名稱(可選) + 根 URL + 服務也會透過 DNS 紀錄與 well-known URL 自動探索。]]> + 點選憑證 + 新增帳號 + 帳號名稱 + 在某些裝置上使用單引號 (\') 似乎會造成問題。 + 使用 Email 地址當作裝置上的帳號顯示名稱,因為當您在行事曆創建活動時,Android 會把帳號顯示名稱放到「活動發起人」欄位。兩個帳號不能有相同的名稱。 + 聯絡人群組的儲存格式 + 需要帳號名稱 + 這個賬號名稱已經被取過了 + 無法新增帳戶 + 完成 + 進階登入 + 無用戶端憑證(可選) + 用戶端憑證:%s + 找不到憑證 + 安裝憑證 + Fastmail + Fastmail 帳戶 + 使用 Fastmail 登入 + Google 聯絡人 / 行事曆 + Google 帳戶 + 使用 Google 登入 + 用戶端 ID(可選) + 隱私權政策。]]> + Google API 服務使用者資料政策,包括有限使用的相關要求。]]> + 無法取得授權碼 + Nextcloud + 使用 Nextcloud 登入 + 這將在網頁瀏覽器中啟動 Nextcloud 登入流程。 + Nextcloud 伺服器位址 + 登入 + 無法取得登入 URL + 無法取得登入資料 + 設定錯誤 + 請稍待,正在詢問伺服器… + 找不到 CalDAV 或 CardDAV 服務。 + 基礎 URL 似乎不是可存取的 CalDAV/CardDAV URL,且服務偵測未成功。 + 我們的已測試服務清單及其基礎 URL。]]> + 請同時再次確認驗證資訊(通常是使用者名稱與密碼)。 + 更多技術資訊可在日誌中取得。 + 檢視日誌 + + 同步設定 + 聯絡人同步間隔 + 只手動同步 + 每 %d 分鐘,以及在本裝置上修改時 + 行事曆同步間隔 + 待辦事項同步間隔 + + 僅手動 + 每15分鐘自動 + 每30分鐘自動 + 每小時自動 + 每2小時自動 + 每4小時自動 + 每天自動 + + 只用 WiFi 同步 + 只於 WiFi 連線時同步 + 任何網路連線都可使用 + 限用特定 WiFi SSID + 只在%s連線時同步 + 所有 WiFi 連線都可以使用 + 使用逗號分割的名稱 (SSIDs) 表示的 WiFi 連線(留空則代表全部) + WiFi SSID 限制需要進一步設定 + 管理 + VPN 需要基礎網際網路連線 + 沒有基礎已驗證網際網路連線的 VPN 不足以執行同步(建議) + 沒有基礎已驗證網際網路連線的 VPN 仍可執行同步 + 認證 + 使用者帳號 + 密碼或應用程式專用密碼 + 應用程式專用密碼。]]> + 新密碼 + 您在伺服器上使用中的密碼 + 再次授權(OAuth) + 當存取權遭撤銷時使用 + 授權成功 + 用戶端憑證 + 沒有可用或已選取的憑證 + 安裝憑證 + CalDAV + 過去活動的時間限制 + 將會同步所有活動 + + %d 天之前的活動會被忽略 + + 此天數前的活動將會被忽略(可設為零),若留空則同步所有活動 + 預設提醒 + + 預設在活動前 %d 分鐘提醒 + + 未設定預設提醒 + 當沒有提醒的活動需要加入預設提醒時,活動開始前多少分鐘出發提醒。留空則停用預設提醒。 + 管理行事曆的顏色 + 行事曆顏色會在每次同步時重設 + 行事曆顏色可由其他應用程式設定 + 設定活動的顔色 + 活動顏色已同步 + 活動顏色未同步 + CardDAV + 聯絡人群組的儲存格式 + + 群組存成額外的 VCard 檔案 + 群組存成每個聯絡人的分類屬性 + + + 建立通訊錄 + 伺服器可能不支援透過 CardDAV 建立通訊錄。 + 建立行事曆 + 預設時區(可選) + + 可使用的行事曆項目 + 活動 + 事務 + 筆記/日誌 + 伺服器可能不支援透過 CalDAV 建立行事曆。 + 顔色 + 標題 + 存儲位置 + 描述(可選) + 建立 + + 聯絡人 + 活動 + 待辦事項 + 刪除行事曆或工作清單 + 此收藏(%s)及其所有資料將被永久移除,包括本機與伺服器上的內容。 + 同步 + 已啟用同步 + 已停用同步 + 唯讀 + 唯讀(由伺服器設定) + 唯讀(由設定決定) + 唯讀(僅限本機) + 讀取/寫入 + 標題 + 描述 + 擁有者 + Push support + 伺服器宣告支援推播 + 於 %1$s 訂閱,於 %2$s 到期 + 上次同步(%s) + 位址(URL) + + 除錯訊息 + ZIP 壓縮檔 + 包含除錯資訊與日誌 + 分享此封存檔以傳輸至電腦、透過電子郵件傳送,或附加至支援服務單。 + 分享封存檔 + 已將除錯資訊附加至此訊息(需要接收應用程式支援附件)。 + HTTP 錯誤 + 伺服器錯誤 + WebDAV 錯誤 + 讀寫錯誤 + 伺服器不允許執行請求的操作類型。 + 發生伺服器端問題。請聯絡您的伺服器支援人員。 + 發生非預期的錯誤。請檢視除錯資訊以取得詳細內容。 + 顯示詳細訊息 + 已收集除錯資訊 + 相關資源 + 與問題相關 + 遠端資源: + 本機資源: + 日誌 + 可用詳細日誌 + 更多技術資訊可在日誌中取得。 + 拷貝URL + 隱私權通知 + 日誌和除錯資訊可能包含私人資訊,請在公開分享時注意。 + + 發生錯誤 + HTTP 發生錯誤 + 讀寫錯誤 + 顯示細節 + + WebDAV 掛載 + 已使用配額:%1$s / 可用配額:%2$s + 分享内容 + 取消掛載 + 新增 WebDAV 掛載 + 只要新增對應的 WebDAV 掛載就可以直接存取你的雲端檔案! + WebDAV 如何運作請見文件。]]> + 顯示名稱 + WebDAV 網址 + 無效 URL + 掛載點和顯示名稱 + 認證 + 使用者帳號 + 密碼 + 使用者名稱(可選) + 密碼(可選) + 新增掛載 + 此網址沒有 WebDAV 服務 + 移除掛點點 + 連線詳細資訊將會遺失,但不會刪除任何檔案。 + 正在存取 WebDAV 檔案 + 正在下載 WebDAV 檔案 + 正在上傳檔案至 WebDAV + WebDAV 掛載 + + DAVx⁵ 權限 + 需要額外的權限 + %s過舊 + 最低需求版本:%1$s + 鑒權失敗(你需要檢查登錄憑證) + 網際網絡或者輸入輸出錯誤——%s + HTTP伺服器錯誤——%s + 資料庫錯誤——%s + 非嚴重錯誤(已達到最大重試次數) + 收到了無效的聯絡人 + 收到了無效的活動 + 收到了無效的任務 + 略過了一個或多個無效的資料 + 同步處理中 + 遠端資料已變更 + + 全部同步 + 同步所有帳戶 + 標示的同步按鈕 + 同步按鈕圖示 + 點擊以手動執行同步。 + + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..8dc2772 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,473 @@ + + + + 账户(已)不存在 + DAVx⁵ 通讯录 + 别在这里更改账户!请直接使用应用管理账户。 + 删除 + 删除 + 取消 + 启用 + 此字段是必填项 + 帮助 + 向上导航 + 选项菜单 + 分享 + 同步已启动/已加入队列 + 数据库损坏 + 所有帐户已在本地删除。 + 调试 + 其它重要消息 + 低优先级状态消息 + 同步 + 同步错误 + 导致同步停止的重要错误,如异常的服务器响应 + 同步警告 + 不重要的同步问题,如某文件无效 + 网络或 I/O 错误 + 超时、连接异常等问题(通常是临时错误) + + 您的数据。您的选择。 + 获得控制。 + 定期同步间隔 + 为了定期进行同步,必须允许%s在后台运行。否则,Android可能会随时暂停同步。 + 我不需要定期的同步。* + %s兼容性 + 特定厂商的固件可能会阻止同步。如果你受到影响,你只能手动解决这一问题。 + 我已完成所需的设置。不再提醒我。* + *取消选中以供稍后提醒。可以在应用设置中重置/%s。 + 更多信息 + jtx Board + + 任务支持 + 如果你的服务器支持任务,它们可以通过一个受支持的任务应用进行同步: + OpenTasks + 似乎已不再开发 — 不推荐 + Tasks.org + 不被支持。]]> + 没有可用的应用商店 + 我不需要任务支持。* + 开源软件 + 我们很高兴您使用 %s 开源软件。开发、维护和支持是艰苦的工作。请考虑通过多种方式提供贡献或捐款。不胜感激! + 如何贡献或捐款 + 不要提醒时长 + + %d 个月 + + 继续 + + 权限 + %s需要权限才能正常工作 + 以下所有 + 使用它来启用所有特性 (推荐) + 已授予全部权限 + 联系人权限 + 无联系人同步(不推荐) + 可同步联系人 + 日历权限 + 无日历同步(不推荐) + 可同步日历 + 通知权限 + 已禁用通知(不推荐) + 已启用通知 + jtx Board 权限 + OpenTasks权限 + Tasks权限 + 无任务同步 + 可同步任务 + 保留权限 + 权限可能被自动重置(不推荐) + 权限不会被自动重置 + 点击权限 > 取消选择 “移除权限,如果应用未使用” + 如果切换没有正常工作,请使用应用程序设置/权限 + 应用设置 + + WiFi SSID权限 + 要访问当前的WiFi名称(SSID),必须满足以下条件: + 精确位置权限 + 已授予位置权限 + 位置权限被拒 + 后台位置权限 + 始终允许 + 位置权限已设为:%s + 位置权限未设为:%s + %s 使用位置数据 (仅 WiFi SSID) 的目的只是为了将同步限制到特定的 WiFi SSID。即使当同步在后台运行时,这也会发生。 + 所有位置数据(仅 WiFi SSID)只在本地使用,不会被发送到任何地方。 + 始终允许定位 + 位置服务已启用 + 位置服务已禁用 + + 翻译 + 程序库 + 版本 %1$s (%2$d) + ©Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) 及贡献者 + 本程序不附带任何担保。这是一款自由软件,你可以有条件地传播它。 + + 无法创建日志文件 + 正记录%s的所有活动 + 查看/分享 + 禁用 + + CalDAV/CardDAV 同步器 + 关于 / 许可 + 测试版反馈 + 请安装网页浏览器 + 设置 + 最新消息 + 工具 + 外部链接 + 应用网站 + 手册 + 常见问题 + 面向机构 + 社区 + 支持项目 + 如何作贡献 + 隐私政策 + 欢迎来到 DAVx⁵! + 连接到你的服务器,保持日历和联系人同步 + 同步所有账户 + + 已禁用通知。你将不会收到同步出错的通知 + 自动同步不活跃(无已验证的互联网连接) + 管理连接 + 启用了流量节省程序。后台同步受限 + 管理流量节省程序 + 启用了节电程序。同步可能受限。 + 管理节电程序 + 低存储空间。Android 不会立即同步本地更改,但会在下次定期同步时进行 + 管理存储 + 缺少日历程序 + 你禁用了“日历存储”系统应用吗? + 缺少联系人程序 + 你禁用了“联系人存储”系统应用吗? + 管理应用 + + 服务配置检测失败 + 无法刷新集合列表 + + 运行于前台 + 在某些设备上,这是自动同步所必需的。 + + 设置 + 调试 + 显示调试信息 + 查看/分享配置详情和日志 + 记录完整日志 + 日志记录处于活跃状态。你可以将日志作为调试信息的一部分来查看 + 日志记录已禁用 + 电池优化 + 排除本应用(推荐) + 施加电池限制(不推荐) + 连接 + 代理类型 + + 系统默认 + 无代理 + HTTP + SOCKS (用于 Orbot) + + 代理主机名称 + 代理端口 + 安全 + 应用权限 + 查看同步所需权限 + 不信任系统证书 + 系统和用户增加的发布者不会被信任 + 系统和用户增加的发布者会被信任(推荐) + 如果此设置处于开启状态,系统证书不会被认为是可信的。这表示你必须手动接受每一个证书(服务器更新其证书时也必须加以确认)或账户设置,且同步不会工作。 + 重设证书信任状态 + 重设所有自定义证书的信任状态 + 所有自定义证书已清除 + 用户界面 + 通知设置 + 管理通知渠道等设置 + 选择主题 + + 系统默认 + 浅色 + 深色 + + 重设提示 + 重新显示之前忽略过的提示 + 所有提示将会再次显示 + 集成 + Tasks 应用 + 未找到兼容的任务应用 + UnifiedPush (实验性) + 无(停用推送) + 选择分发程序 + 未安装推送分发程序 + 未配置端点 + 准备好通过 %s 接收推送消息 + FCM (Google Play) + 推送消息始终是加密的 + + 账户已被删除 + CardDAV + CalDAV + Webcal + 需要额外权限来同步这些集合 + 管理权限 + 立即同步 + 账户设置 + 重命名账户 + 未保存的本地数据可能会消失。重命名后需要重新同步。 + 新账户名 + 重命名 + 账户名已被占用 + 无法重命名账户 + 删除账户 + 真的要删除账户吗? + 所有通讯录、日历和任务列表的本机存储将被删除。 + 同步该集合 + 只读 + 日历 + 联系人 + 日记 + 任务 + 只显示个人 + 刷新列表 + 可以用外部应用来同步 Webcal 订阅 + 找不到支持 Webcal 的应用 + 安装 ICSx⁵ + + 增加账户 + 隐私政策。]]> + 常规登录 + 特定服务商的登录 + 继续 + 登录 + 使用邮箱地址登录 + Email 地址 + 请输入有效 Email 地址 + 服务发现 通过 DNS 记录和已知URLs 进行。]]> + 密码 + 隐藏密码 + 显示密码 + 密码(可选) + 使用 URL 和用户名登录 + 用户名 + 用户名(可选) + 根地址 + 服务发现也将使用 DNS 记录 和已知 URLs 进行。]]> + 选择证书 + 增加账户 + 账户显示名 + 使用撇号(\')似乎会在一些设备上造成问题 + 请使用你的邮箱地址作为帐户名,因为 Android 会将你创建的日历事件的创建者项设置为帐户名。你不能拥有多个帐户名相同的账户。 + 联系人分组方式 + 请输入账户名 + 账户名已被占用 + 无法添加账户 + 完成 + 高级登录 + 无客户端证书(可选) + 客户端证书:%s + 没有找到证书 + 安装证书 + Fastmail + Fastmail 账户 + 使用 Fastmail 登录 + Google 联系人/日历 + Google 账户 + 使用 Google 账户登录 + Client ID (可选) + 隐私政策 。]]> + Google API 服务用户数据政策,包括有限使用的要求。]]> + 无法获得身份验证码 + Nextcloud + 用 Nextcloud 登录 + 这会在网页浏览器中开启 Nextcloud 登录流程 + Nextcloud 服务器地址 + 登录 + 无法获取登录 URL + 无法获得登陆数据 + 正在配置 + 正在与服务器通信,请稍等… + 找不到 CalDAV 或 CardDAV 服务。 + 基URL似乎不是可访问的CalDAV/CardDAV URL 且服务检测不成功。 + 我们的已测试服务列表 及它们的基础 URLs.]]> + 也请仔细核查身份验证数据(通常是用户名和密码)。 + 可以在日志中看到进一步的技术信息 + 查看日志 + + 同步 + 通讯录自动同步间隔 + 手动同步 + 每 %d 分钟或本地修改后 + 日历自动同步间隔 + 任务自动同步间隔 + + 手动同步 + 每 15 分钟 + 每 30 分钟 + 每小时 + 每 2 小时 + 每 4 小时 + 每天一次 + + 只在 WiFi 下同步 + 同步只在 WiFi 连接下进行 + 同步不受数据连接类型限制 + WiFi SSID 限制 + 只使用 %s 网络同步 + 任意 WiFi 网络均可同步 + 请用半角逗号分隔允许同步的 WiFi 网络名(SSID),留空则允许任意网络 + WiFi SSID 限制需要进一步设置 + 管理 + VPN 需要底层互联网 + 没有底层验证的互联网连接的 VPN 不足以运行同步(推荐选项) + 没有底层验证的互联网连接的 VPN 足以运行同步了 + 认证 + 用户名 + 密码或应用密码 + 应用密码.]]> + 新密码 + 修改服务器密码 + 再次授权 (OAuth) + 当访问权被撤销时使用 + 授权成功 + 客户端证书 + 无证书可用或未选择证书 + 安装证书 + CalDAV + 旧日程时间限制 + 同步所有日程 + + %d 天前的日程不会被同步 + + 超过这个数字的天数的旧日程将会被忽略(可以为 0)。留空则同步所有日程。 + 默认提醒 + + 默认事件开始前 %d 分钟提醒 + + 默认提醒未创建 + 当没有提醒的事件需增加默认提醒时,事件开始前多少分钟触发提醒。留空以禁用默认提醒。 + 管理日历颜色 + 日历的颜色会在每次同步时被重置 + 日历的颜色可以由其他应用程序设置 + 事件日历颜色支持 + 事件颜色已同步 + 事件颜色未同步 + CardDAV + 联系人分组方式 + + 按 VCard 文件分组 + 按联系人分类分组 + + + 创建通讯录 + 服务器可能不支持通过 CalDAV 创建通讯录 + 创建日历 + 默认时区(可选) + + 可能使用的日历类型 + 事件 + 任务 + 笔记 / 日志 + 服务器可能不支持通过 CalDAV 创建日历 + 颜色 + 标题 + 存储位置 + 描述(可选) + 创建 + + 联系人 + 活动 + 任务 + 删除集合 + 此集合(%s)及其所有数据将从本地和服务器被永久删除 + 同步 + 同步已启用 + 已停用同步 + 只读 + 只读(服务器) + 只读(设置决定) + 只读 (仅本地) + 读/写 + 标题 + 描述 + 所有者 + 推送支持 + 服务器宣告推送支持 + 订阅于 %1$s,过期于 %2$s + 上次同步(%s) + 地址(URL) + + 调试信息 + ZIP 压缩文件 + 包含调试信息和日志 + 共享压缩文件以将其传输到计算机上,通过电子邮件发送或将其附加到支持请求。 + 分享压缩文件 + 已附加调试信息到此消息(需要接收应用支持附件功能) + HTTP错误 + 服务器错误 + WebDAV错误 + I/O错误 + 查看细节 + 已收集调试信息 + 所涉资源 + 与此问题有关 + 远程资源: + 本地资源: + 日志 + 详细日志可用 + 查看日志 + 复制 URL + 隐私声明 + 日志和调试信息可能包含私密信息。公开分享时请意识到这一点 + + 出现错误 + 出现 HTTP 错误 + 出现 I/O 错误 + 显示详情 + + WebDAV 文件系统 + 已用配额:%1$s/可用容量:%2$s + 分享内容 + 解除挂载 + 添加 WebDAV 文件系统 + 通过添加 WebDAV 挂载直接访问您的云文件! + WebDAV 挂载如何工作.]]> + 展示名称 + WebDAV URL + 无效 URL + 挂载点和显示名称 + 认证 + 用户名 + 密码 + 用户名(可选) + 密码(可选) + 添加 WebDAV 网址 + 此 URL 无 WebDAV 服务 + 删除装载点 + 将丢失连接详情,但不会删除文件 + 正在访问 WebDAV 文件 + 正在下载 WebDAV 文件 + 正在上传 WebDAV 文件 + WebDAV 文件系统 + + DAVx⁵ 权限 + 需要额外权限 + %s太旧 + 最低要求版本: %1$s + 认证失败(请检查登录凭据,如用户名密码) + 网络或 I/O 错误 – %s + HTTP 服务器错误 – %s + 本地存储错误 – %s + 软错误(达到最大重试次数) + 从服务器收到无效的通讯录 + 从服务器收到无效的日历事件 + 从服务器收到无效的任务项 + 正在忽略若干无效资源 + 待同步 + 远程数据已更改 + + 同步所有 + 同步所有账户 + 带标签的同步按钮 + 同步按钮图标 + 轻按手动运行同步 + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..62ec229 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #7cb342 + #aee571 + #4b830d + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6ea89a1 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,544 @@ + + + + + + DAVx⁵ + + Account does not exist (anymore) + bitfire.at.davdroid + at.bitfire.davdroid.address_book + DAVx⁵ Address book + Don\'t change the account here! Directly use the app to manage accounts instead. + Delete + Remove + Cancel + Enable + This field is required + Help + Navigate up + Options menu + Share + Synchronization started/enqueued + + Database corrupted + All accounts have been removed locally. + + Debugging + Other important messages + Low-priority status messages + Synchronization + Synchronization errors + Important errors which stop synchronization like unexpected server replies + Synchronization warnings + Non-fatal synchronization problems like certain invalid files + Network and I/O errors + Timeouts, connection problems, etc. (often temporary) + + + Your data. Your choice. + Take control. + Regular sync intervals + For synchronization at regular intervals, %s must be allowed to run in the background. Otherwise, Android may pause synchronization at any time. + I don\'t need regular sync intervals.* + %s compatibility + Vendor-specific firmware may block synchronization. If you\'re affected, you can only resolve this manually. + I have done the required settings. Don\'t remind me anymore.* + * Leave unchecked to be reminded later. Can be reset in app settings / %s. + More information + jtx Board + + Tasks support + If tasks are supported by your server, they can be synchronized with a supported tasks app: + OpenTasks + Doesn\'t seem to be developed anymore – not recommended. + Tasks.org + are not supported.]]> + No app store available + I don\'t need tasks support.* + Open-source software + We\'re happy that you use %s, which is open-source software. Development, maintenance and support are hard work. Please consider contributing (there are many ways) or a donation. It would be highly appreciated! + How to contribute/donate + Don\'t remind me for + + %d month + %d months + + Next + + + Permissions + %s requires permissions to work properly. + All of the below + Use this to enable all features (recommended) + All permissions granted + Contacts permissions + No contact sync (not recommended) + Contact sync possible + Calendar permissions + No calendar sync (not recommended) + Calendar sync possible + Notification permission + Notifications disabled (not recommended) + Notifications enabled + jtx Board permissions + OpenTasks permissions + Tasks permissions + No task sync + Task sync possible + Keep permissions + Permissions may be reset automatically (not recommended) + Permissions won\'t be reset automatically + Click Permissions > uncheck \"Remove permissions if app isn\'t used\" + If a switch doesn\'t work, use app settings / Permissions. + App settings + + + WiFi SSID permissions + To be able to access the current WiFi name (SSID), these conditions must be met: + Precise location permission + Location permission granted + Location permission denied + Background location permission + Allow all the time + Location permission set to: %s + Location permission not set to: %s + %s uses location data (only WiFi SSID) solely to restrict synchronization to a specific WiFi SSID. This will happen even when the synchronization runs in background. + All location data (only WiFi SSID) are only used locally and are not sent anywhere. + Location always enabled + Location service is enabled + Location service is disabled + + + Translations + Libraries + Version %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) and contributors + This program comes with ABSOLUTELY NO WARRANTY. It is free software, and you are welcome to redistribute it under certain conditions. + + + Couldn\'t create log file + Now logging all %s activities + View/share + Disable + + + CalDAV/CardDAV Sync Adapter + About / License + Beta feedback + Please install a Web browser + Settings + News & updates + Tools + External links + Web site + Manual + FAQ + For organizations + Community + Support the project + How to contribute + Privacy policy + Welcome to DAVx⁵! + Connect to your server and keep your calendars and contacts synchronized. + Sync all accounts + + + Notifications disabled. You won\'t be notified about sync errors. + Automatic synchronization not active (no verified Internet connection). + Manage connections + Data saver enabled. Background synchronization is restricted. + Manage data saver + Battery saver enabled. Synchronization may be restricted. + Manage battery saver + Storage space low. Android will not sync local changes immediately, but during the next regular sync. + Manage storage + Calendar provider missing + Did you disable the \"Calendar storage\" system app? + Contacts provider missing + Did you disable the \"Contacts storage\" system app? + Manage apps + + + + + Service detection failed + Couldn\'t refresh collection list + + + Running in foreground + On some devices, this is necessary for automatic synchronization. + + + Settings + Debugging + Show debug info + View/share configuration details and logs + Verbose logging + Logging is active. You can view the logs as part of the debug info. + Logging is disabled + Battery optimization + App is exempted (recommended) + Battery restrictions apply (not recommended) + Connection + Proxy type + + System default + No proxy + HTTP + SOCKS (for Orbot) + + + -1 + 0 + 1 + 2 + + Proxy host name + Proxy port + Security + App permissions + Review permissions required for synchronization + Distrust system certificates + System and user-added CAs won\'t be trusted + System and user-added CAs will be trusted (recommended) + If this setting is active, system certificates are not considered as trustworthy. This means that you will have to manually accept every certificate (also when the server renews its certificate) or account setup and sync will not work. + Reset (un)trusted certificates + Resets trust of all custom certificates + All custom certificates have been cleared + User interface + Notification settings + Manage notification channels and their settings + Select theme + + System default + Light + Dark + + + -1 + 1 + 2 + + Reset hints + Re-enables hints which have been dismissed previously + All hints will be shown again + Integration + Tasks app + No compatible tasks app found + UnifiedPush (experimental) + None (disable push) + Choose a distributor + No push distributor installed + No endpoint configured + Ready to receive push messages over %s + FCM (Google Play) + Push messages are always encrypted. + + + Account has been removed + CardDAV + CalDAV + Webcal + Additional permissions are required to synchronize these collections. + Manage permissions + Synchronize now + Account settings + Rename account + Unsaved local data may be dismissed. Re-synchronization is required after renaming. + New account name + Rename + Account name already taken + Couldn\'t rename account + Delete account + Really delete account? + All local copies of address books, calendars and task lists will be deleted. + synchronize this collection + read-only + calendar + contacts + journal + tasks + Show only personal + Refresh list + Webcal subscriptions can be synchronized with external apps. + No Webcal-capable app found + Install ICSx⁵ + + + Add account + privacy policy.]]> + Generic login + Provider-specific login + Continue + Login + Login with email address + Email address + Valid email address required + Services are discovered using DNS records and well-known URLs.]]> + Password + Hide password + Show password + Password (optional) + Login with URL and user name + User name + User name (optional) + Base URL + services are also discovered using DNS records and well-known URLs.]]> + Select certificate + Add account + Account name + Usage of apostrophes (\') seems to cause problems on some devices. + Use your email address as account name because Android will use the account name as ORGANIZER field for events you create. You can\'t have two accounts with the same name. + Contact group method: + Account name required + Account name already taken + Account could not be added + Finish + Advanced login + No client certificate (optional) + Client certificate: %s + No certificate found + Install certificate + Fastmail + Fastmail account + Sign in with Fastmail + Google Contacts / Calendar + Google account + Sign in with Google + Client ID (optional) + Privacy policy for details.]]> + Google API Services User Data Policy, including the Limited Use requirements.]]> + Couldn\'t obtain authorization code + Nextcloud + Login with Nextcloud + This will start the Nextcloud Login Flow in a Web browser. + Nextcloud server address + Sign in + Couldn\'t obtain login URL + Couldn\'t obtain login data + + Configuration detection + Please wait, querying server… + Couldn\'t find CalDAV or CardDAV service. + The base URL doesn\'t seem to be an accessible CalDAV/CardDAV URL and service detection was not successful. + our list of tested services and their base URLs.]]> + Please also double-check authentication (usually username and password). + Further technical information is available in the logs. + View logs + + + Synchronization + Contacts sync. interval + Only manually + Every %d minutes + immediately on local changes + Calendars sync. interval + Tasks sync. interval + + -1 + 900 + 1800 + 3600 + 7200 + 14400 + 86400 + + + Only manually + Every 15 minutes + Every 30 minutes + Every hour + Every 2 hours + Every 4 hours + Once a day + + Sync over WiFi only + Synchronization is restricted to WiFi connections + Connection type is not taken into consideration + WiFi SSID restriction + Will only sync over %s + All WiFi connections will be used + Comma-separated names (SSIDs) of allowed WiFi networks (leave blank for all) + WiFi SSID restriction requires further settings + Manage + VPN requires underlying Internet + VPN without underlying validated Internet connection is not enough to run synchronization (recommended) + VPN without underlying validated Internet connection is enough to run synchronization + Authentication + User name + Password or app password + app password.]]> + New password + Update the password according to your server. + Authorize again (OAuth) + Use when access has been revoked + Authorization successful + Client certificate + No certificate available or selected + Install certificate + CalDAV + Past event time limit + All events will be synchronized + + Events more than one day in the past will be ignored + Events more than %d days in the past will be ignored + + Events which are more than this number of days in the past will be ignored (may be 0). Leave blank to synchronize all events. + Default reminder + + Default reminder one minute before event + Default reminder %d minutes before event + + No default reminders are created + If default reminders shall be created for events without reminder: the desired number of minutes before the event. Leave blank to disable default reminders. + Manage calendar colors + Calendar colors are reset at each sync + Calendar colors can be set by other apps + Event color support + Event colors are synced + Event colors are not synced + CardDAV + Contact group method + + GROUP_VCARDS + CATEGORIES + + + Groups are separate vCards + Groups are per-contact categories + + + + Create address book + Address book creation over CardDAV may not be supported by the server. + Create calendar + Default time zone (optional) + + Possible calendar entries + Events + Tasks + Notes / journal + Calendar creation over CalDAV may not be supported by the server. + Color + Title + Storage location + Description (optional) + Create + + + contacts + events + tasks + Delete collection + This collection (%s) and all its data will be removed permanently, both locally and on the server. + Synchronization + Synchronization enabled + Synchronization disabled + Read-only + Read-only (by server) + Read-only (by policy) + Read-only (only locally) + Read/write + Title + Description + Owner + Push support + Server advertises Push support + Subscribed at %1$s, expires at %2$s + Last sync (%s) + Address (URL) + + + at.bitfire.davdroid.debug + Debug info + ZIP archive + Contains debug info and logs + Share the archive to transfer it to a computer, to send it by email or to attach it to a support ticket. + Share archive + Debug info attached to this message (requires attachment support of the receiving app). + HTTP Error + Server Error + WebDAV Error + I/O Error + The request has been denied by the server. + The requested resource doesn\'t exist (anymore). + The server doesn\'t allow the requested type of operation. + A server-side problem occurred. Please contact your server support. + An unexpected error has occurred. View debug info for details. + View details + Debug info have been collected + Involved resources + Related to the problem + Remote resource: + Local resource: + Logs + Verbose logs are available + View logs + Copy URL + Inspect resource + Privacy notice + Logs and debug info may contain private information. Please be aware of this when sharing publicly. + Unable to view resource + + + An error has occurred. + An HTTP error has occurred. + An I/O error has occurred. + Show details + + + at.bitfire.davdroid.webdav + WebDAV mounts + Quota used: %1$s / available: %2$s + Share content + Unmount + Add WebDAV mount + Directly access your cloud files by adding a WebDAV mount! + how WebDAV mounts work.]]> + Display name + WebDAV URL + Invalid URL + Mount point and display name + Authentication + User name + Password + User name (optional) + Password (optional) + Add mount + No WebDAV service at this URL + Remove mount point + Connection details will be lost, but no files will be deleted. + Accessing WebDAV file + Downloading WebDAV file + Uploading WebDAV file + WebDAV mount + + + DAVx⁵ permissions + Additional permissions required + %s too old + Minimum required version: %1$s + Authentication failed (check login credentials) + Network or I/O error – %s + HTTP server error – %s + Local storage error – %s + Soft error (max retries reached) + Received invalid contact from server + Received invalid event from server + Received invalid task from server + Ignoring one or more invalid resources + Sync pending + Remote data have changed + + + Sync all + Sync all accounts + Labeled Sync Button + Icon Sync Button + Tap to run synchronization manually. + + + + diff --git a/app/src/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml new file mode 100644 index 0000000..85eaa20 --- /dev/null +++ b/app/src/main/res/xml/account_authenticator.xml @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/xml/account_authenticator_address_book.xml b/app/src/main/res/xml/account_authenticator_address_book.xml new file mode 100644 index 0000000..6305f31 --- /dev/null +++ b/app/src/main/res/xml/account_authenticator_address_book.xml @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/xml/contacts.xml b/app/src/main/res/xml/contacts.xml new file mode 100644 index 0000000..b59d066 --- /dev/null +++ b/app/src/main/res/xml/contacts.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/debug_paths.xml b/app/src/main/res/xml/debug_paths.xml new file mode 100644 index 0000000..9fcb6e8 --- /dev/null +++ b/app/src/main/res/xml/debug_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..cf46331 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/xml/sync_calendars.xml b/app/src/main/res/xml/sync_calendars.xml new file mode 100644 index 0000000..e50cee6 --- /dev/null +++ b/app/src/main/res/xml/sync_calendars.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/xml/sync_contacts.xml b/app/src/main/res/xml/sync_contacts.xml new file mode 100644 index 0000000..18c4eea --- /dev/null +++ b/app/src/main/res/xml/sync_contacts.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/xml/sync_notes.xml b/app/src/main/res/xml/sync_notes.xml new file mode 100644 index 0000000..6fa047b --- /dev/null +++ b/app/src/main/res/xml/sync_notes.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/xml/sync_opentasks.xml b/app/src/main/res/xml/sync_opentasks.xml new file mode 100644 index 0000000..15d78ab --- /dev/null +++ b/app/src/main/res/xml/sync_opentasks.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/xml/sync_prefs.xml b/app/src/main/res/xml/sync_prefs.xml new file mode 100644 index 0000000..2e6e5eb --- /dev/null +++ b/app/src/main/res/xml/sync_prefs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/sync_tasks_org.xml b/app/src/main/res/xml/sync_tasks_org.xml new file mode 100644 index 0000000..088206c --- /dev/null +++ b/app/src/main/res/xml/sync_tasks_org.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/xml/widget_info_icon_sync_button.xml b/app/src/main/res/xml/widget_info_icon_sync_button.xml new file mode 100644 index 0000000..11235ea --- /dev/null +++ b/app/src/main/res/xml/widget_info_icon_sync_button.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/xml/widget_info_labeled_sync_button.xml b/app/src/main/res/xml/widget_info_labeled_sync_button.xml new file mode 100644 index 0000000..53cc169 --- /dev/null +++ b/app/src/main/res/xml/widget_info_labeled_sync_button.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/ose/AndroidManifest.xml b/app/src/ose/AndroidManifest.xml new file mode 100644 index 0000000..6c637e1 --- /dev/null +++ b/app/src/ose/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/DebugInfoCrashHandler.kt b/app/src/ose/kotlin/at/bitfire/davdroid/DebugInfoCrashHandler.kt new file mode 100644 index 0000000..27df8ca --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/DebugInfoCrashHandler.kt @@ -0,0 +1,50 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid + +import android.content.Context +import at.bitfire.davdroid.ui.DebugInfoActivity +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject + +class DebugInfoCrashHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val logger: Logger +): Thread.UncaughtExceptionHandler { + + @Module + @InstallIn(SingletonComponent::class) + interface DebugInfoCrashHandlerModule { + @Binds + fun debugInfoCrashHandler( + debugInfoCrashHandler: DebugInfoCrashHandler + ): Thread.UncaughtExceptionHandler + } + + // See https://developer.android.com/about/versions/oreo/android-8.0-changes#loue + val originalCrashHandler = Thread.getDefaultUncaughtExceptionHandler() + + + override fun uncaughtException(t: Thread, e: Throwable) { + logger.log(Level.SEVERE, "Unhandled exception in thread ${t.id}!", e) + + // start debug info activity with exception (will be started in a new process) + val intent = DebugInfoActivity.IntentBuilder(context) + .withCause(e) + .newTask() + .build() + context.startActivity(intent) + + // pass through to default handler to kill the process + originalCrashHandler?.uncaughtException(t, e) + } + +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/di/OseFlavorModule.kt b/app/src/ose/kotlin/at/bitfire/davdroid/di/OseFlavorModule.kt new file mode 100644 index 0000000..6db4362 --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/di/OseFlavorModule.kt @@ -0,0 +1,52 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.di + +import at.bitfire.davdroid.ui.intro.OseIntroPageFactory + +import at.bitfire.davdroid.ui.AboutActivity +import at.bitfire.davdroid.ui.AccountsDrawerHandler +import at.bitfire.davdroid.ui.OpenSourceLicenseInfoProvider +import at.bitfire.davdroid.ui.OseAccountsDrawerHandler +import at.bitfire.davdroid.ui.intro.IntroPageFactory +import at.bitfire.davdroid.ui.setup.LoginTypesProvider +import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.components.SingletonComponent + +interface OseModules { + + @Module + @InstallIn(ActivityComponent::class) + interface ForActivities { + @Binds + fun accountsDrawerHandler(impl: OseAccountsDrawerHandler): AccountsDrawerHandler + + @Binds + fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider + } + + @Module + @InstallIn(ViewModelComponent::class) + interface ForViewModels { + @Binds + fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider + + @Binds + fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider + } + + @Module + @InstallIn(SingletonComponent::class) + interface Global { + @Binds + fun introPageFactory(impl: OseIntroPageFactory): IntroPageFactory + } + +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt new file mode 100644 index 0000000..a076d63 --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/OpenSourceLicenseInfoProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import android.app.Application +import android.text.Spanned +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.text.HtmlCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import com.google.common.io.CharStreams +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLicenseInfoProvider { + + @Composable + override fun LicenseInfo() { + LicenseInfoGpl() + } + + @Composable + fun LicenseInfoGpl( + model: Model = viewModel() + ) { + model.gpl?.let { OpenSourceLicenseInfo(it.toAnnotatedString()) } + } + + + @HiltViewModel + class Model @Inject constructor(app: Application): AndroidViewModel(app) { + + var gpl by mutableStateOf(null) + + init { + viewModelScope.launch(Dispatchers.IO) { + app.resources.assets.open("gplv3.html").use { inputStream -> + val raw = CharStreams.toString(inputStream.bufferedReader()) + gpl = HtmlCompat.fromHtml(raw, HtmlCompat.FROM_HTML_MODE_LEGACY) + } + } + } + + } + +} + + +@Composable +fun OpenSourceLicenseInfo(license: AnnotatedString) { + Text(text = license) +} + +@Composable +@Preview +fun OpenSourceLicenseInfo_Preview() { + OpenSourceLicenseInfo(AnnotatedString("It's open-source.")) +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/ThemeColors.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/ThemeColors.kt new file mode 100644 index 0000000..9e6d250 --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/ThemeColors.kt @@ -0,0 +1,167 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +@Suppress("MemberVisibilityCanBePrivate") +object M3ColorScheme { + + // All colors hand-crafted because Material Theme Builder generates unbelievably ugly colors + + val primaryLight = Color(0xFF7cb342) + val onPrimaryLight = Color(0xFFffffff) + val primaryContainerLight = Color(0xFFb4e47d) + val onPrimaryContainerLight = Color(0xFF232d18) + val secondaryLight = Color(0xFFff7f2a) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFffa565) + val onSecondaryContainerLight = Color(0xFF3a271b) + val tertiaryLight = Color(0xFF658a24) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFb0d08e) + val onTertiaryContainerLight = Color(0xFF263015) + val errorLight = Color(0xFFd71717) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFefb6b6) + val onErrorContainerLight = Color(0xFF3a0b0b) + val backgroundLight = Color(0xFFfcfcfc) + val onBackgroundLight = Color(0xFF2a2a2a) + val surfaceLight = Color(0xFFf5f5f5) + val onSurfaceLight = Color(0xFF4d4d4d) + val surfaceVariantLight = Color(0xFFe4e4e4) + val onSurfaceVariantLight = Color(0xFF2a2a2a) + val outlineLight = Color(0xFF838383) + val outlineVariantLight = Color(0xFFd4d4d4) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF2e322b) + val inverseOnSurfaceLight = Color(0xFFfafaf8) + val inversePrimaryLight = Color(0xFFb4e47d) + val surfaceDimLight = Color(0xFFe3e3e3) + val surfaceBrightLight = Color(0xFFf9f9f9) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFfafafa) + val surfaceContainerLight = Color(0xFFf5f5f5) + val surfaceContainerHighLight = Color(0xFFf0f0ef) + val surfaceContainerHighestLight = Color(0xFFebebea) + + val primaryDark = Color(0xFFc4e3a4) + val onPrimaryDark = Color(0xFF2b4310) + val primaryContainerDark = Color(0xFF7cb342) + val onPrimaryContainerDark = Color(0xFFedf5e4) + val secondaryDark = Color(0xFFe5c3ac) + val onSecondaryDark = Color(0xFF3e332e) + val secondaryContainerDark = Color(0xFFff7f2a) + val onSecondaryContainerDark = Color(0xFFffeadb) + val tertiaryDark = Color(0xFFc6e597) + val onTertiaryDark = Color(0xFF4b661b) + val tertiaryContainerDark = Color(0xFF658a24) + val onTertiaryContainerDark = Color(0xFFf0f8e2) + val errorDark = Color(0xFFf6d0d0) + val onErrorDark = Color(0xFF4f1212) + val errorContainerDark = Color(0xFFe93434) + val onErrorContainerDark = Color(0xFFfcdede) + val backgroundDark = Color(0xFF1a1a1a) + val onBackgroundDark = Color(0xFFf0f0f0) + val surfaceDark = Color(0xFF292929) + val onSurfaceDark = Color(0xFFdedede) + val surfaceVariantDark = Color(0xFF363636) + val onSurfaceVariantDark = Color(0xFFededed) + val outlineDark = Color(0xFFa3a3a3) + val outlineVariantDark = Color(0xFF7cb342) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFdbdbdb) + val inverseOnSurfaceDark = Color(0xFF292929) + val inversePrimaryDark = Color(0xFF7cb342) + val surfaceDimDark = Color(0xFF333333) + val surfaceBrightDark = Color(0xFF4d4d4d) + val surfaceContainerLowestDark = Color(0xFF141414) + val surfaceContainerLowDark = Color(0xFF1f1f1f) + val surfaceContainerDark = Color(0xff3a3a3a) + val surfaceContainerHighDark = Color(0xFF383838) + val surfaceContainerHighestDark = Color(0xFF434343) + + + // Copied from Material Theme Builder: Theme.kt + + val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, + ) + + val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, + ) + +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/intro/OseIntroPageFactory.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/intro/OseIntroPageFactory.kt new file mode 100644 index 0000000..fd78cc2 --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/intro/OseIntroPageFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.intro + +import javax.inject.Inject + +class OseIntroPageFactory @Inject constructor( + batteryOptimizationsPage: BatteryOptimizationsPage, + openSourcePage: OpenSourcePage, + permissionsIntroPage: PermissionsIntroPage, + tasksIntroPage: TasksIntroPage +): IntroPageFactory { + + override val introPages = arrayOf( + WelcomePage(), + tasksIntroPage, + permissionsIntroPage, + batteryOptimizationsPage, + openSourcePage + ) + +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypePage.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypePage.kt new file mode 100644 index 0000000..19e8b74 --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypePage.kt @@ -0,0 +1,121 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.ExternalUris +import at.bitfire.davdroid.ui.ExternalUris.withStatParams +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.composable.Assistant + +@Composable +fun StandardLoginTypePage( + selectedLoginType: LoginType, + onSelectLoginType: (LoginType) -> Unit, + + @Suppress("unused") // for build variants + setInitialLoginInfo: (LoginInfo) -> Unit, + + onContinue: () -> Unit = {} +) { + Assistant( + nextLabel = stringResource(R.string.login_continue), + nextEnabled = true, + onNext = onContinue + ) { + Column(Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_generic_login), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(vertical = 8.dp) + ) + for (type in StandardLoginTypesProvider.genericLoginTypes) + LoginTypeSelector( + title = stringResource(type.title), + selected = type == selectedLoginType, + onSelect = { onSelectLoginType(type) } + ) + + Text( + stringResource(R.string.login_provider_login), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + for (type in StandardLoginTypesProvider.specificLoginTypes) + LoginTypeSelector( + title = stringResource(type.title), + selected = type == selectedLoginType, + onSelect = { onSelectLoginType(type) } + ) + + HorizontalDivider(Modifier.padding(vertical = 12.dp)) + + val privacyPolicy = ExternalUris.Homepage.baseUrl.buildUpon() + .appendPath(ExternalUris.Homepage.PATH_PRIVACY) + .withStatParams("StandardLoginTypePage") + .build().toString() + val privacy = HtmlCompat.fromHtml( + stringResource(R.string.login_privacy_hint, stringResource(R.string.app_name), privacyPolicy), + HtmlCompat.FROM_HTML_MODE_COMPACT) + Text( + text = privacy.toAnnotatedString(), + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +fun LoginTypeSelector( + title: String, + selected: Boolean, + onSelect: () -> Unit = {} +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = onSelect) + .padding(bottom = 4.dp) + ) { + RadioButton( + selected = selected, + onClick = onSelect + ) + Text( + title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + } + } +} + + +@Composable +@Preview +fun StandardLoginTypePage_Preview() { + StandardLoginTypePage( + selectedLoginType = StandardLoginTypesProvider.genericLoginTypes.first(), + onSelectLoginType = {}, + setInitialLoginInfo = {}, + onContinue = {} + ) +} \ No newline at end of file diff --git a/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt new file mode 100644 index 0000000..1642a61 --- /dev/null +++ b/app/src/ose/kotlin/at/bitfire/davdroid/ui/setup/StandardLoginTypesProvider.kt @@ -0,0 +1,66 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import at.bitfire.davdroid.ui.setup.LoginTypesProvider.LoginAction +import java.util.logging.Logger +import javax.inject.Inject + +class StandardLoginTypesProvider @Inject constructor( + private val logger: Logger +) : LoginTypesProvider { + + companion object { + val genericLoginTypes = listOf( + UrlLogin, + EmailLogin, + AdvancedLogin + ) + + val specificLoginTypes = listOf( + FastmailLogin, + GoogleLogin, + NextcloudLogin + ) + } + + override val defaultLoginType = UrlLogin + + override fun intentToInitialLoginType(intent: Intent): LoginAction = + intent.data?.normalizeScheme().let { uri -> + when { + intent.hasExtra(LoginActivity.EXTRA_LOGIN_FLOW) -> + LoginAction(NextcloudLogin, true) + uri?.scheme == "mailto" -> + LoginAction(EmailLogin, true) + listOf("caldavs", "carddavs", "davx5", "http", "https").any { uri?.scheme == it } -> + LoginAction(UrlLogin, true) + else -> { + logger.warning("Did not understand login intent: $intent") + LoginAction(defaultLoginType, false) // Don't skip login type page if intent is unclear + } + } + } + + @Composable + override fun LoginTypePage( + snackbarHostState: SnackbarHostState, + selectedLoginType: LoginType, + onSelectLoginType: (LoginType) -> Unit, + setInitialLoginInfo: (LoginInfo) -> Unit, + onContinue: () -> Unit + ) { + StandardLoginTypePage( + selectedLoginType = selectedLoginType, + onSelectLoginType = onSelectLoginType, + setInitialLoginInfo = setInitialLoginInfo, + onContinue = onContinue + ) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt new file mode 100644 index 0000000..0b96e84 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid + +import at.bitfire.davdroid.util.DavUtils +import at.bitfire.davdroid.util.DavUtils.lastSegment +import at.bitfire.davdroid.util.DavUtils.parent +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Test + +class DavUtilsTest { + + @Test + fun testAcceptAnything() { + assertEquals("*/*", DavUtils.acceptAnything(null)) + assertEquals("some/thing;v=2.1, */*;q=0.8", DavUtils.acceptAnything("some/thing;v=2.1".toMediaType())) + } + + @Test + fun testARGBtoCalDAVColor() { + assertEquals("#00000000", DavUtils.ARGBtoCalDAVColor(0)) + assertEquals("#123456FF", DavUtils.ARGBtoCalDAVColor(0xFF123456.toInt())) + assertEquals("#000000FF", DavUtils.ARGBtoCalDAVColor(0xFF000000.toInt())) + } + + + @Test + fun testHttpUrl_LastSegment() { + val exampleURL = "http://example.com/" + Assert.assertEquals("/", exampleURL.toHttpUrl().lastSegment) + Assert.assertEquals("dir", (exampleURL + "dir").toHttpUrl().lastSegment) + Assert.assertEquals("dir", (exampleURL + "dir/").toHttpUrl().lastSegment) + Assert.assertEquals("file.html", (exampleURL + "dir/file.html").toHttpUrl().lastSegment) + } + + @Test + fun testHttpUrl_Parent() { + // with trailing slash + assertEquals("http://example.com/1/2/".toHttpUrl(), "http://example.com/1/2/3/".toHttpUrl().parent()) + assertEquals("http://example.com/1/".toHttpUrl(), "http://example.com/1/2/".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com/1/".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com/".toHttpUrl().parent()) + + // without trailing slash + assertEquals("http://example.com/1/2/".toHttpUrl(), "http://example.com/1/2/3".toHttpUrl().parent()) + assertEquals("http://example.com/1/".toHttpUrl(), "http://example.com/1/2".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com/1".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com".toHttpUrl().parent()) + } + +} diff --git a/app/src/test/kotlin/at/bitfire/davdroid/log/StringHandlerTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/log/StringHandlerTest.kt new file mode 100644 index 0000000..09150fc --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/log/StringHandlerTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.log + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.System.lineSeparator +import java.util.logging.Formatter +import java.util.logging.Level +import java.util.logging.LogRecord + +class StringHandlerTest { + + @Test + fun test_logSomeText() { + val handler = StringHandler(1000) + handler.publish(LogRecord(Level.INFO, "Line 1")) + handler.publish(LogRecord(Level.FINEST, "Line 2")) + val str = handler.toString() + assertTrue(str.contains("Line 1${lineSeparator()}")) + assertTrue(str.contains("Line 2${lineSeparator()}")) + } + + @Test + fun test_logSomeText_ExceedingMaxSize() { + val handler = StringHandler(10).apply { + formatter = object: Formatter() { + override fun format(record: LogRecord) = record.message + } + } + handler.publish(LogRecord(Level.INFO, "Line 1 Line 1 Line 1 Line 1 Line 1")) + handler.publish(LogRecord(Level.FINEST, "Line 2")) + + val str = handler.toString() + assertEquals(10, handler.toString().length) + assertEquals("Line [...]", str) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/network/MemoryCookieStoreTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/network/MemoryCookieStoreTest.kt new file mode 100644 index 0000000..4a8e130 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/network/MemoryCookieStoreTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import okhttp3.Cookie +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test + +class MemoryCookieStoreTest { + + lateinit var store: MemoryCookieStore + + @Before + fun setup() { + store = MemoryCookieStore() + } + + + @Test + fun testSaveFromResponse_AndRead() { + val url = "https://example.com/path".toHttpUrl() + val cookie = Cookie.Builder() + .name("cookie1") + .value("value1") + .domain("example.com") + .path("/path") + .build() + store.saveFromResponse(url, listOf(cookie)) + assertArrayEquals( + arrayOf(cookie), + store.loadForRequest(url).toTypedArray() + ) + } + + @Test + fun testSaveFromResponse_Overwrite_AndRead() { + val url = "https://example.com/path".toHttpUrl() + store.saveFromResponse(url, listOf( + Cookie.Builder() + .name("cookie1") + .value("first value") + .domain("example.com") + .path("/path") + .build() + )) + + val updatedCookie = Cookie.Builder() + .name("cookie1") + .value("updated value") + .domain("example.com") + .path("/path") + .build() + store.saveFromResponse(url, listOf(updatedCookie)) + assertArrayEquals( + arrayOf(updatedCookie), + store.loadForRequest(url).toTypedArray() + ) + } + + + @Test + fun testLoadForRequest_SubPath() { + val url = "https://example.com/path".toHttpUrl() + val cookie = Cookie.Builder() + .name("cookie1") + .value("value1") + .domain("example.com") + .path("/path") + .build() + store.saveFromResponse(url, listOf(cookie)) + + assertArrayEquals( + arrayOf(cookie), + store.loadForRequest(url.newBuilder() + .addPathSegment("sub-path") + .build()).toTypedArray() + ) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt new file mode 100644 index 0000000..8f7bff0 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import at.bitfire.davdroid.resource.LocalResource +import io.mockk.mockk +import okhttp3.HttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncExceptionTest { + + @Test + fun testWrapWithLocalResource_LocalResource_Exception() { + val outer = mockk>() + val inner = mockk>() + val e = Exception() + + val result = assertSyncException { + SyncException.wrapWithLocalResource(outer) { + SyncException.wrapWithLocalResource(inner) { + throw e + } + } + } + + assertEquals(inner, result.localResource) + assertEquals(e, result.cause) + } + + @Test + fun testWrapWithLocalResource_LocalResource_SyncException() { + val outer = mockk>() + val inner = mockk>() + val e = SyncException(Exception()) + + val result = assertSyncException { + SyncException.wrapWithLocalResource(outer) { + SyncException.wrapWithLocalResource(inner) { + throw e + } + } + } + + assertEquals(inner, result.localResource) + assertEquals(e, result) + } + + @Test + fun testWrapWithLocalResource_RemoteResource_Exception() { + val local = mockk>() + val remote = mockk() + val e = Exception() + + val result = assertSyncException { + SyncException.wrapWithLocalResource(local) { + SyncException.wrapWithRemoteResource(remote) { + throw e + } + } + } + + assertEquals(local, result.localResource) + assertEquals(remote, result.remoteResource) + assertEquals(e, result.cause) + } + + @Test + fun testWrapWithLocalResource_RemoteResource_SyncException() { + val local = mockk>() + val remote = mockk() + val e = SyncException(Exception()) + + val result = assertSyncException { + SyncException.wrapWithLocalResource(local) { + SyncException.wrapWithRemoteResource(remote) { + throw e + } + } + } + + assertEquals(local, result.localResource) + assertEquals(remote, result.remoteResource) + assertEquals(e, result) + } + + + @Test + fun testWrapWithRemoteResource_LocalResource_Exception() { + val remote = mockk() + val local = mockk>() + val e = Exception() + + val result = assertSyncException { + SyncException.wrapWithRemoteResource(remote) { + SyncException.wrapWithLocalResource(local) { + throw e + } + } + } + + assertEquals(local, result.localResource) + assertEquals(remote, result.remoteResource) + assertEquals(e, result.cause) + } + + @Test + fun testWrapWithRemoteResource_LocalResource_SyncException() { + val remote = mockk() + val local = mockk>() + val e = SyncException(Exception()) + + val result = assertSyncException { + SyncException.wrapWithRemoteResource(remote) { + SyncException.wrapWithLocalResource(local) { + throw e + } + } + } + + assertEquals(local, result.localResource) + assertEquals(remote, result.remoteResource) + assertEquals(e, result) + } + + @Test + fun testWrapWithRemoteResource_RemoteResource_Exception() { + val outer = mockk() + val inner = mockk() + val e = Exception() + + val result = assertSyncException { + SyncException.wrapWithRemoteResource(outer) { + SyncException.wrapWithRemoteResource(inner) { + throw e + } + } + } + + assertEquals(inner, result.remoteResource) + assertEquals(e, result.cause) + } + + @Test + fun testWrapWithRemoteResource_RemoteResource_SyncException() { + val outer = mockk() + val inner = mockk() + val e = SyncException(Exception()) + + val result = assertSyncException { + SyncException.wrapWithRemoteResource(outer) { + SyncException.wrapWithRemoteResource(inner) { + throw e + } + } + } + + assertEquals(inner, result.remoteResource) + assertEquals(e, result) + } + + + @Test + fun testUnwrap_Exception() { + val e = Exception() + + var contextProvided = false + val unwrapped = SyncException.unwrap(e) { + contextProvided = true + } + assertEquals(e, unwrapped) + assertFalse(contextProvided) + } + + @Test + fun testUnwrap_SyncException() { + val e = Exception() + val wrapped = SyncException(e) + + var contextProvided = false + val unwrapped = SyncException.unwrap(wrapped) { + assertEquals(wrapped, it) + contextProvided = true + } + assertEquals(e, unwrapped) + assertTrue(contextProvided) + } + + + // helpers + + fun assertSyncException(block: () -> Unit): SyncException { + try { + block() + } catch(ex: Throwable) { + if (ex is SyncException) + return ex + } + throw AssertionError("Expected SyncException") + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/util/SensitiveStringTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/util/SensitiveStringTest.kt new file mode 100644 index 0000000..7ce8259 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/util/SensitiveStringTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SensitiveStringTest { + + private data class UsernameAndPassword( + val username: String, + val password: SensitiveString + ) + + @Test + fun `equals (other object)`() { + val password = "some-password".toSensitiveString() + assertFalse(password == Any()) + } + + @Test + fun `equals (other password)`() { + val password = "some-password".toSensitiveString() + assertFalse(password == "other-password".toSensitiveString()) + } + + @Test + fun `equals (same password)`() { + val password = "some-password".toSensitiveString() + assertTrue(password == "some-password".toSensitiveString()) + } + + @Test + fun `toString in data class`() { + val credentials = UsernameAndPassword( + "some-user", + "some-password".toSensitiveString() + ) + + val logMessage = "Credentials: $credentials" + assertEquals("Credentials: UsernameAndPassword(username=some-user, password=*****)", logMessage) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/util/StringUtilsTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/util/StringUtilsTest.kt new file mode 100644 index 0000000..525da43 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/util/StringUtilsTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StringUtilsTest { + + @Test + fun trimToNull_Empty() { + assertNull("".trimToNull()) + } + + @Test + fun trimToNull_NoWhitespace() { + assertEquals("test", "test".trimToNull()) + } + + @Test + fun trimToNull_Null() { + assertNull(null.trimToNull()) + } + + @Test + fun trimToNull_PaddedWithWhitespace() { + assertEquals("test", "\r\n test ".trimToNull()) + } + + + @Test + fun withTrailingSlash_WithSlash() { + assertEquals("test/", "test/".withTrailingSlash()) + } + + @Test + fun withTrailingSlash_WithoutSlash() { + assertEquals("test/", "test".withTrailingSlash()) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/webdav/DiskCacheTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/webdav/DiskCacheTest.kt new file mode 100644 index 0000000..1d05e7b --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/webdav/DiskCacheTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.os.FileUtils +import at.bitfire.davdroid.webdav.cache.DiskCache +import com.google.common.io.ByteStreams +import com.google.common.io.Files +import ezvcard.util.IOUtils +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DiskCacheTest { + + companion object { + const val SOME_KEY = "key1" + const val SOME_VALUE_LENGTH = 15 + val SOME_VALUE = ByteArray(SOME_VALUE_LENGTH) { it.toByte() } + val SOME_OTHER_VALUE = ByteArray(30) { (it/2).toByte() } + + const val MAX_CACHE_MB = 10 + const val MAX_CACHE_SIZE = MAX_CACHE_MB * 1024*1024L + } + + @Rule + @JvmField + val tempDir = TemporaryFolder() + + lateinit var cache: DiskCache + + + @Before + fun createCache() { + cache = DiskCache(tempDir.newFolder(), MAX_CACHE_SIZE) + } + + @After + fun deleteCache() { + assertTrue(cache.cacheDir.deleteRecursively()) + } + + + @Test + fun testGetFile_Null() { + assertNull(cache.getFileOrPut(SOME_KEY) { null }) + + // null value shouldn't have been written to cache + assertEquals(0, cache.entries()) + cache.getFileOrPut(SOME_KEY) { SOME_VALUE }!!.let { + assertArrayEquals(SOME_VALUE, Files.asByteSource(it).read()) + } + } + + @Test + fun testGetFile_NotNull() { + cache.getFileOrPut(SOME_KEY) { SOME_VALUE }!!.let { + assertArrayEquals(SOME_VALUE, Files.asByteSource(it).read()) + } + + // non-null value should have been written to cache + assertEquals(1, cache.entries()) + cache.getFileOrPut(SOME_KEY) { SOME_OTHER_VALUE }!!.let { + assertArrayEquals(SOME_VALUE, Files.asByteSource(it).read()) + } + } + + + @Test + fun testClear() { + for (i in 1..50) { + cache.getFileOrPut(i.toString()) { i.toString().toByteArray() } + } + assertEquals(50, cache.entries()) + + cache.clear() + assertEquals(0, cache.entries()) + } + + + @Test + fun testTrim() { + assertEquals(0, cache.entries()) + + cache.getFileOrPut(SOME_KEY) { SOME_VALUE } + assertEquals(1, cache.entries()) + + cache.trim() + assertEquals(1, cache.entries()) + + // add 11 x 1 MB + for (i in 0..MAX_CACHE_MB) { + cache.getFileOrPut(i.toString()) { ByteArray(1024*1024) } + Thread.sleep(5) // make sure that files are exactly sortable by modification date + } + // now in cache: SOME_KEY (some bytes) and "0" .. "10" (1 MB each), i.e. 11 MB + some bytes in total + assertEquals(MAX_CACHE_MB+2, cache.entries()) + + // trim() should remove the oldest entries (SOME_KEY and "0") to trim to 10 MB + assertEquals(2, cache.trim()) + + // now in cache: "1" .. "10" = 10 MB + assertEquals((1..MAX_CACHE_MB).map { it.toString() }.toSet(), cache.keys().toSet()) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/webdav/DocumentSortByMapperTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/webdav/DocumentSortByMapperTest.kt new file mode 100644 index 0000000..7bbccf2 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/webdav/DocumentSortByMapperTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.provider.DocumentsContract.Document +import junit.framework.TestCase.assertEquals +import org.junit.Test +import java.util.logging.Logger + +class DocumentSortByMapperTest { + + private val mapper = DocumentSortByMapper(Logger.getGlobal()) + + @Test + fun test_MapContentProviderToSql() { + // Valid column without direction + assertEquals( + "displayName ASC", + mapper.mapContentProviderToSql(Document.COLUMN_DISPLAY_NAME) + ) + // Valid column with direction + assertEquals( + "displayName DESC", + mapper.mapContentProviderToSql("${Document.COLUMN_DISPLAY_NAME} DESC") + ) + // Valid column with direction and multiple spaces + assertEquals( + "displayName ASC", + mapper.mapContentProviderToSql("${Document.COLUMN_DISPLAY_NAME} ASC") + ) + // Invalid column without direction + assertEquals( + "displayName ASC", + mapper.mapContentProviderToSql("invalid") + ) + // Invalid column with direction + assertEquals( + "displayName ASC", + mapper.mapContentProviderToSql("invalid ASC") + ) + // Valid and invalid columns with and without directions + assertEquals( + "displayName ASC, mimeType DESC", + mapper.mapContentProviderToSql( + "${Document.COLUMN_DISPLAY_NAME}, invalid DESC, ${Document.COLUMN_MIME_TYPE} DESC" + ) + ) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/webdav/PagingReaderTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/webdav/PagingReaderTest.kt new file mode 100644 index 0000000..3a300a6 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/webdav/PagingReaderTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.google.common.cache.LoadingCache +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + In the context of these tests, a "small file" is a file that is smaller + than the max page size. A "page-sized file" is a file that is exactly as + large as the page size. A "large file" is a file that is larger than the page + size, i.e. a file that comprises at least two pages. + */ +class PagingReaderTest { + + @Test + fun testRead_AcrossThreePages() { + var idx = 0 + val reader = pagingReader(350, 100) { offset, size -> + assertEquals(idx * 100L, offset) + assertEquals( + when (idx) { + 0, 1, 2 -> 100 + 3 -> 50 + else -> throw AssertionError("idx=$idx, size=$size") + }, + size + ) + idx += 1 + ByteArray(size) { idx.toByte() } + } + val dst = ByteArray(103) + assertEquals(103, reader.read(99, 103, dst)) + assertArrayEquals( + ByteArray(1) { 1 } + ByteArray(100) { 2 } + ByteArray(2) { 3 }, + dst + ) + } + + @Test + fun testRead_AtBeginning_FewBytes() { + val reader = pagingReader(200, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(10) + assertEquals(10, reader.read(0, 10, dst)) + assertArrayEquals(ByteArray(10) { 1 }, dst) + } + + @Test + fun testRead_AtEOF() { + val reader = pagingReader(200, 100) { _, _ -> + throw AssertionError("Must not be called with size=0") + } + assertEquals(0, reader.read(200, 10, ByteArray(10))) + } + + + @Test + fun testReadPage_LargeFile_FromMid_ToMid() { + val reader = pagingReader(200, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(10) + assertEquals(10, reader.readPage(50, 10, dst, 0)) + assertArrayEquals(ByteArray(10) { 1 }, dst) + } + + @Test + fun testReadPage_LargeFile_FromMid_BeyondPage() { + val reader = pagingReader(200, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(100) + assertEquals(50, reader.readPage(50, 100, dst, 0)) + assertArrayEquals(ByteArray(50) { 1 }, dst.copyOfRange(0, 50)) + } + + @Test + fun testReadPage_LargeFile_FromStart_LessThanAPage() { + val reader = pagingReader(200, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(10) + assertEquals(10, reader.readPage(0, 10, dst, 0)) + assertArrayEquals(ByteArray(10) { 1 }, dst) + } + + @Test + fun testReadPage_LargeFile_FromStart_OnePage() { + val reader = pagingReader(200, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(100) + assertEquals(100, reader.readPage(0, 100, dst, 0)) + assertArrayEquals(ByteArray(100) { 1 }, dst) + } + + @Test + fun testReadPage_LargeFile_FromStart_MoreThanAvailable() { + val reader = pagingReader(200, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(200) + assertEquals(100, reader.readPage(0, 200, dst, 100)) + assertArrayEquals(ByteArray(100) { 1 }, dst.copyOfRange(100, 200)) + } + + + @Test + fun testReadPage_PageSizedFile_FromEnd() { + val reader = pagingReader(100, 100) { _, _ -> + throw AssertionError() + } + val dst = ByteArray(100) + assertEquals(0, reader.readPage(100, 100, dst, 0)) + assertArrayEquals(ByteArray(100), dst) + } + + @Test + fun testReadPage_PageSizedFile_FromStart_Complete() { + val reader = pagingReader(100, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(100) + assertEquals(100, reader.readPage(0, 100, dst, 0)) + assertArrayEquals(ByteArray(100) { 1 }, dst) + } + + @Test + fun testReadPage_PageSizedFile_FromStart_MoreThanAvailable() { + val reader = pagingReader(100, 100) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(200) + assertEquals(100, reader.readPage(0, 200, dst, 100)) + assertArrayEquals(ByteArray(100) { 1 }, dst.copyOfRange(100, 200)) + } + + + @Test + fun testReadPage_SmallFile_FromStart_Partial() { + val reader = pagingReader(100, 1000) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(10) + assertEquals(10, reader.readPage(0, 10, dst, 0)) + assertArrayEquals(dst, ByteArray(10) { 1 }) + } + + @Test + fun testReadPage_SmallFile_FromStart_Complete() { + val reader = pagingReader(100, 1000) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(100) + assertEquals(100, reader.readPage(0, 100, dst, 0)) + assertArrayEquals(ByteArray(100) { 1 }, dst) + } + + @Test + fun testReadPage_SmallFile_FromStart_MoreThanAvailable() { + val reader = pagingReader(100, 1000) { offset, size -> + assertEquals(0, offset) + assertEquals(100, size) + ByteArray(100) { 1 } + } + val dst = ByteArray(200) + assertEquals(100, reader.readPage(0, 200, dst, 100)) + assertArrayEquals(ByteArray(100) { 1 }, dst.copyOfRange(100, 200)) + } + + + private fun pageCache(loader: (offset: Long, size: Int) -> ByteArray): LoadingCache = + CacheBuilder.newBuilder() + .build(object: CacheLoader() { + override fun load(key: RandomAccessCallback.PageIdentifier) = loader(key.offset, key.size) + }) + + private fun pagingReader(fileSize: Long, pageSize: Int, loader: (offset: Long, size: Int) -> ByteArray) = + PagingReader(fileSize, pageSize, pageCache(loader)) + +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e3102af --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.ksp) apply false + + alias(libs.plugins.mikepenz.aboutLibraries.android) apply false +} \ No newline at end of file diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 0000000..ba62ebb --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1 @@ +javadoc/ diff --git a/doc/DAVdroid-Linuxwochen-2016.odp b/doc/DAVdroid-Linuxwochen-2016.odp new file mode 100644 index 0000000..129f030 Binary files /dev/null and b/doc/DAVdroid-Linuxwochen-2016.odp differ diff --git a/doc/NIST.SP.800-52r1.pdf b/doc/NIST.SP.800-52r1.pdf new file mode 100644 index 0000000..0436449 Binary files /dev/null and b/doc/NIST.SP.800-52r1.pdf differ diff --git a/doc/caldav-proxy.txt b/doc/caldav-proxy.txt new file mode 100644 index 0000000..2d96bfc --- /dev/null +++ b/doc/caldav-proxy.txt @@ -0,0 +1,560 @@ + + + +Calendar Server Extension C. Daboo + Apple Computer + May 3, 2007 + + + Calendar User Proxy Functionality in CalDAV + caldav-cu-proxy-02 + +Abstract + + This specification defines an extension to CalDAV that makes it easy + for clients to setup and manage calendar user proxies, using the + WebDAV Access Control List extension as a basis. + + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2 + 2. Conventions Used in This Document . . . . . . . . . . . . . . 2 + 3. Overview . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 + 3.1. Server . . . . . . . . . . . . . . . . . . . . . . . . . . 3 + 3.2. Client . . . . . . . . . . . . . . . . . . . . . . . . . . 3 + 4. Open Issues . . . . . . . . . . . . . . . . . . . . . . . . . 4 + 5. New features in CalDAV . . . . . . . . . . . . . . . . . . . . 4 + 5.1. Proxy Principal Resource . . . . . . . . . . . . . . . . . 4 + 5.2. Privilege Provisioning . . . . . . . . . . . . . . . . . . 8 + 6. Security Considerations . . . . . . . . . . . . . . . . . . . 9 + 7. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 9 + 8. Normative References . . . . . . . . . . . . . . . . . . . . . 9 + Appendix A. Acknowledgments . . . . . . . . . . . . . . . . . . . 9 + Appendix B. Change History . . . . . . . . . . . . . . . . . . . 10 + Author's Address . . . . . . . . . . . . . . . . . . . . . . . . . 10 + + + + + + + + + + + + + + + + + + + +Daboo [Page 1] + + CalDAV Proxy May 2007 + + +1. Introduction + + CalDAV [RFC4791] provides a way for calendar users to store calendar + data and exchange this data via scheduling operations. Based on the + WebDAV protocol [RFC2518], it also includes the ability to manage + access to calendar data via the WebDAV ACL extension [RFC3744]. + + It is often common for a calendar user to delegate some form of + responsibility for their calendar and schedules to another calendar + user (e.g., a boss allows an assistant to check a calendar or to send + and accept scheduling invites on his behalf). The user handling the + calendar data on behalf of someone else is often referred to as a + "calendar user proxy". + + Whilst CalDAV does have fine-grained access control features that can + be used to setup complex sharing and management of calendars, often + the proxy behavior required is an "all-or-nothing" approach - i.e. + the proxy has access to all the calendars or to no calendars (in + which case they are of course not a proxy). So a simple way to + manage access to an entire set of calendars and scheduling ability + would be handy. + + In addition, calendar user agents will often want to display to a + user who has proxy access to their calendars, or to whom they are + acting as a proxy. Again, CalDAV's access control discovery and + report features can be used to do that, but with fine-grained control + that exists, it can be hard to tell who is a "real" proxy as opposed + to someone just granted rights to some subset of calendars. Again, a + simple way to discover proxy information would be handy. + + +2. Conventions Used in This Document + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + When XML element types in the namespace "DAV:" are referenced in this + document outside of the context of an XML fragment, the string "DAV:" + will be prefixed to the element type names. + + When XML element types in the namespaces "DAV:" and + "urn:ietf:params:xml:ns:caldav" are referenced in this document + outside of the context of an XML fragment, the string "DAV:" and + "CALDAV:" will be prefixed to the element type names respectively. + + The namespace "http://calendarserver.org/ns/" is used for XML + elements defined in this specification. When XML element types in + + + +Daboo [Page 2] + + CalDAV Proxy May 2007 + + + this namespace are referenced in this document outside of the context + of an XML fragment, the string "CS:" will be prefixed to the element + type names respectively. + + +3. Overview + +3.1. Server + + For each calendar user principal on the server, the server will + generate two group principals - "proxy groups". One is used to hold + the list of principals who have read-only proxy access to the main + principal's calendars, the other holds the list of principals who + have read-write and scheduling proxy access. NB these new group + principals would have no equivalent in Open Directory. + + Privileges on each "proxy group" principal will be set so that the + "owner" has the ability to change property values. + + The "proxy group" principals will be child resources of the user + principal resource with specific resource types and thus are easy to + discover. As a result the user principal resources will also be + collection resources. + + When provisioning the calendar user home collection, the server will: + + a. Add an ACE to the calendar home collection giving the read-only + "proxy group" inheritable read access. + + b. Add an ACE to the calendar home collection giving the read-write + "proxy group" inheritable read-write access. + + c. Add an ACE to each of the calendar Inbox and Outbox collections + giving the CALDAV:schedule privilege + [I-D.desruisseaux-caldav-sched] to the read-write "proxy group". + +3.2. Client + + A client can see who the proxies are for the current principal by + examining the principal resource for the two "proxy group" properties + and then looking at the DAV:group-member-set property of each. + + The client can edit the list of proxies for the current principal by + editing the DAV:group-member-set property on the relevant "proxy + group" principal resource. + + The client can find out who the current principal is a proxy for by + running a DAV:principal-match REPORT on the principal collection. + + + +Daboo [Page 3] + + CalDAV Proxy May 2007 + + + Alternatively, the client can find out who the current principal is a + proxy for by examining the DAV:group-membership property on the + current principal resource looking for membership in other users' + "proxy groups". + + +4. Open Issues + + 1. Do we want to separate read-write access to calendars vs the + ability to schedule as a proxy? + + 2. We may want to restrict changing properties on the proxy group + collections to just the DAV:group-member-set property? + + 3. There is no way for a proxy to be able to manage the list of + proxies. We could allow the main calendar user DAV:write-acl on + their "proxy group" principals, in which case they could grant + others the right to modify the group membership. + + 4. Should the "proxy group" principals also be collections given + that the regular principal resources will be? + + +5. New features in CalDAV + +5.1. Proxy Principal Resource + + Each "regular" principal resource that needs to allow calendar user + proxy support MUST be a collection resource. i.e. in addition to + including the DAV:principal XML element in the DAV:resourcetype + property on the resource, it MUST also include the DAV:collection XML + element. + + Each "regular" principal resource MUST contain two child resources + with names "calendar-proxy-read" and "calendar-proxy-write" (note + that these are only suggested names - the server could choose any + unique name for these). These resources are themselves principal + resources that are groups contain the list of principals for calendar + users who can act as a read-only or read-write proxy respectively. + + The server MUST include the CS:calendar-proxy-read or CS:calendar- + proxy-write XML elements in the DAV:resourcetype property of the + child resources, respectively. This allows clients to discover the + "proxy group" principals by using a PROPFIND, Depth:1 request on the + current user's principal resource and requesting the DAV:resourcetype + property be returned. The element type declarations are: + + + + + +Daboo [Page 4] + + CalDAV Proxy May 2007 + + + + + + + The server MUST allow the "parent" principal to change the DAV:group- + member-set property on each of the "child" "proxy group" principal + resources. When a principal is listed as a member of the "child" + resource, the server MUST include the "child" resource URI in the + DAV:group-membership property on the included principal resource. + Note that this is just "normal" behavior for a group principal. + + An example principal resource layout might be: + + + / + + principals/ + + users/ + + cyrus/ + calendar-proxy-read + calendar-proxy-write + + red/ + calendar-proxy-read + calendar-proxy-write + + wilfredo/ + calendar-proxy-read + calendar-proxy-write + + If the principal "cyrus" wishes to have the principal "red" act as a + calendar user proxy on his behalf and have the ability to change + items on his calendar or schedule meetings on his behalf, then he + would add the principal resource URI for "red" to the DAV:group- + member-set property of the principal resource /principals/users/ + cyrus/calendar-proxy-write, giving: + + + /principals/users/red/ + + + The DAV:group-membership property on the resource /principals/users/ + red/ would be: + + + /principals/users/cyrus/calendar-proxy-write + + + If the principal "red" was also a read-only proxy for the principal + "wilfredo", then the DA:group-membership property on the resource + /principals/users/red/ would be: + + + + +Daboo [Page 5] + + CalDAV Proxy May 2007 + + + + /principals/users/cyrus/calendar-proxy-write + /principals/users/wilfredo/calendar-proxy-read + + + Thus a client can discover to which principals a particular principal + is acting as a calendar user proxy for by examining the DAV:group- + membership property. + + An alternative to discovering which principals a user can proxy as is + to use the WebDAV ACL principal-match report, targeted at the + principal collections available on the server. + + Example: + + >> Request << + + REPORT /principals/ HTTP/1.1 + Host: cal.example.com + Depth: 0 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="red", + realm="cal.example.com", nonce="...", + uri="/principals/", response="...", opaque="..." + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo [Page 6] + + CalDAV Proxy May 2007 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Fri, 10 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + /principals/users/red/ + + + + + + + + HTTP/1.1 200 OK + + + + /principals/users/cyrus/calendar-proxy-write + + + + + + + + HTTP/1.1 200 OK + + + + /principals/users/wilfredo/calendar-proxy-read + + + + + + + + HTTP/1.1 200 OK + + + + + + + +Daboo [Page 7] + + CalDAV Proxy May 2007 + + +5.2. Privilege Provisioning + + In order for a calendar user proxy to be able to access the calendars + of the user they are proxying for the server MUST ensure that the + privileges on the relevant calendars are setup accordingly: + + The DAV:read privilege MUST be granted for read-only and read- + write calendar user proxy principals + + The DAV:write privilege MUST be granted for read-write calendar + user proxy principals. + + Additionally, the CalDAV scheduling Inbox and Outbox calendar + collections for the user allowing proxy access, MUST have the CALDAV: + schedule privilege [I-D.desruisseaux-caldav-sched] granted for read- + write calendar user proxy principals. + + Note that with a suitable repository layout, a server may be able to + grant the appropriate privileges on a parent collection and ensure + that all the contained collections and resources inherit that. For + example, given the following repository layout: + + + / + + calendars/ + + users/ + + cyrus/ + inbox + outbox + home + work + + red/ + inbox + outbox + work + soccer + + wilfredo/ + inbox + outbox + home + work + flying + + In order for the principal "red" to act as a read-write proxy for the + principal "cyrus", the following WebDAV ACE will need to be granted + on the resource /calendars/users/cyrus/ and all children of that + resource: + + + + + +Daboo [Page 8] + + CalDAV Proxy May 2007 + + + + + /principals/users/cyrus/calendar-proxy-write + + + + + + + +6. Security Considerations + + TBD + + +7. IANA Considerations + + This document does not require any actions on the part of IANA. + + +8. Normative References + + [I-D.desruisseaux-caldav-sched] + Desruisseaux, B., "Scheduling Extensions to CalDAV", + draft-desruisseaux-caldav-sched-03 (work in progress), + January 2007. + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC2518] Goland, Y., Whitehead, E., Faizi, A., Carter, S., and D. + Jensen, "HTTP Extensions for Distributed Authoring -- + WEBDAV", RFC 2518, February 1999. + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web + Distributed Authoring and Versioning (WebDAV) Access + Control Protocol", RFC 3744, May 2004. + + [RFC4791] Daboo, C., Desruisseaux, B., and L. Dusseault, + "Calendaring Extensions to WebDAV (CalDAV)", RFC 4791, + March 2007. + + +Appendix A. Acknowledgments + + This specification is the result of discussions between the Apple + calendar server and client teams. + + + + +Daboo [Page 9] + + CalDAV Proxy May 2007 + + +Appendix B. Change History + + Changes from -00: + + 1. Updated to RFC 4791 reference. + + Changes from -00: + + 1. Added more details on actual CalDAV protocol changes. + + 2. Changed namespace from http://apple.com/ns/calendarserver/ to + http://calendarserver.org/ns/. + + 3. Made "proxy group" principals child resources of their "owner" + principals. + + 4. The "proxy group" principals now have their own resourcetype. + + +Author's Address + + Cyrus Daboo + Apple Computer, Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + Email: cyrus@daboo.name + URI: http://www.apple.com/ + + + + + + + + + + + + + + + + + + + + + + +Daboo [Page 10] + diff --git a/doc/how_davx5_works.svgz b/doc/how_davx5_works.svgz new file mode 100644 index 0000000..93b51e4 Binary files /dev/null and b/doc/how_davx5_works.svgz differ diff --git a/doc/rfc3744-webdav-access-control-protocol.txt b/doc/rfc3744-webdav-access-control-protocol.txt new file mode 100644 index 0000000..afcdb2a --- /dev/null +++ b/doc/rfc3744-webdav-access-control-protocol.txt @@ -0,0 +1,4035 @@ + + + + + + +Network Working Group G. Clemm +Request for Comments: 3744 IBM +Category: Standards Track J. Reschke + greenbytes + E. Sedlar + Oracle Corporation + J. Whitehead + U.C. Santa Cruz + May 2004 + + + Web Distributed Authoring and Versioning (WebDAV) + Access Control Protocol + +Status of this Memo + + This document specifies an Internet standards track protocol for the + Internet community, and requests discussion and suggestions for + improvements. Please refer to the current edition of the "Internet + Official Protocol Standards" (STD 1) for the standardization state + and status of this protocol. Distribution of this memo is unlimited. + +Copyright Notice + + Copyright (C) The Internet Society (2004). All Rights Reserved. + +Abstract + + This document specifies a set of methods, headers, message bodies, + properties, and reports that define Access Control extensions to the + WebDAV Distributed Authoring Protocol. This protocol permits a + client to read and modify access control lists that instruct a server + whether to allow or deny operations upon a resource (such as + HyperText Transfer Protocol (HTTP) method invocations) by a given + principal. A lightweight representation of principals as Web + resources supports integration of a wide range of user management + repositories. Search operations allow discovery and manipulation of + principals using human names. + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 1] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 4 + 1.1. Terms. . . . . . . . . . . . . . . . . . . . . . . . . . 6 + 1.2. Notational Conventions . . . . . . . . . . . . . . . . . 8 + 2. Principals . . . . . . . . . . . . . . . . . . . . . . . . . . 8 + 3. Privileges . . . . . . . . . . . . . . . . . . . . . . . . . . 8 + 3.1. DAV:read Privilege . . . . . . . . . . . . . . . . . . . 10 + 3.2. DAV:write Privilege. . . . . . . . . . . . . . . . . . . 10 + 3.3. DAV:write-properties Privilege . . . . . . . . . . . . . 10 + 3.4. DAV:write-content Privilege. . . . . . . . . . . . . . . 11 + 3.5. DAV:unlock Privilege . . . . . . . . . . . . . . . . . . 11 + 3.6. DAV:read-acl Privilege . . . . . . . . . . . . . . . . . 11 + 3.7. DAV:read-current-user-privilege-set Privilege. . . . . . 12 + 3.8. DAV:write-acl Privilege. . . . . . . . . . . . . . . . . 12 + 3.9. DAV:bind Privilege . . . . . . . . . . . . . . . . . . . 12 + 3.10. DAV:unbind Privilege . . . . . . . . . . . . . . . . . . 12 + 3.11. DAV:all Privilege. . . . . . . . . . . . . . . . . . . . 13 + 3.12. Aggregation of Predefined Privileges . . . . . . . . . . 13 + 4. Principal Properties . . . . . . . . . . . . . . . . . . . . . 13 + 4.1. DAV:alternate-URI-set. . . . . . . . . . . . . . . . . . 14 + 4.2. DAV:principal-URL. . . . . . . . . . . . . . . . . . . . 14 + 4.3. DAV:group-member-set . . . . . . . . . . . . . . . . . . 14 + 4.4. DAV:group-membership . . . . . . . . . . . . . . . . . . 14 + 5. Access Control Properties. . . . . . . . . . . . . . . . . . . 15 + 5.1. DAV:owner. . . . . . . . . . . . . . . . . . . . . . . . 15 + 5.1.1. Example: Retrieving DAV:owner . . . . . . . . . . 15 + 5.1.2. Example: An Attempt to Set DAV:owner. . . . . . . 16 + 5.2. DAV:group. . . . . . . . . . . . . . . . . . . . . . . . 18 + 5.3. DAV:supported-privilege-set. . . . . . . . . . . . . . . 18 + 5.3.1. Example: Retrieving a List of Privileges + Supported on a Resource . . . . . . . . . . . . . 19 + 5.4. DAV:current-user-privilege-set . . . . . . . . . . . . . 21 + 5.4.1. Example: Retrieving the User's Current Set of + Assigned Privileges . . . . . . . . . . . . . . . 22 + 5.5. DAV:acl. . . . . . . . . . . . . . . . . . . . . . . . . 23 + 5.5.1. ACE Principal . . . . . . . . . . . . . . . . . . 23 + 5.5.2. ACE Grant and Deny. . . . . . . . . . . . . . . . 25 + 5.5.3. ACE Protection. . . . . . . . . . . . . . . . . . 25 + 5.5.4. ACE Inheritance . . . . . . . . . . . . . . . . . 25 + 5.5.5. Example: Retrieving a Resource's Access Control + List. . . . . . . . . . . . . . . . . . . . . . . 25 + 5.6. DAV:acl-restrictions . . . . . . . . . . . . . . . . . . 27 + 5.6.1. DAV:grant-only. . . . . . . . . . . . . . . . . . 27 + 5.6.2. DAV:no-invert ACE Constraint. . . . . . . . . . . 28 + 5.6.3. DAV:deny-before-grant . . . . . . . . . . . . . . 28 + 5.6.4. Required Principals . . . . . . . . . . . . . . . 28 + 5.6.5. Example: Retrieving DAV:acl-restrictions. . . . . 28 + + + +Clemm, et al. Standards Track [Page 2] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + 5.7. DAV:inherited-acl-set. . . . . . . . . . . . . . . . . . 29 + 5.8. DAV:principal-collection-set . . . . . . . . . . . . . . 30 + 5.8.1. Example: Retrieving DAV:principal-collection-set. 30 + 5.9. Example: PROPFIND to retrieve access control properties. 32 + 6. ACL Evaluation . . . . . . . . . . . . . . . . . . . . . . . . 36 + 7. Access Control and existing methods. . . . . . . . . . . . . . 37 + 7.1. Any HTTP method. . . . . . . . . . . . . . . . . . . . . 37 + 7.1.1. Error Handling. . . . . . . . . . . . . . . . . . 37 + 7.2. OPTIONS. . . . . . . . . . . . . . . . . . . . . . . . . 38 + 7.2.1. Example - OPTIONS . . . . . . . . . . . . . . . . 39 + 7.3. MOVE . . . . . . . . . . . . . . . . . . . . . . . . . . 39 + 7.4. COPY . . . . . . . . . . . . . . . . . . . . . . . . . . 39 + 7.5. LOCK . . . . . . . . . . . . . . . . . . . . . . . . . . 39 + 8. Access Control Methods . . . . . . . . . . . . . . . . . . . . 40 + 8.1. ACL. . . . . . . . . . . . . . . . . . . . . . . . . . . 40 + 8.1.1. ACL Preconditions . . . . . . . . . . . . . . . . 40 + 8.1.2. Example: the ACL method . . . . . . . . . . . . . 42 + 8.1.3. Example: ACL method failure due to protected + ACE conflict. . . . . . . . . . . . . . . . . . . 43 + 8.1.4. Example: ACL method failure due to an + inherited ACE conflict. . . . . . . . . . . . . . 44 + 8.1.5. Example: ACL method failure due to an attempt + to set grant and deny in a single ACE . . . . . . 45 + 9. Access Control Reports . . . . . . . . . . . . . . . . . . . . 46 + 9.1. REPORT Method. . . . . . . . . . . . . . . . . . . . . . 46 + 9.2. DAV:acl-principal-prop-set Report. . . . . . . . . . . . 47 + 9.2.1. Example: DAV:acl-principal-prop-set Report. . . . 48 + 9.3. DAV:principal-match REPORT . . . . . . . . . . . . . . . 49 + 9.3.1. Example: DAV:principal-match REPORT . . . . . . . 50 + 9.4. DAV:principal-property-search REPORT . . . . . . . . . . 51 + 9.4.1. Matching. . . . . . . . . . . . . . . . . . . . . 53 + 9.4.2. Example: successful DAV:principal-property-search + REPORT. . . . . . . . . . . . . . . . . . . . . . 54 + 9.5. DAV:principal-search-property-set REPORT . . . . . . . . 56 + 9.5.1. Example: DAV:principal-search-property-set + REPORT. . . . . . . . . . . . . . . . . . . . . . 58 + 10. XML Processing . . . . . . . . . . . . . . . . . . . . . . . . 59 + 11. Internationalization Considerations. . . . . . . . . . . . . . 59 + 12. Security Considerations. . . . . . . . . . . . . . . . . . . . 60 + 12.1. Increased Risk of Compromised Users. . . . . . . . . . . 60 + 12.2. Risks of the DAV:read-acl and + DAV:current-user-privilege-set Privileges. . . . . . . . 60 + 12.3. No Foreknowledge of Initial ACL. . . . . . . . . . . . . 61 + 13. Authentication . . . . . . . . . . . . . . . . . . . . . . . . 61 + 14. IANA Considerations. . . . . . . . . . . . . . . . . . . . . . 62 + 15. Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . 62 + + + + + +Clemm, et al. Standards Track [Page 3] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + 16. References . . . . . . . . . . . . . . . . . . . . . . . . . . 62 + 16.1. Normative References . . . . . . . . . . . . . . . . . . 62 + 16.2. Informative References . . . . . . . . . . . . . . . . . 63 + Appendices + A. WebDAV XML Document Type Definition Addendum . . . . . . . . . 64 + B. WebDAV Method Privilege Table (Normative). . . . . . . . . . . 67 + Index. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 + Authors' Addresses . . . . . . . . . . . . . . . . . . . . . . . . 71 + Full Copyright Statement. . . . . . . . . . . . . . . . . . . . . 72 + +1. Introduction + + The goal of the WebDAV access control extensions is to provide an + interoperable mechanism for handling discretionary access control for + content and metadata managed by WebDAV servers. WebDAV access + control can be implemented on content repositories with security as + simple as that of a UNIX file system, as well as more sophisticated + models. The underlying principle of access control is that who you + are determines what operations you can perform on a resource. The + "who you are" is defined by a "principal" identifier; users, client + software, servers, and groups of the previous have principal + identifiers. The "operations you can perform" are determined by a + single "access control list" (ACL) associated with a resource. An + ACL contains a set of "access control entries" (ACEs), where each ACE + specifies a principal and a set of privileges that are either granted + or denied to that principal. When a principal submits an operation + (such as an HTTP or WebDAV method) to a resource for execution, the + server evaluates the ACEs in the ACL to determine if the principal + has permission for that operation. + + Since every ACE contains the identifier of a principal, client + software operated by a human must provide a mechanism for selecting + this principal. This specification uses http(s) scheme URLs to + identify principals, which are represented as WebDAV-capable + resources. There is no guarantee that the URLs identifying + principals will be meaningful to a human. For example, + http://www.example.com/u/256432 and + http://www.example.com/people/Greg.Stein are both valid URLs that + could be used to identify the same principal. To remedy this, every + principal resource has the DAV:displayname property containing a + human-readable name for the principal. + + Since a principal can be identified by multiple URLs, it raises the + problem of determining exactly which principal is being referenced in + a given ACE. It is impossible for a client to determine that an ACE + granting the read privilege to http://www.example.com/people/ + Greg.Stein also affects the principal at http://www.example.com/u/ + 256432. That is, a client has no mechanism for determining that two + + + +Clemm, et al. Standards Track [Page 4] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + URLs identify the same principal resource. As a result, this + specification requires clients to use just one of the many possible + URLs for a principal when creating ACEs. A client can discover which + URL to use by retrieving the DAV:principal-URL property (Section 4.2) + from a principal resource. No matter which of the principal's URLs + is used with PROPFIND, the property always returns the same URL. + + With a system having hundreds to thousands of principals, the problem + arises of how to allow a human operator of client software to select + just one of these principals. One approach is to use broad + collection hierarchies to spread the principals over a large number + of collections, yielding few principals per collection. An example + of this is a two level hierarchy with the first level containing 36 + collections (a-z, 0-9), and the second level being another 36, + creating collections /a/a/, /a/b/, ..., /a/z/, such that a principal + with last name "Stein" would appear at /s/t/Stein. In effect, this + pre-computes a common query, search on last name, and encodes it into + a hierarchy. The drawback with this scheme is that it handles only a + small set of predefined queries, and drilling down through the + collection hierarchy adds unnecessary steps (navigate down/up) when + the user already knows the principal's name. While organizing + principal URLs into a hierarchy is a valid namespace organization, + users should not be forced to navigate this hierarchy to select a + principal. + + This specification provides the capability to perform substring + searches over a small set of properties on the resources representing + principals. This permits searches based on last name, first name, + user name, job title, etc. Two separate searches are supported, both + via the REPORT method, one to search principal resources + (DAV:principal-property-search, Section 9.4), the other to determine + which properties may be searched at all (DAV:principal-search- + property-set, Section 9.5). + + Once a principal has been identified in an ACE, a server evaluating + that ACE must know the identity of the principal making a protocol + request, and must validate that that principal is who they claim to + be, a process known as authentication. This specification + intentionally omits discussion of authentication, as the HTTP + protocol already has a number of authentication mechanisms [RFC2617]. + Some authentication mechanism (such as HTTP Digest Authentication, + which all WebDAV compliant implementations are required to support) + must be available to validate the identity of a principal. + + + + + + + + +Clemm, et al. Standards Track [Page 5] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The following issues are out of scope for this document: + + o Access control that applies only to a particular property on a + resource (excepting the access control properties DAV:acl and + DAV:current-user-privilege-set), rather than the entire resource, + + o Role-based security (where a role can be seen as a dynamically + defined group of principals), + + o Specification of the ways an ACL on a resource is initialized, + + o Specification of an ACL that applies globally to all resources, + rather than to a particular resource. + + o Creation and maintenance of resources representing people or + computational agents (principals), and groups of these. + + This specification is organized as follows. Section 1.1 defines key + concepts used throughout the specification, and is followed by a more + in-depth discussion of principals (Section 2), and privileges + (Section 3). Properties defined on principals are specified in + Section 4, and access control properties for content resources are + specified in Section 5. The ways ACLs are to be evaluated is + described in Section 6. Client discovery of access control + capability using OPTIONS is described in Section 7.2. Interactions + between access control functionality and existing HTTP and WebDAV + methods are described in the remainder of Section 7. The access + control setting method, ACL, is specified in Section 8. Four reports + that provide limited server-side searching capabilities are described + in Section 9. Sections on XML processing (Section 10), + Internationalization considerations (Section 11), security + considerations (Section 12), and authentication (Section 13) round + out the specification. An appendix (Appendix A) provides an XML + Document Type Definition (DTD) for the XML elements defined in the + specification. + +1.1. Terms + + This document uses the terms defined in HTTP [RFC2616] and WebDAV + [RFC2518]. In addition, the following terms are defined: + + principal + + A "principal" is a distinct human or computational actor that + initiates access to network resources. In this protocol, a + principal is an HTTP resource that represents such an actor. + + + + + +Clemm, et al. Standards Track [Page 6] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + group + + A "group" is a principal that represents a set of other + principals. + + privilege + + A "privilege" controls access to a particular set of HTTP + operations on a resource. + + aggregate privilege + + An "aggregate privilege" is a privilege that contains a set of + other privileges. + + abstract privilege + + The modifier "abstract", when applied to a privilege on a + resource, means the privilege cannot be set in an access control + element (ACE) on that resource. + + access control list (ACL) + + An "ACL" is a list of access control elements that define access + control to a particular resource. + + access control element (ACE) + + An "ACE" either grants or denies a particular set of (non- + abstract) privileges for a particular principal. + + inherited ACE + + An "inherited ACE" is an ACE that is dynamically shared from the + ACL of another resource. When a shared ACE changes on the primary + resource, it is also changed on inheriting resources. + + protected property + + A "protected property" is one whose value cannot be updated except + by a method explicitly defined as updating that specific property. + In particular, a protected property cannot be updated with a + PROPPATCH request. + + + + + + + + +Clemm, et al. Standards Track [Page 7] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +1.2. Notational Conventions + + The augmented BNF used by this document to describe protocol elements + is described in Section 2.1 of [RFC2616]. Because this augmented BNF + uses the basic production rules provided in Section 2.2 of [RFC2616], + those rules apply to this document as well. + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + Definitions of XML elements in this document use XML element type + declarations (as found in XML Document Type Declarations), described + in Section 3.2 of [REC-XML]. When an XML element type in the "DAV:" + namespace is referenced in this document outside of the context of an + XML fragment, the string "DAV:" will be prefixed to the element name. + +2. Principals + + A principal is a network resource that represents a distinct human or + computational actor that initiates access to network resources. + Users and groups are represented as principals in many + implementations; other types of principals are also possible. A URI + of any scheme MAY be used to identify a principal resource. However, + servers implementing this specification MUST expose principal + resources at an http(s) URL, which is a privileged scheme that points + to resources that have additional properties, as described in Section + 4. So, a principal resource can have multiple URIs, one of which has + to be an http(s) scheme URL. Although an implementation SHOULD + support PROPFIND and MAY support PROPPATCH to access and modify + information about a principal, it is not required to do so. + + A principal resource may be a group, where a group is a principal + that represents a set of other principals, called the members of the + group. If a person or computational agent matches a principal + resource that is a member of a group, they also match the group. + Membership in a group is recursive, so if a principal is a member of + group GRPA, and GRPA is a member of group GRPB, then the principal is + also a member of GRPB. + +3. Privileges + + Ability to perform a given method on a resource MUST be controlled by + one or more privileges. Authors of protocol extensions that define + new HTTP methods SHOULD specify which privileges (by defining new + privileges, or mapping to ones below) are required to perform the + method. A principal with no privileges to a resource MUST be denied + any HTTP access to that resource, unless the principal matches an ACE + + + +Clemm, et al. Standards Track [Page 8] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + constructed using the DAV:all, DAV:authenticated, or + DAV:unauthenticated pseudo-principals (see Section 5.5.1). Servers + MUST report a 403 "Forbidden" error if access is denied, except in + the case where the privilege restricts the ability to know the + resource exists, in which case 404 "Not Found" may be returned. + + Privileges may be containers of other privileges, in which case they + are termed "aggregate privileges". If a principal is granted or + denied an aggregate privilege, it is semantically equivalent to + granting or denying each of the aggregated privileges individually. + For example, an implementation may define add-member and remove- + member privileges that control the ability to add and remove a member + of a group. Since these privileges control the ability to update the + state of a group, these privileges would be aggregated by the + DAV:write privilege on a group, and granting the DAV:write privilege + on a group would also grant the add-member and remove-member + privileges. + + Privileges may be declared to be "abstract" for a given resource, in + which case they cannot be set in an ACE on that resource. Aggregate + and non-aggregate privileges are both capable of being abstract. + Abstract privileges are useful for modeling privileges that otherwise + would not be exposed via the protocol. Abstract privileges also + provide server implementations with flexibility in implementing the + privileges defined in this specification. For example, if a server + is incapable of separating the read resource capability from the read + ACL capability, it can still model the DAV:read and DAV:read-acl + privileges defined in this specification by declaring them abstract, + and containing them within a non-abstract aggregate privilege (say, + read-all) that holds DAV:read, and DAV:read-acl. In this way, it is + possible to set the aggregate privilege, read-all, thus coupling the + setting of DAV:read and DAV:read-acl, but it is not possible to set + DAV:read, or DAV:read-acl individually. Since aggregate privileges + can be abstract, it is also possible to use abstract privileges to + group or organize non-abstract privileges. Privilege containment + loops are not allowed; therefore, a privilege MUST NOT contain + itself. For example, DAV:read cannot contain DAV:read. + + The set of privileges that apply to a particular resource may vary + with the DAV:resourcetype of the resource, as well as between + different server implementations. To promote interoperability, + however, this specification defines a set of well-known privileges + (e.g., DAV:read, DAV:write, DAV:read-acl, DAV:write-acl, DAV:read- + current-user-privilege-set, and DAV:all), which can at least be used + to classify the other privileges defined on a particular resource. + The access permissions on null resources (defined in [RFC2518], + Section 3) are solely those they inherit (if any), and they are not + discoverable (i.e., the access control properties specified in + + + +Clemm, et al. Standards Track [Page 9] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Section 5 are not defined on null resources). On the transition from + null to stateful resource, the initial access control list is set by + the server's default ACL value policy (if any). + + Server implementations MAY define new privileges beyond those defined + in this specification. Privileges defined by individual + implementations MUST NOT use the DAV: namespace, and instead should + use a namespace that they control, such as an http scheme URL. + +3.1. DAV:read Privilege + + The read privilege controls methods that return information about the + state of the resource, including the resource's properties. Affected + methods include GET and PROPFIND. Any implementation-defined + privilege that also controls access to GET and PROPFIND must be + aggregated under DAV:read - if an ACL grants access to DAV:read, the + client may expect that no other privilege needs to be granted to have + access to GET and PROPFIND. Additionally, the read privilege MUST + control the OPTIONS method. + + + +3.2. DAV:write Privilege + + The write privilege controls methods that lock a resource or modify + the content, dead properties, or (in the case of a collection) + membership of the resource, such as PUT and PROPPATCH. Note that + state modification is also controlled via locking (see section 5.3 of + [RFC2518]), so effective write access requires that both write + privileges and write locking requirements are satisfied. Any + implementation-defined privilege that also controls access to methods + modifying content, dead properties or collection membership must be + aggregated under DAV:write, e.g., if an ACL grants access to + DAV:write, the client may expect that no other privilege needs to be + granted to have access to PUT and PROPPATCH. + + + +3.3. DAV:write-properties Privilege + + The DAV:write-properties privilege controls methods that modify the + dead properties of the resource, such as PROPPATCH. Whether this + privilege may be used to control access to any live properties is + determined by the implementation. Any implementation-defined + privilege that also controls access to methods modifying dead + properties must be aggregated under DAV:write-properties - e.g., if + + + + + +Clemm, et al. Standards Track [Page 10] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + an ACL grants access to DAV:write-properties, the client can safely + expect that no other privilege needs to be granted to have access to + PROPPATCH. + + + +3.4. DAV:write-content Privilege + + The DAV:write-content privilege controls methods that modify the + content of an existing resource, such as PUT. Any implementation- + defined privilege that also controls access to content must be + aggregated under DAV:write-content - e.g., if an ACL grants access to + DAV:write-content, the client can safely expect that no other + privilege needs to be granted to have access to PUT. Note that PUT - + when applied to an unmapped URI - creates a new resource and + therefore is controlled by the DAV:bind privilege on the parent + collection. + + + +3.5. DAV:unlock Privilege + + The DAV:unlock privilege controls the use of the UNLOCK method by a + principal other than the lock owner (the principal that created a + lock can always perform an UNLOCK). While the set of users who may + lock a resource is most commonly the same set of users who may modify + a resource, servers may allow various kinds of administrators to + unlock resources locked by others. Any privilege controlling access + by non-lock owners to UNLOCK MUST be aggregated under DAV:unlock. + + A lock owner can always remove a lock by issuing an UNLOCK with the + correct lock token and authentication credentials. That is, even if + a principal does not have DAV:unlock privilege, they can still remove + locks they own. Principals other than the lock owner can remove a + lock only if they have DAV:unlock privilege and they issue an UNLOCK + with the correct lock token. Lock timeout is not affected by the + DAV:unlock privilege. + + + +3.6. DAV:read-acl Privilege + + The DAV:read-acl privilege controls the use of PROPFIND to retrieve + the DAV:acl property of the resource. + + + + + + + +Clemm, et al. Standards Track [Page 11] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +3.7. DAV:read-current-user-privilege-set Privilege + + The DAV:read-current-user-privilege-set privilege controls the use of + PROPFIND to retrieve the DAV:current-user-privilege-set property of + the resource. + + Clients are intended to use this property to visually indicate in + their UI items that are dependent on the permissions of a resource, + for example, by graying out resources that are not writable. + + This privilege is separate from DAV:read-acl because there is a need + to allow most users access to the privileges permitted the current + user (due to its use in creating the UI), while the full ACL contains + information that may not be appropriate for the current authenticated + user. As a result, the set of users who can view the full ACL is + expected to be much smaller than those who can read the current user + privilege set, and hence distinct privileges are needed for each. + + + +3.8. DAV:write-acl Privilege + + The DAV:write-acl privilege controls use of the ACL method to modify + the DAV:acl property of the resource. + + + +3.9. DAV:bind Privilege + + The DAV:bind privilege allows a method to add a new member URL to the + specified collection (for example via PUT or MKCOL). It is ignored + for resources that are not collections. + + + +3.10. DAV:unbind Privilege + + The DAV:unbind privilege allows a method to remove a member URL from + the specified collection (for example via DELETE or MOVE). It is + ignored for resources that are not collections. + + + + + + + + + + + +Clemm, et al. Standards Track [Page 12] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +3.11. DAV:all Privilege + + DAV:all is an aggregate privilege that contains the entire set of + privileges that can be applied to the resource. + + + +3.12. Aggregation of Predefined Privileges + + Server implementations are free to aggregate the predefined + privileges (defined above in Sections 3.1-3.10) subject to the + following limitations: + + DAV:read-acl MUST NOT contain DAV:read, DAV:write, DAV:write-acl, + DAV:write-properties, DAV:write-content, or DAV:read-current-user- + privilege-set. + + DAV:write-acl MUST NOT contain DAV:write, DAV:read, DAV:read-acl, or + DAV:read-current-user-privilege-set. + + DAV:read-current-user-privilege-set MUST NOT contain DAV:write, + DAV:read, DAV:read-acl, or DAV:write-acl. + + DAV:write MUST NOT contain DAV:read, DAV:read-acl, or DAV:read- + current-user-privilege-set. + + DAV:read MUST NOT contain DAV:write, DAV:write-acl, DAV:write- + properties, or DAV:write-content. + + DAV:write MUST contain DAV:bind, DAV:unbind, DAV:write-properties and + DAV:write-content. + +4. Principal Properties + + Principals are manifested to clients as a WebDAV resource, identified + by a URL. A principal MUST have a non-empty DAV:displayname property + (defined in Section 13.2 of [RFC2518]), and a DAV:resourcetype + property (defined in Section 13.9 of [RFC2518]). Additionally, a + principal MUST report the DAV:principal XML element in the value of + the DAV:resourcetype property. The element type declaration for + DAV:principal is: + + + + + + + + + + +Clemm, et al. Standards Track [Page 13] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + This protocol defines the following additional properties for a + principal. Since it can be expensive for a server to retrieve access + control information, the name and value of these properties SHOULD + NOT be returned by a PROPFIND allprop request (as defined in Section + 12.14.1 of [RFC2518]). + +4.1. DAV:alternate-URI-set + + This protected property, if non-empty, contains the URIs of network + resources with additional descriptive information about the + principal. This property identifies additional network resources + (i.e., it contains one or more URIs) that may be consulted by a + client to gain additional knowledge concerning a principal. One + expected use for this property is the storage of an LDAP [RFC2255] + scheme URL. A user-agent encountering an LDAP URL could use LDAP + [RFC2251] to retrieve additional machine-readable directory + information about the principal, and display that information in its + user interface. Support for this property is REQUIRED, and the value + is empty if no alternate URI exists for the principal. + + + +4.2. DAV:principal-URL + + A principal may have many URLs, but there must be one "principal URL" + that clients can use to uniquely identify a principal. This + protected property contains the URL that MUST be used to identify + this principal in an ACL request. Support for this property is + REQUIRED. + + + +4.3. DAV:group-member-set + + This property of a group principal identifies the principals that are + direct members of this group. Since a group may be a member of + another group, a group may also have indirect members (i.e., the + members of its direct members). A URL in the DAV:group-member-set + for a principal MUST be the DAV:principal-URL of that principal. + + + +4.4. DAV:group-membership + + This protected property identifies the groups in which the principal + is directly a member. Note that a server may allow a group to be a + member of another group, in which case the DAV:group-membership of + + + + +Clemm, et al. Standards Track [Page 14] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + those other groups would need to be queried in order to determine the + groups in which the principal is indirectly a member. Support for + this property is REQUIRED. + + + +5. Access Control Properties + + This specification defines a number of new properties for WebDAV + resources. Access control properties may be retrieved just like + other WebDAV properties, using the PROPFIND method. Since it is + expensive, for many servers, to retrieve access control information, + a PROPFIND allprop request (as defined in Section 12.14.1 of + [RFC2518]) SHOULD NOT return the names and values of the properties + defined in this section. + + Access control properties (especially DAV:acl and DAV:inherited-acl- + set) are defined on the resource identified by the Request-URI of a + PROPFIND request. A direct consequence is that if the resource is + accessible via multiple URI, the value of access control properties + is the same across these URI. + + HTTP resources that support the WebDAV Access Control Protocol MUST + contain the following properties. Null resources (described in + Section 3 of [RFC2518]) MUST NOT contain the following properties. + +5.1. DAV:owner + + This property identifies a particular principal as being the "owner" + of the resource. Since the owner of a resource often has special + access control capabilities (e.g., the owner frequently has permanent + DAV:write-acl privilege), clients might display the resource owner in + their user interface. + + Servers MAY implement DAV:owner as protected property and MAY return + an empty DAV:owner element as property value in case no owner + information is available. + + + +5.1.1. Example: Retrieving DAV:owner + + This example shows a client request for the value of the DAV:owner + property from a collection resource with URL http://www.example.com/ + papers/. The principal making the request is authenticated using + Digest authentication. The value of DAV:owner is the URL http:// + www.example.com/acl/users/gstein, wrapped in the DAV:href XML + element. + + + +Clemm, et al. Standards Track [Page 15] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + >> Request << + + PROPFIND /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="jim", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/papers/ + + + + http://www.example.com/acl/users/gstein + + + HTTP/1.1 200 OK + + + + +5.1.2. Example: An Attempt to Set DAV:owner + + The following example shows a client request to modify the value of + the DAV:owner property on the resource with URL . Since DAV:owner is a protected property on + this particular server, it responds with a 207 (Multi-Status) + response that contains a 403 (Forbidden) status code for the act of + setting DAV:owner. Section 8.2.1 of [RFC2518] describes PROPPATCH + status code information, Section 11 of [RFC2518] describes the + + + +Clemm, et al. Standards Track [Page 16] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Multi-Status response and Sections 1.6 and 3.12 of [RFC3253] describe + additional error marshaling for PROPPATCH attempts on protected + properties. + + >> Request << + + PROPPATCH /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="jim", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + + + + http://www.example.com/acl/users/jim + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/papers/ + + + HTTP/1.1 403 Forbidden + + + Failure to set protected property (DAV:owner) + + + + + + + + + +Clemm, et al. Standards Track [Page 17] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +5.2. DAV:group + + This property identifies a particular principal as being the "group" + of the resource. This property is commonly found on repositories + that implement the Unix privileges model. + + Servers MAY implement DAV:group as protected property and MAY return + an empty DAV:group element as property value in case no group + information is available. + + + +5.3. DAV:supported-privilege-set + + This is a protected property that identifies the privileges defined + for the resource. + + + + Each privilege appears as an XML element, where aggregate privileges + list as sub-elements all of the privileges that they aggregate. + + + + + An abstract privilege MUST NOT be used in an ACE for that resource. + Servers MUST fail an attempt to set an abstract privilege. + + + + A description is a human-readable description of what this privilege + controls access to. Servers MUST indicate the human language of the + description using the xml:lang attribute and SHOULD consider the HTTP + Accept-Language request header when selecting one of multiple + available languages. + + + + It is envisioned that a WebDAV ACL-aware administrative client would + list the supported privileges in a dialog box, and allow the user to + choose non-abstract privileges to apply in an ACE. The privileges + tree is useful programmatically to map well-known privileges (defined + by WebDAV or other standards groups) into privileges that are + supported by any particular server implementation. The privilege + tree also serves to hide complexity in implementations allowing large + number of privileges to be defined by displaying aggregates to the + user. + + + +Clemm, et al. Standards Track [Page 18] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +5.3.1. Example: Retrieving a List of Privileges Supported on a Resource + + This example shows a client request for the DAV:supported-privilege- + set property on the resource http://www.example.com/papers/. The + value of the DAV:supported-privilege-set property is a tree of + supported privileges (using "[XML Namespace , localname]" to identify + each privilege): + + [DAV:, all] (aggregate, abstract) + | + +-- [DAV:, read] (aggregate) + | + +-- [DAV:, read-acl] (abstract) + +-- [DAV:, read-current-user-privilege-set] (abstract) + | + +-- [DAV:, write] (aggregate) + | + +-- [DAV:, write-acl] (abstract) + +-- [DAV:, write-properties] + +-- [DAV:, write-content] + | + +-- [DAV:, unlock] + + This privilege tree is not normative (except that it reflects the + normative aggregation rules given in Section 3.12), and many possible + privilege trees are possible. + + >> Request << + + PROPFIND /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="gclemm", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 19] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + >> Response << + + HTTP/1.1 207 Multi-Status + + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/papers/ + + + + + + + + Any operation + + + + + Read any object + + + + + Read ACL + + + + + + + + Read current user privilege set property + + + + + + + Write any object + + + + + + + +Clemm, et al. Standards Track [Page 20] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Write ACL + + + + + + + Write properties + + + + + + Write resource content + + + + + + + Unlock resource + + + + + + HTTP/1.1 200 OK + + + + +5.4. DAV:current-user-privilege-set + + DAV:current-user-privilege-set is a protected property containing the + exact set of privileges (as computed by the server) granted to the + currently authenticated HTTP user. Aggregate privileges and their + contained privileges are listed. A user-agent can use the value of + this property to adjust its user interface to make actions + inaccessible (e.g., by graying out a menu item or button) for which + the current principal does not have permission. This property is + also useful for determining what operations the current principal can + perform, without having to actually execute an operation. + + + + + + + + + +Clemm, et al. Standards Track [Page 21] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + If the current user is granted a specific privilege, that privilege + must belong to the set of privileges that may be set on this + resource. Therefore, each element in the DAV:current-user- + privilege-set property MUST identify a non-abstract privilege from + the DAV:supported-privilege-set property. + +5.4.1. Example: Retrieving the User's Current Set of Assigned + Privileges + + Continuing the example from Section 5.3.1, this example shows a + client requesting the DAV:current-user-privilege-set property from + the resource with URL http://www.example.com/papers/. The username + of the principal making the request is "khare", and Digest + authentication is used in the request. The principal with username + "khare" has been granted the DAV:read privilege. Since the DAV:read + privilege contains the DAV:read-acl and DAV:read-current-user- + privilege-set privileges (see Section 5.3.1), the principal with + username "khare" can read the ACL property, and the DAV:current- + user-privilege-set property. However, the DAV:all, DAV:read-acl, + DAV:write-acl and DAV:read-current-user-privilege-set privileges are + not listed in the value of DAV:current-user-privilege-set, since (for + this example) they are abstract privileges. DAV:write is not listed + since the principal with username "khare" is not listed in an ACE + granting that principal write permission. + + >> Request << + + PROPFIND /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="khare", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 22] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/papers/ + + + + + + + HTTP/1.1 200 OK + + + + +5.5. DAV:acl + + This is a protected property that specifies the list of access + control entries (ACEs), which define what principals are to get what + privileges for this resource. + + + + Each DAV:ace element specifies the set of privileges to be either + granted or denied to a single principal. If the DAV:acl property is + empty, no principal is granted any privilege. + + + +5.5.1. ACE Principal + + The DAV:principal element identifies the principal to which this ACE + applies. + + + + The current user matches DAV:href only if that user is authenticated + as being (or being a member of) the principal identified by the URL + contained by that DAV:href. + + + + +Clemm, et al. Standards Track [Page 23] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The current user always matches DAV:all. + + + + The current user matches DAV:authenticated only if authenticated. + + + + The current user matches DAV:unauthenticated only if not + authenticated. + + + + DAV:all is the union of DAV:authenticated, and DAV:unauthenticated. + For a given request, the user matches either DAV:authenticated, or + DAV:unauthenticated, but not both (that is, DAV:authenticated and + DAV:unauthenticated are disjoint sets). + + The current user matches a DAV:property principal in a DAV:acl + property of a resource only if the value of the identified property + of that resource contains at most one DAV:href XML element, the URI + value of DAV:href identifies a principal, and the current user is + authenticated as being (or being a member of) that principal. For + example, if the DAV:property element contained , the + current user would match the DAV:property principal only if the + current user is authenticated as matching the principal identified by + the DAV:owner property of the resource. + + + + The current user matches DAV:self in a DAV:acl property of the + resource only if that resource is a principal and that principal + matches the current user or, if the principal is a group, a member of + that group matches the current user. + + + + Some servers may support ACEs applying to those users NOT matching + the current principal, e.g., all users not in a particular group. + This can be done by wrapping the DAV:principal element with + DAV:invert. + + + + + + + + + + +Clemm, et al. Standards Track [Page 24] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +5.5.2. ACE Grant and Deny + + Each DAV:grant or DAV:deny element specifies the set of privileges to + be either granted or denied to the specified principal. A DAV:grant + or DAV:deny element of the DAV:acl of a resource MUST only contain + non-abstract elements specified in the DAV:supported-privilege-set of + that resource. + + + + + +5.5.3. ACE Protection + + A server indicates an ACE is protected by including the DAV:protected + element in the ACE. If the ACL of a resource contains an ACE with a + DAV:protected element, an attempt to remove that ACE from the ACL + MUST fail. + + + +5.5.4. ACE Inheritance + + The presence of a DAV:inherited element indicates that this ACE is + inherited from another resource that is identified by the URL + contained in a DAV:href element. An inherited ACE cannot be modified + directly, but instead the ACL on the resource from which it is + inherited must be modified. + + Note that ACE inheritance is not the same as ACL initialization. ACL + initialization defines the ACL that a newly created resource will use + (if not specified). ACE inheritance refers to an ACE that is + logically shared - where an update to the resource containing an ACE + will affect the ACE of each resource that inherits that ACE. The + method by which ACLs are initialized or by which ACEs are inherited + is not defined by this document. + + + +5.5.5. Example: Retrieving a Resource's Access Control List + + Continuing the example from Sections 5.3.1 and 5.4.1, this example + shows a client requesting the DAV:acl property from the resource with + URL http://www.example.com/papers/. There are two ACEs defined in + this ACL: + + + + + + +Clemm, et al. Standards Track [Page 25] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + ACE #1: The group identified by URL http://www.example.com/acl/ + groups/maintainers (the group of site maintainers) is granted + DAV:write privilege. Since (for this example) DAV:write contains the + DAV:write-acl privilege (see Section 5.3.1), this means the + "maintainers" group can also modify the access control list. + + ACE #2: All principals (DAV:all) are granted the DAV:read privilege. + Since (for this example) DAV:read contains DAV:read-acl and + DAV:read-current-user-privilege-set, this means all users (including + all members of the "maintainers" group) can read the DAV:acl property + and the DAV:current-user-privilege-set property. + + >> Request << + + PROPFIND /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="masinter", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + http://www.example.com/papers/ + + + + + + http://www.example.com/acl/groups/maintainers + + + + + + +Clemm, et al. Standards Track [Page 26] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + + + + + + HTTP/1.1 200 OK + + + + +5.6. DAV:acl-restrictions + + This protected property defines the types of ACLs supported by this + server, to avoid clients needlessly getting errors. When a client + tries to set an ACL via the ACL method, the server may reject the + attempt to set the ACL as specified. The following properties + indicate the restrictions the client must observe before setting an + ACL: + + Deny ACEs are not supported + + Inverted ACEs are not supported + + All deny ACEs must occur before any grant ACEs + + Indicates which principals are required to be + present + + + + +5.6.1. DAV:grant-only + + This element indicates that ACEs with deny clauses are not allowed. + + + + + + + + +Clemm, et al. Standards Track [Page 27] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +5.6.2. DAV:no-invert ACE Constraint + + This element indicates that ACEs with the element are not + allowed. + + + +5.6.3. DAV:deny-before-grant + + This element indicates that all deny ACEs must precede all grant + ACEs. + + + +5.6.4. Required Principals + + The required principal elements identify which principals must have + an ACE defined in the ACL. + + + + For example, the following element requires that the ACL contain a + + DAV:owner property ACE: + + + + + +5.6.5. Example: Retrieving DAV:acl-restrictions + + In this example, the client requests the value of the DAV:acl- + restrictions property. Digest authentication provides credentials + for the principal operating the client. + + >> Request << + + PROPFIND /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="srcarter", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + +Clemm, et al. Standards Track [Page 28] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/papers/ + + + + + + + + + + HTTP/1.1 200 OK + + + + +5.7. DAV:inherited-acl-set + + This protected property contains a set of URLs that identify other + resources that also control the access to this resource. To have a + privilege on a resource, not only must the ACL on that resource + (specified in the DAV:acl property of that resource) grant the + privilege, but so must the ACL of each resource identified in the + DAV:inherited-acl-set property of that resource. Effectively, the + privileges granted by the current ACL are ANDed with the privileges + granted by each inherited ACL. + + + + + + + + + + +Clemm, et al. Standards Track [Page 29] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +5.8. DAV:principal-collection-set + + This protected property of a resource contains a set of URLs that + identify the root collections that contain the principals that are + available on the server that implements this resource. A WebDAV + Access Control Protocol user agent could use the contents of + DAV:principal-collection-set to retrieve the DAV:displayname property + (specified in Section 13.2 of [RFC2518]) of all principals on that + server, thereby yielding human-readable names for each principal that + could be displayed in a user interface. + + + + Since different servers can control different parts of the URL + namespace, different resources on the same host MAY have different + DAV:principal-collection-set values. The collections specified in + the DAV:principal-collection-set MAY be located on different hosts + from the resource. The URLs in DAV:principal-collection-set SHOULD be + http or https scheme URLs. For security and scalability reasons, a + server MAY report only a subset of the entire set of known principal + collections, and therefore clients should not assume they have + retrieved an exhaustive listing. Additionally, a server MAY elect to + report none of the principal collections it knows about, in which + case the property value would be empty. + + The value of DAV:principal-collection-set gives the scope of the + DAV:principal-property-search REPORT (defined in Section 9.4). + Clients use the DAV:principal-property-search REPORT to populate + their user interface with a list of principals. Therefore, servers + that limit a client's ability to obtain principal information will + interfere with the client's ability to manipulate access control + lists, due to the difficulty of getting the URL of a principal for + use in an ACE. + +5.8.1. Example: Retrieving DAV:principal-collection-set + + In this example, the client requests the value of the DAV:principal- + collection-set property on the collection resource identified by URL + http://www.example.com/papers/. The property contains the two URLs, + http://www.example.com/acl/users/ and http:// + www.example.com/acl/groups/, both wrapped in DAV:href XML elements. + Digest authentication provides credentials for the principal + operating the client. + + + + + + + + +Clemm, et al. Standards Track [Page 30] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The client might reasonably follow this request with two separate + PROPFIND requests to retrieve the DAV:displayname property of the + members of the two collections (/acl/users and /acl/groups). This + information could be used when displaying a user interface for + creating access control entries. + + >> Request << + + PROPFIND /papers/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="yarong", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/papers/ + + + + http://www.example.com/acl/users/ + http://www.example.com/acl/groups/ + + + HTTP/1.1 200 OK + + + + + + + + + +Clemm, et al. Standards Track [Page 31] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +5.9. Example: PROPFIND to retrieve access control properties + + The following example shows how access control information can be + retrieved by using the PROPFIND method to fetch the values of the + DAV:owner, DAV:supported-privilege-set, DAV:current-user-privilege- + set, and DAV:acl properties. + + >> Request << + + PROPFIND /top/container/ HTTP/1.1 + Host: www.example.com + Content-type: text/xml; charset="utf-8" + Content-Length: xxx + Depth: 0 + Authorization: Digest username="ejw", + realm="users@example.com", nonce="...", + uri="/top/container/", response="...", opaque="..." + + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + http://www.example.com/top/container/ + + + + http://www.example.com/users/gclemm + + + + + + + + +Clemm, et al. Standards Track [Page 32] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + Any operation + + + + + Read any object + + + + + + + Write any object + + + + + Create an object + + + + + + Update an object + + + + + + + Delete an object + + + + + + Read the ACL + + + + + + Write the ACL + + + + + + + +Clemm, et al. Standards Track [Page 33] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + http://www.example.com/users/esedlar + + + + + + + + + + http://www.example.com/groups/mrktng + + + + + + + + + + + + + + + + + + + + + http://www.example.com/top + + + + + HTTP/1.1 200 OK + + + + + + + +Clemm, et al. Standards Track [Page 34] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The value of the DAV:owner property is a single DAV:href XML element + containing the URL of the principal that owns this resource. + + The value of the DAV:supported-privilege-set property is a tree of + supported privileges (using "[XML Namespace , localname]" to identify + each privilege): + + [DAV:, all] (aggregate, abstract) + | + +-- [DAV:, read] + +-- [DAV:, write] (aggregate, abstract) + | + +-- [http://www.example.com/acl, create] + +-- [http://www.example.com/acl, update] + +-- [http://www.example.com/acl, delete] + +-- [DAV:, read-acl] + +-- [DAV:, write-acl] + + The DAV:current-user-privilege-set property contains two privileges, + DAV:read, and DAV:read-acl. This indicates that the current + authenticated user only has the ability to read the resource, and + read the DAV:acl property on the resource. The DAV:acl property + contains a set of four ACEs: + + ACE #1: The principal identified by the URL http://www.example.com/ + users/esedlar is granted the DAV:read, DAV:write, and DAV:read-acl + privileges. + + ACE #2: The principals identified by the URL http://www.example.com/ + groups/mrktng are denied the DAV:read privilege. In this example, + the principal URL identifies a group. + + ACE #3: In this ACE, the principal is a property principal, + specifically the DAV:owner property. When evaluating this ACE, the + value of the DAV:owner property is retrieved, and is examined to see + if it contains a DAV:href XML element. If so, the URL within the + DAV:href element is read, and identifies a principal. In this ACE, + the owner is granted DAV:read-acl, and DAV:write-acl privileges. + + ACE #4: This ACE grants the DAV:all principal (all users) the + DAV:read privilege. This ACE is inherited from the resource http:// + www.example.com/top, the parent collection of this resource. + + + + + + + + + +Clemm, et al. Standards Track [Page 35] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +6. ACL Evaluation + + WebDAV ACLs are evaluated in similar manner as ACLs on Windows NT and + in NFSv4 [RFC3530]). An ACL is evaluated to determine whether or not + access will be granted for a WebDAV request. ACEs are maintained in + a particular order, and are evaluated until all of the permissions + required by the current request have been granted, at which point the + ACL evaluation is terminated and access is granted. If, during ACL + evaluation, a ACE (matching the current user) is encountered + for a privilege which has not yet been granted, the ACL evaluation is + terminated and access is denied. Failure to have all required + privileges granted results in access being denied. + + Note that the semantics of many other existing ACL systems may be + represented via this mechanism, by mixing deny and grant ACEs. For + example, consider the standard "rwx" privilege scheme used by UNIX. + In this scheme, if the current user is the owner of the file, access + is granted if the corresponding privilege bit is set and denied if + not set, regardless of the permissions set on the file's group and + for the world. An ACL for UNIX permissions of "r--rw-r--" might be + constructed like: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 36] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + + + + + + + + + + and the would be defined as: + + + + + + + + + Note that the client can still get errors from a UNIX server in spite + of obeying the , including + (adding an ACE specifying a principal other than the ones in the ACL + above) or (by trying to reorder the ACEs in the + example above), as these particular implementation semantics are too + complex to be captured with the simple (but general) declarative + restrictions. + +7. Access Control and existing methods + + This section defines the impact of access control functionality on + existing methods. + +7.1. Any HTTP method + +7.1.1. Error Handling + + The WebDAV ACL mechanism requires the usage of HTTP method + "preconditions" as described in section 1.6 of RFC3253 for ALL HTTP + methods. All HTTP methods have an additional precondition called + DAV:need-privileges. If an HTTP method fails due to insufficient + privileges, the response body to the "403 Forbidden" error MUST + contain the element, which in turn contains the + + + +Clemm, et al. Standards Track [Page 37] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + element, which contains one or more + elements indicating which resource had insufficient + privileges, and what the lacking privileges were: + + + + + Since some methods require multiple permissions on multiple + resources, this information is needed to resolve any ambiguity. + There is no requirement that all privilege violations be reported - + for implementation reasons, some servers may only report the first + privilege violation. For example: + + >> Request << + + MOVE /a/b/ HTTP/1.1 + Host: www.example.com + Destination: http://www.example.com/c/d + + >> Response << + + HTTP/1.1 403 Forbidden + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + /a + + + + /c + + + + + +7.2. OPTIONS + + If the server supports access control, it MUST return "access- + control" as a field in the DAV response header from an OPTIONS + request on any resource implemented by that server. A value of + "access-control" in the DAV header MUST indicate that the server + supports all MUST level requirements and REQUIRED features specified + in this document. + + + + + +Clemm, et al. Standards Track [Page 38] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +7.2.1. Example - OPTIONS + + >> Request << + + OPTIONS /foo.html HTTP/1.1 + Host: www.example.com + Content-Length: 0 + + >> Response << + + HTTP/1.1 200 OK + DAV: 1, 2, access-control + Allow: OPTIONS, GET, PUT, PROPFIND, PROPPATCH, ACL + + In this example, the OPTIONS response indicates that the server + supports access control and that /foo.html can have its access + control list modified by the ACL method. + +7.3. MOVE + + When a resource is moved from one location to another due to a MOVE + request, the non-inherited and non-protected ACEs in the DAV:acl + property of the resource MUST NOT be modified, or the MOVE request + fails. Handling of inherited and protected ACEs is intentionally + undefined to give server implementations flexibility in how they + implement ACE inheritance and protection. + +7.4. COPY + + The DAV:acl property on the resource at the destination of a COPY + MUST be the same as if the resource was created by an individual + resource creation request (e.g., MKCOL, PUT). Clients wishing to + preserve the DAV:acl property across a copy need to read the DAV:acl + property prior to the COPY, then perform an ACL operation on the new + resource at the destination to restore, insofar as this is possible, + the original access control list. + +7.5. LOCK + + A lock on a resource ensures that only the lock owner can modify ACEs + that are not inherited and not protected (these are the only ACEs + that a client can modify with an ACL request). A lock does not + protect inherited or protected ACEs, since a client cannot modify + them with an ACL request on that resource. + + + + + + + +Clemm, et al. Standards Track [Page 39] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +8. Access Control Methods + +8.1. ACL + + The ACL method modifies the access control list (which can be read + via the DAV:acl property) of a resource. Specifically, the ACL + method only permits modification to ACEs that are not inherited, and + are not protected. An ACL method invocation modifies all non- + inherited and non-protected ACEs in a resource's access control list + to exactly match the ACEs contained within in the DAV:acl XML element + (specified in Section 5.5) of the request body. An ACL request body + MUST contain only one DAV:acl XML element. Unless the non-inherited + and non-protected ACEs of the DAV:acl property of the resource can be + updated to be exactly the value specified in the ACL request, the ACL + request MUST fail. + + It is possible that the ACEs visible to the current user in the + DAV:acl property may only be a portion of the complete set of ACEs on + that resource. If this is the case, an ACL request only modifies the + set of ACEs visible to the current user, and does not affect any + non-visible ACE. + + In order to avoid overwriting DAV:acl changes by another client, a + client SHOULD acquire a WebDAV lock on the resource before retrieving + the DAV:acl property of a resource that it intends on updating. + + Implementation Note: Two common operations are to add or remove an + ACE from an existing access control list. To accomplish this, a + client uses the PROPFIND method to retrieve the value of the + DAV:acl property, then parses the returned access control list to + remove all inherited and protected ACEs (these ACEs are tagged + with the DAV:inherited and DAV:protected XML elements). In the + remaining set of non-inherited, non-protected ACEs, the client can + add or remove one or more ACEs before submitting the final ACE set + in the request body of the ACL method. + +8.1.1. ACL Preconditions + + An implementation MUST enforce the following constraints on an ACL + request. If the constraint is violated, a 403 (Forbidden) or 409 + (Conflict) response MUST be returned and the indicated XML element + MUST be returned as a child of a top level DAV:error element in an + XML response body. + + Though these status elements are generally expressed as empty XML + elements (and are defined as EMPTY in the DTD), implementations MAY + return additional descriptive XML elements as children of the status + + + + +Clemm, et al. Standards Track [Page 40] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + element. Clients MUST be able to accept children of these status + elements. Clients that do not understand the additional XML elements + should ignore them. + + (DAV:no-ace-conflict): The ACEs submitted in the ACL request MUST NOT + conflict with each other. This is a catchall error code indicating + that an implementation-specific ACL restriction has been violated. + + (DAV:no-protected-ace-conflict): The ACEs submitted in the ACL + request MUST NOT conflict with the protected ACEs on the resource. + For example, if the resource has a protected ACE granting DAV:write + to a given principal, then it would not be consistent if the ACL + request submitted an ACE denying DAV:write to the same principal. + + (DAV:no-inherited-ace-conflict): The ACEs submitted in the ACL + request MUST NOT conflict with the inherited ACEs on the resource. + For example, if the resource inherits an ACE from its parent + collection granting DAV:write to a given principal, then it would not + be consistent if the ACL request submitted an ACE denying DAV:write + to the same principal. Note that reporting of this error will be + implementation-dependent. Implementations MUST either report this + error or allow the ACE to be set, and then let normal ACE evaluation + rules determine whether the new ACE has any impact on the privileges + available to a specific principal. + + (DAV:limited-number-of-aces): The number of ACEs submitted in the ACL + request MUST NOT exceed the number of ACEs allowed on that resource. + However, ACL-compliant servers MUST support at least one ACE granting + privileges to a single principal, and one ACE granting privileges to + a group. + + (DAV:deny-before-grant): All non-inherited deny ACEs MUST precede all + non-inherited grant ACEs. + + (DAV:grant-only): The ACEs submitted in the ACL request MUST NOT + include a deny ACE. This precondition applies only when the ACL + restrictions of the resource include the DAV:grant-only constraint + (defined in Section 5.6.1). + + (DAV:no-invert): The ACL request MUST NOT include a DAV:invert + element. This precondition applies only when the ACL semantics of + the resource includes the DAV:no-invert constraint (defined in + Section 5.6.2). + + (DAV:no-abstract): The ACL request MUST NOT attempt to grant or deny + an abstract privilege (see Section 5.3). + + + + + +Clemm, et al. Standards Track [Page 41] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + (DAV:not-supported-privilege): The ACEs submitted in the ACL request + MUST be supported by the resource. + + (DAV:missing-required-principal): The result of the ACL request MUST + have at least one ACE for each principal identified in a + DAV:required-principal XML element in the ACL semantics of that + resource (see Section 5.5). + + (DAV:recognized-principal): Every principal URL in the ACL request + MUST identify a principal resource. + + (DAV:allowed-principal): The principals specified in the ACEs + submitted in the ACL request MUST be allowed as principals for the + resource. For example, a server where only authenticated principals + can access resources would not allow the DAV:all or + DAV:unauthenticated principals to be used in an ACE, since these + would allow unauthenticated access to resources. + +8.1.2. Example: the ACL method + + In the following example, user "fielding", authenticated by + information in the Authorization header, grants the principal + identified by the URL http://www.example.com/users/esedlar (i.e., the + user "esedlar") read and write privileges, grants the owner of the + resource read-acl and write-acl privileges, and grants everyone read + privileges. + + >> Request << + + ACL /top/container/ HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="fielding", + realm="users@example.com", nonce="...", + uri="/top/container/", response="...", opaque="..." + + + + + + http://www.example.com/users/esedlar + + + + + + + + + +Clemm, et al. Standards Track [Page 42] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + + + + + + + + + + + >> Response << + + HTTP/1.1 200 OK + +8.1.3. Example: ACL method failure due to protected ACE conflict + + In the following request, user "fielding", authenticated by + information in the Authorization header, attempts to deny the + principal identified by the URL http://www.example.com/users/esedlar + (i.e., the user "esedlar") write privileges. Prior to the request, + the DAV:acl property on the resource contained a protected ACE (see + Section 5.5.3) granting DAV:owner the DAV:read and DAV:write + privileges. The principal identified by URL http://www.example.com/ + users/esedlar is the owner of the resource. The ACL method + invocation fails because the submitted ACE conflicts with the + protected ACE, thus violating the semantics of ACE protection. + + >> Request << + + ACL /top/container/ HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="fielding", + realm="users@example.com", nonce="...", + uri="/top/container/", response="...", opaque="..." + + + + + + + + +Clemm, et al. Standards Track [Page 43] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + http://www.example.com/users/esedlar + + + + + + + + >> Response << + + HTTP/1.1 403 Forbidden + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + + +8.1.4. Example: ACL method failure due to an inherited ACE conflict + + In the following request, user "ejw", authenticated by information in + the Authorization header, tries to change the access control list on + the resource http://www.example.com/top/index.html. This resource + has two inherited ACEs. + + Inherited ACE #1 grants the principal identified by URL http:// + www.example.com/users/ejw (i.e., the user "ejw") http:// + www.example.com/privs/write-all and DAV:read-acl privileges. On this + server, http://www.example.com/privs/write-all is an aggregate + privilege containing DAV:write, and DAV:write-acl. + + Inherited ACE #2 grants principal DAV:all the DAV:read privilege. + + The request attempts to set a (non-inherited) ACE, denying the + principal identified by the URL http://www.example.com/users/ejw + (i.e., the user "ejw") DAV:write permission. This conflicts with + inherited ACE #1. Note that the decision to report an inherited ACE + conflict is specific to this server implementation. Another server + implementation could have allowed the new ACE to be set, and then + used normal ACE evaluation rules to determine whether the new ACE has + any impact on the privileges available to a principal. + + + + + + + + + +Clemm, et al. Standards Track [Page 44] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + >> Request << + + ACL /top/index.html HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="ejw", + realm="users@example.com", nonce="...", + uri="/top/index.html", response="...", opaque="..." + + + + + + http://www.example.com/users/ejw + + + + + + >> Response << + + HTTP/1.1 403 Forbidden + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + + +8.1.5. Example: ACL method failure due to an attempt to set grant and + deny in a single ACE + + In this example, user "ygoland", authenticated by information in the + Authorization header, tries to change the access control list on the + resource http://www.example.com/diamond/engagement-ring.gif. The ACL + request includes a single, syntactically and semantically incorrect + ACE, which attempts to grant the group identified by the URL http:// + www.example.com/users/friends DAV:read privilege and deny the + principal identified by URL http://www.example.com/users/ygoland-so + (i.e., the user "ygoland-so") DAV:read privilege. However, it is + illegal to have multiple principal elements, as well as both a grant + and deny element in the same ACE, so the request fails due to poor + syntax. + + + + + + +Clemm, et al. Standards Track [Page 45] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + >> Request << + + ACL /diamond/engagement-ring.gif HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="ygoland", + realm="users@example.com", nonce="...", + uri="/diamond/engagement-ring.gif", response="...", + opaque="..." + + + + + + http://www.example.com/users/friends + + + + http://www.example.com/users/ygoland-so + + + + + + >> Response << + + HTTP/1.1 400 Bad Request + Content-Length: 0 + + Note that if the request had been divided into two ACEs, one to + grant, and one to deny, the request would have been syntactically + well formed. + +9. Access Control Reports + +9.1. REPORT Method + + The REPORT method (defined in Section 3.6 of [RFC3253]) provides an + extensible mechanism for obtaining information about a resource. + Unlike the PROPFIND method, which returns the value of one or more + named properties, the REPORT method can involve more complex + processing. REPORT is valuable in cases where the server has access + to all of the information needed to perform the complex request (such + as a query), and where it would require multiple requests for the + client to retrieve the information needed to perform the same + request. + + + + +Clemm, et al. Standards Track [Page 46] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + A server that supports the WebDAV Access Control Protocol MUST + support the DAV:expand-property report (defined in Section 3.8 of + [RFC3253]). + +9.2. DAV:acl-principal-prop-set Report + + The DAV:acl-principal-prop-set report returns, for all principals in + the DAV:acl property (of the Request-URI) that are identified by + http(s) URLs or by a DAV:property principal, the value of the + properties specified in the REPORT request body. In the case where a + principal URL appears multiple times, the DAV:acl-principal-prop-set + report MUST return the properties for that principal only once. + Support for this report is REQUIRED. + + One expected use of this report is to retrieve the human readable + name (found in the DAV:displayname property) of each principal found + in an ACL. This is useful for constructing user interfaces that show + each ACE in a human readable form. + + Marshalling + + The request body MUST be a DAV:acl-principal-prop-set XML element. + + + ANY value: a sequence of one or more elements, with at most one + DAV:prop element. + prop: see RFC 2518, Section 12.11 + + This report is only defined when the Depth header has value "0"; + other values result in a 400 (Bad Request) error response. Note + that [RFC3253], Section 3.6, states that if the Depth header is + not present, it defaults to a value of "0". + + The response body for a successful request MUST be a + DAV:multistatus XML element (i.e., the response uses the same + format as the response for PROPFIND). In the case where there are + no response elements, the returned multistatus XML element is + empty. + + multistatus: see RFC 2518, Section 12.9 + + The response body for a successful DAV:acl-principal-prop-set + REPORT request MUST contain a DAV:response element for each + principal identified by an http(s) URL listed in a DAV:principal + XML element of an ACE within the DAV:acl property of the resource + identified by the Request-URI. + + + + + +Clemm, et al. Standards Track [Page 47] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Postconditions: + + (DAV:number-of-matches-within-limits): The number of matching + principals must fall within server-specific, predefined limits. + For example, this condition might be triggered if a search + specification would cause the return of an extremely large number + of responses. + +9.2.1. Example: DAV:acl-principal-prop-set Report + + Resource http://www.example.com/index.html has an ACL with three + ACEs: + + ACE #1: All principals (DAV:all) have DAV:read and DAV:read-current- + user-privilege-set access. + + ACE #2: The principal identified by http://www.example.com/people/ + gstein (the user "gstein") is granted DAV:write, DAV:write-acl, + DAV:read-acl privileges. + + ACE #3: The group identified by http://www.example.com/groups/authors + (the "authors" group) is granted DAV:write and DAV:read-acl + privileges. + + The following example shows a DAV:acl-principal-prop-set report + requesting the DAV:displayname property. It returns the value of + DAV:displayname for resources http://www.example.com/people/gstein + and http://www.example.com/groups/authors , but not for DAV:all, + since this is not an http(s) URL. + + >> Request << + + REPORT /index.html HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + Depth: 0 + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 48] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/people/gstein + + + Greg Stein + + HTTP/1.1 200 OK + + + + http://www.example.com/groups/authors + + + Site authors + + HTTP/1.1 200 OK + + + + +9.3. DAV:principal-match REPORT + + The DAV:principal-match REPORT is used to identify all members (at + any depth) of the collection identified by the Request-URI that are + principals and that match the current user. In particular, if the + collection contains principals, the report can be used to identify + all members of the collection that match the current user. + Alternatively, if the collection contains resources that have a + property that identifies a principal (e.g., DAV:owner), the report + can be used to identify all members of the collection whose property + identifies a principal that matches the current user. For example, + this report can return all of the resources in a collection hierarchy + that are owned by the current user. Support for this report is + REQUIRED. + + Marshalling: + + The request body MUST be a DAV:principal-match XML element. + + + + + +Clemm, et al. Standards Track [Page 49] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + ANY value: an element whose value identifies a property. The + expectation is the value of the named property typically contains + an href element that contains the URI of a principal + + prop: see RFC 2518, Section 12.11 + + This report is only defined when the Depth header has value "0"; + other values result in a 400 (Bad Request) error response. Note + that [RFC3253], Section 3.6, states that if the Depth header is + not present, it defaults to a value of "0". The response body for + a successful request MUST be a DAV:multistatus XML element. In + the case where there are no response elements, the returned + multistatus XML element is empty. + + multistatus: see RFC 2518, Section 12.9 + + The response body for a successful DAV:principal-match REPORT + request MUST contain a DAV:response element for each member of the + collection that matches the current user. When the + DAV:principal-property element is used, a match occurs if the + current user is matched by the principal identified by the URI + found in the DAV:href element of the property identified by the + DAV:principal-property element. When the DAV:self element is used + in a DAV:principal-match report issued against a group, it matches + the group if a member identifies the same principal as the current + user. + + If DAV:prop is specified in the request body, the properties + specified in the DAV:prop element MUST be reported in the + DAV:response elements. + +9.3.1. Example: DAV:principal-match REPORT + + The following example identifies the members of the collection + identified by the URL http://www.example.com/doc that are owned by + the current user. The current user ("gclemm") is authenticated using + Digest authentication. + + >> Request << + + REPORT /doc/ HTTP/1.1 + Host: www.example.com + Authorization: Digest username="gclemm", + realm="users@example.com", nonce="...", + uri="/papers/", response="...", opaque="..." + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + Depth: 0 + + + +Clemm, et al. Standards Track [Page 50] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/doc/foo.html + HTTP/1.1 200 OK + + + http://www.example.com/doc/img/bar.gif + HTTP/1.1 200 OK + + + +9.4. DAV:principal-property-search REPORT + + The DAV:principal-property-search REPORT performs a search for all + principals whose properties contain character data that matches the + search criteria specified in the request. One expected use of this + report is to discover the URL of a principal associated with a given + person or group by searching for them by name. This is done by + searching over DAV:displayname, which is defined on all principals. + + The actual search method (exact matching vs. substring matching vs, + prefix-matching, case-sensitivity) deliberately is left to the server + implementation to allow implementation on a wide set of possible user + management systems. In cases where the implementation of + DAV:principal-property-search is not constrained by the semantics of + an underlying user management repository, preferred default semantics + are caseless substring matches. + + For implementation efficiency, servers do not typically support + searching on all properties. A search requesting properties that are + not searchable for a particular principal will not match that + principal. + + Support for the DAV:principal-property-search report is REQUIRED. + + + +Clemm, et al. Standards Track [Page 51] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Implementation Note: The value of a WebDAV property is a sequence + of well-formed XML, and hence can include any character in the + Unicode/ISO-10646 standard, that is, most known characters in + human languages. Due to the idiosyncrasies of case mapping across + human languages, implementation of case-insensitive matching is + non-trivial. Implementors of servers that do perform substring + matching are strongly encouraged to consult "The Unicode Standard" + [UNICODE4], especially Section 5.18, Subsection "Caseless + Matching", for guidance when implementing their case-insensitive + matching algorithms. + + Implementation Note: Some implementations of this protocol will + use an LDAP repository for storage of principal metadata. The + schema describing each attribute (akin to a WebDAV property) in an + LDAP repository specifies whether it supports case-sensitive or + caseless searching. One of the benefits of leaving the search + method to the discretion of the server implementation is the + default LDAP attribute search behavior can be used when + implementing the DAV:principal-property-search report. + + Marshalling: + + The request body MUST be a DAV:principal-property-search XML + element containing a search specification and an optional list of + properties. For every principal that matches the search + specification, the response will contain the value of the + requested properties on that principal. + + + + By default, the report searches all members (at any depth) of the + collection identified by the Request-URI. If DAV:apply-to- + principal-collection-set is specified in the request body, the + request is applied instead to each collection identified by the + DAV:principal-collection-set property of the resource identified + by the Request-URI. + + The DAV:property-search element contains a prop element + enumerating the properties to be searched and a match element, + containing the search string. + + + prop: see RFC 2518, Section 12.11 + + + + + + + +Clemm, et al. Standards Track [Page 52] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Multiple property-search elements or multiple elements within a + DAV:prop element will be interpreted with a logical AND. + + This report is only defined when the Depth header has value "0"; + other values result in a 400 (Bad Request) error response. Note + that [RFC3253], Section 3.6, states that if the Depth header is + not present, it defaults to a value of "0". + + The response body for a successful request MUST be a + DAV:multistatus XML element. In the case where there are no + response elements, the returned multistatus XML element is empty. + + multistatus: see RFC 2518, Section 12.9 + + The response body for a successful DAV:principal-property-search + REPORT request MUST contain a DAV:response element for each + principal whose property values satisfy the search specification + given in DAV:principal-property-search. + + If DAV:prop is specified in the request body, the properties + specified in the DAV:prop element MUST be reported in the + DAV:response elements. + + Preconditions: + + None + + Postconditions: + + (DAV:number-of-matches-within-limits): The number of matching + principals must fall within server-specific, predefined limits. + For example, this condition might be triggered if a search + specification would cause the return of an extremely large number + of responses. + +9.4.1. Matching + + There are several cases to consider when matching strings. The + easiest case is when a property value is "simple" and has only + character information item content (see [REC-XML-INFOSET]). For + example, the search string "julian" would match the DAV:displayname + property with value "Julian Reschke". Note that the on-the-wire + marshaling of DAV:displayname in this case is: + + Julian Reschke + + + + + + +Clemm, et al. Standards Track [Page 53] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The name of the property is encoded into the XML element information + item, and the character information item content of the property is + "Julian Reschke". + + A more complicated case occurs when properties have mixed content + (that is, compound values consisting of multiple child element items, + other types of information items, and character information item + content). Consider the property "aprop" in the namespace "http:// + www.example.com/props/", marshaled as: + + + {cdata 0}{cdata 1} + {cdata 2}{cdata 3} + + + In this case, matching is performed on each individual contiguous + sequence of character information items. In the example above, a + search string would be compared to the four following strings: + + {cdata 0} + {cdata 1} + {cdata 2} + {cdata 3} + + That is, four individual matches would be performed, one each for + {cdata 0}, {cdata 1}, {cdata 2}, and {cdata 3}. + +9.4.2. Example: successful DAV:principal-property-search REPORT + + In this example, the client requests the principal URLs of all users + whose DAV:displayname property contains the substring "doE" and whose + "title" property in the namespace "http://BigCorp.com/ns/" (that is, + their professional title) contains "Sales". In addition, the client + requests five properties to be returned with the matching principals: + + In the DAV: namespace: displayname + + In the http://www.example.com/ns/ namespace: department, phone, + office, salary + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 54] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The response shows that two principal resources meet the search + specification, "John Doe" and "Zygdoebert Smith". The property + "salary" in namespace "http://www.example.com/ns/" is not returned, + since the principal making the request does not have sufficient + access permissions to read this property. + + >> Request << + + REPORT /users/ HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset=utf-8 + Content-Length: xxxx + Depth: 0 + + + + + + + + doE + + + + + + Sales + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: text/xml; charset=utf-8 + Content-Length: xxxx + + + + + http://www.example.com/users/jdoe + + + + +Clemm, et al. Standards Track [Page 55] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + John Doe + Widget Sales + 234-4567 + 209 + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 403 Forbidden + + + + http://www.example.com/users/zsmith + + + Zygdoebert Smith + Gadget Sales + 234-7654 + 114 + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 403 Forbidden + + + + +9.5. DAV:principal-search-property-set REPORT + + The DAV:principal-search-property-set REPORT identifies those + properties that may be searched using the DAV:principal-property- + search REPORT (defined in Section 9.4). + + Servers MUST support the DAV:principal-search-property-set REPORT on + all collections identified in the value of a DAV:principal- + collection-set property. + + An access control protocol user agent could use the results of the + DAV:principal-search-property-set REPORT to present a query interface + to the user for retrieving principals. + + + +Clemm, et al. Standards Track [Page 56] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + Support for this report is REQUIRED. + + Implementation Note: Some clients will have only limited screen + real estate for the display of lists of searchable properties. In + this case, a user might appreciate having the most frequently + searched properties be displayed on-screen, rather than having to + scroll through a long list of searchable properties. One + mechanism for signaling the most frequently searched properties is + to return them towards the start of a list of properties. A + client can then preferentially display the list of properties in + order, increasing the likelihood that the most frequently searched + properties will appear on-screen, and will not require scrolling + for their selection. + + Marshalling: + + The request body MUST be an empty DAV:principal-search-property- + set XML element. + + This report is only defined when the Depth header has value "0"; + other values result in a 400 (Bad Request) error response. Note + that [RFC3253], Section 3.6, states that if the Depth header is + not present, it defaults to a value of "0". + + The response body MUST be a DAV:principal-search-property-set XML + element, containing a DAV:principal-search-property XML element + for each property that may be searched with the DAV:principal- + property-search REPORT. A server MAY limit its response to just a + subset of the searchable properties, such as those likely to be + useful to an interactive access control client. + + + + Each DAV:principal-search-property XML element contains exactly + one searchable property, and a description of the property. + + + + The DAV:prop element contains one principal property on which the + server is able to perform a DAV:principal-property-search REPORT. + + prop: see RFC 2518, Section 12.11 + + + + + + + + +Clemm, et al. Standards Track [Page 57] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + The description element is a human-readable description of what + information this property represents. Servers MUST indicate the + human language of the description using the xml:lang attribute and + SHOULD consider the HTTP Accept-Language request header when + selecting one of multiple available languages. + + + +9.5.1. Example: DAV:principal-search-property-set REPORT + + In this example, the client determines the set of searchable + principal properties by requesting the DAV:principal-search- + property-set REPORT on the root of the server's principal URL + collection set, identified by http://www.example.com/users/. + + >> Request << + + REPORT /users/ HTTP/1.1 + Host: www.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + Accept-Language: en, de + Authorization: BASIC d2FubmFtYWs6cGFzc3dvcmQ= + Depth: 0 + + + + + >> Response << + + HTTP/1.1 200 OK + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + + + + Full name + + + + + + + + + + +Clemm, et al. Standards Track [Page 58] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + Job title + + + +10. XML Processing + + Implementations of this specification MUST support the XML element + ignore rule, as specified in Section 23.3.2 of [RFC2518], and the XML + Namespace recommendation [REC-XML-NAMES]. + + Note that use of the DAV namespace is reserved for XML elements and + property names defined in a standards-track or Experimental IETF RFC. + +11. Internationalization Considerations + + In this specification, the only human-readable content can be found + in the description XML element, found within the DAV:supported- + privilege-set property. This element contains a human-readable + description of the capabilities controlled by a privilege. As a + result, the description element must be capable of representing + descriptions in multiple character sets. Since the description + element is found within a WebDAV property, it is represented on the + wire as XML [REC-XML], and hence can leverage XML's language tagging + and character set encoding capabilities. Specifically, XML + processors at minimum must be able to read XML elements encoded using + the UTF-8 [RFC3629] encoding of the ISO 10646 multilingual plane. + XML examples in this specification demonstrate use of the charset + parameter of the Content-Type header, as defined in [RFC3023], as + well as the XML "encoding" attribute, which together provide charset + identification information for MIME and XML processors. Furthermore, + this specification requires server implementations to tag description + fields with the xml:lang attribute (see Section 2.12 of [REC-XML]), + which specifies the human language of the description. Additionally, + server implementations should take into account the value of the + Accept-Language HTTP header to determine which description string to + return. + + For XML elements other than the description element, it is expected + that implementations will treat the property names, privilege names, + and values as tokens, and convert these tokens into human-readable + text in the user's language and character set when displayed to a + person. Only a generic WebDAV property display utility would display + these values in their raw form to a human user. + + For error reporting, we follow the convention of HTTP/1.1 status + codes, including with each status code a short, English description + of the code (e.g., 200 (OK)). While the possibility exists that a + + + +Clemm, et al. Standards Track [Page 59] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + poorly crafted user agent would display this message to a user, + internationalized applications will ignore this message, and display + an appropriate message in the user's language and character set. + + Further internationalization considerations for this protocol are + described in the WebDAV Distributed Authoring protocol specification + [RFC2518]. + +12. Security Considerations + + Applications and users of this access control protocol should be + aware of several security considerations, detailed below. In + addition to the discussion in this document, the security + considerations detailed in the HTTP/1.1 specification [RFC2616], the + WebDAV Distributed Authoring Protocol specification [RFC2518], and + the XML Media Types specification [RFC3023] should be considered in a + security analysis of this protocol. + +12.1. Increased Risk of Compromised Users + + In the absence of a mechanism for remotely manipulating access + control lists, if a single user's authentication credentials are + compromised, only those resources for which the user has access + permission can be read, modified, moved, or deleted. With the + introduction of this access control protocol, if a single compromised + user has the ability to change ACLs for a broad range of other users + (e.g., a super-user), the number of resources that could be altered + by a single compromised user increases. This risk can be mitigated + by limiting the number of people who have write-acl privileges across + a broad range of resources. + +12.2. Risks of the DAV:read-acl and DAV:current-user-privilege-set + Privileges + + The ability to read the access privileges (stored in the DAV:acl + property), or the privileges permitted the currently authenticated + user (stored in the DAV:current-user-privilege-set property) on a + resource may seem innocuous, since reading an ACL cannot possibly + affect the resource's state. However, if all resources have world- + readable ACLs, it is possible to perform an exhaustive search for + those resources that have inadvertently left themselves in a + vulnerable state, such as being world-writable. In particular, the + property retrieval method PROPFIND, executed with Depth infinity on + an entire hierarchy, is a very efficient way to retrieve the DAV:acl + or DAV:current-user-privilege-set properties. Once found, this + vulnerability can be exploited by a denial of service attack in which + the open resource is repeatedly overwritten. Alternately, writable + resources can be modified in undesirable ways. + + + +Clemm, et al. Standards Track [Page 60] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + To reduce this risk, read-acl privileges should not be granted to + unauthenticated principals, and restrictions on read-acl and read- + current-user-privilege-set privileges for authenticated principals + should be carefully analyzed when deploying this protocol. Access to + the current-user-privilege-set property will involve a tradeoff of + usability versus security. When the current-user-privilege-set is + visible, user interfaces are expected to provide enhanced information + concerning permitted and restricted operations, yet this information + may also indicate a vulnerability that could be exploited. + Deployment of this protocol will need to evaluate this tradeoff in + light of the requirements of the deployment environment. + +12.3. No Foreknowledge of Initial ACL + + In an effort to reduce protocol complexity, this protocol + specification intentionally does not address the issue of how to + manage or discover the initial ACL that is placed upon a resource + when it is created. The only way to discover the initial ACL is to + create a new resource, then retrieve the value of the DAV:acl + property. This assumes the principal creating the resource also has + been granted the DAV:read-acl privilege. + + As a result, it is possible that a principal could create a resource, + and then discover that its ACL grants privileges that are + undesirable. Furthermore, this protocol makes it possible (though + unlikely) that the creating principal could be unable to modify the + ACL, or even delete the resource. Even when the ACL can be modified, + there will be a short period of time when the resource exists with + the initial ACL before its new ACL can be set. + + Several factors mitigate this risk. Human principals are often aware + of the default access permissions in their editing environments and + take this into account when writing information. Furthermore, + default privilege policies are usually very conservative, limiting + the privileges granted by the initial ACL. + +13. Authentication + + Authentication mechanisms defined for use with HTTP and WebDAV also + apply to this WebDAV Access Control Protocol, in particular the Basic + and Digest authentication mechanisms defined in [RFC2617]. + Implementation of the ACL spec requires that Basic authentication, if + used, MUST only be supported over secure transport such as TLS. + + + + + + + + +Clemm, et al. Standards Track [Page 61] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +14. IANA Considerations + + This document uses the namespace defined by [RFC2518] for XML + elements. That is, this specification uses the "DAV:" URI namespace, + previously registered in the URI schemes registry. All other IANA + considerations mentioned in [RFC2518] are also applicable to this + specification. + +15. Acknowledgements + + This protocol is the collaborative product of the WebDAV ACL design + team: Bernard Chester, Geoff Clemm, Anne Hopkins, Barry Lind, Sean + Lyndersay, Eric Sedlar, Greg Stein, and Jim Whitehead. The authors + are grateful for the detailed review and comments provided by Jim + Amsden, Dylan Barrell, Gino Basso, Murthy Chintalapati, Lisa + Dusseault, Stefan Eissing, Tim Ellison, Yaron Goland, Dennis + Hamilton, Laurie Harper, Eckehard Hermann, Ron Jacobs, Chris Knight, + Remy Maucherat, Larry Masinter, Joe Orton, Peter Raymond, and Keith + Wannamaker. We thank Keith Wannamaker for the initial text of the + principal property search sections. Prior work on WebDAV access + control protocols has been performed by Yaron Goland, Paul Leach, + Lisa Dusseault, Howard Palmer, and Jon Radoff. We would like to + acknowledge the foundation laid for us by the authors of the DeltaV, + WebDAV and HTTP protocols upon which this protocol is layered, and + the invaluable feedback from the WebDAV working group. + +16. References + +16.1. Normative References + + [REC-XML] Bray, T., Paoli, J., Sperberg-McQueen, C. and E. + Maler, "Extensible Markup Language (XML) 1.0 + ((Third ed)", W3C REC REC-xml-20040204, February + 2004, . + + [REC-XML-INFOSET] Cowan, J. and R. Tobin, "XML Information Set + (Second Edition)", W3C REC REC-xml-infoset- + 20040204, February 2004, + . + + [REC-XML-NAMES] Bray, T., Hollander, D. and A. Layman, "Namespaces + in XML", W3C REC REC-xml-names-19990114, January + 1999, . + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + + +Clemm, et al. Standards Track [Page 62] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + [RFC2518] Goland, Y., Whitehead, E., Faizi, A., Carter, S. + and D. Jensen, "HTTP Extensions for Distributed + Authoring -- WEBDAV", RFC 2518, February 1999. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., + Masinter, L., Leach, P. and T. Berners-Lee, + "Hypertext Transfer Protocol -- HTTP/1.1", RFC + 2616, June 1999. + + [RFC2617] Franks, J., Hallam-Baker, P., Hostetler, J., + Lawrence, S., Leach, P., Luotonen, A. and L. + Stewart, "HTTP Authentication: Basic and Digest + Access Authentication", RFC 2617, June 1999. + + [RFC3023] Murata, M., St.Laurent, S. and D. Kohn, "XML Media + Types", RFC 3023, January 2001. + + [RFC3253] Clemm, G., Amsden, J., Ellison, T., Kaler, C. and + J. Whitehead, "Versioning Extensions to WebDAV", + RFC 3253, March 2002. + + [RFC3530] Shepler, S., Ed., Callaghan, B., Robinson, D., + Thurlow, R., Beame, C., Eisler, M. and D. Noveck, + "Network File System (NFS) version 4 Protocol", RFC + 3530, April 2003. + + [RFC3629] Yergeau, F., "UTF-8, a transformation format of ISO + 10646", STD 63, RFC 3629 November 2003. + +16.2. Informative References + + [RFC2251] Wahl, M., Howes, T. and S. Kille, "Lightweight + Directory Access Protocol (v3)", RFC 2251, December + 1997. + + [RFC2255] Howes, T. and M. Smith, "The LDAP URL Format", RFC + 2255, December 1997. + + [UNICODE4] The Unicode Consortium, "The Unicode Standard - + Version 4.0", Addison-Wesley , August 2003, + . + ISBN 0321185781. + + + + + + + + + +Clemm, et al. Standards Track [Page 63] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +Appendix A. WebDAV XML Document Type Definition Addendum + + All XML elements defined in this Document Type Definition (DTD) + belong to the DAV namespace. This DTD should be viewed as an addendum + to the DTD provided in [RFC2518], section 23.1. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 64] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 65] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + + + + + + + + + + + + + + + + + + + + + + + + ANY value: a sequence of one or more elements, with at most one + DAV:prop element. + + + + ANY value: an element whose value identifies a property. The + expectation is the value of the named property typically contains + an href element that contains the URI of a principal + + + + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 66] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +Appendix B. WebDAV Method Privilege Table (Normative) + + The following table of WebDAV methods (as defined in RFC 2518, 2616, + and 3253) clarifies which privileges are required for access for each + method. Note that the privileges listed, if denied, MUST cause + access to be denied. However, given that a specific implementation + MAY define an additional custom privilege to control access to + existing methods, having all of the indicated privileges does not + mean that access will be granted. Note that lack of the indicated + privileges does not imply that access will be denied, since a + particular implementation may use a sub-privilege aggregated under + the indicated privilege to control access. Privileges required refer + to the current resource being processed unless otherwise specified. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Clemm, et al. Standards Track [Page 67] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + +---------------------------------+---------------------------------+ + | METHOD | PRIVILEGES | + +---------------------------------+---------------------------------+ + | GET | | + | HEAD | | + | OPTIONS | | + | PUT (target exists) | on target | + | | resource | + | PUT (no target exists) | on parent collection | + | | of target | + | PROPPATCH | | + | ACL | | + | PROPFIND | (plus and | + | | as needed) | + | COPY (target exists) | , and | + | | on target | + | | resource | + | COPY (no target exists) | , on target | + | | collection | + | MOVE (no target exists) | on source collection | + | | and on target | + | | collection | + | MOVE (target exists) | As above, plus on | + | | the target collection | + | DELETE | on parent collection | + | LOCK (target exists) | | + | LOCK (no target exists) | on parent collection | + | MKCOL | on parent collection | + | UNLOCK | | + | CHECKOUT | | + | CHECKIN | | + | REPORT | (on all referenced | + | | resources) | + | VERSION-CONTROL | | + | MERGE | | + | MKWORKSPACE | on parent | + | | collection | + | BASELINE-CONTROL | and | + | | | + | MKACTIVITY | on parent | + | | collection | + +---------------------------------+---------------------------------+ + + + + + + + + +Clemm, et al. Standards Track [Page 68] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +Index + + A + ACL method 40 + + C + Condition Names + DAV:allowed-principal (pre) 42 + DAV:deny-before-grant (pre) 41 + DAV:grant-only (pre) 41 + DAV:limited-number-of-aces (pre) 41 + DAV:missing-required-principal (pre) 42 + DAV:no-abstract (pre) 41 + DAV:no-ace-conflict (pre) 41 + DAV:no-inherited-ace-conflict (pre) 41 + DAV:no-invert (pre) 41 + DAV:no-protected-ace-conflict (pre) 41 + DAV:not-supported-privilege (pre) 42 + DAV:number-of-matches-within-limits (post) 48, 53 + DAV:recognized-principal (pre) 42 + + D + DAV header + compliance class 'access-control' 38 + DAV:acl property 23 + DAV:acl-principal-prop-set report 48 + DAV:acl-restrictions property 27 + DAV:all privilege 13 + DAV:allowed-principal precondition 42 + DAV:alternate-URI-set property 14 + DAV:bind privilege 12 + DAV:current-user-privilege-set property 21 + DAV:deny-before-grant precondition 41 + DAV:grant-only precondition 41 + DAV:group property 18 + DAV:group-member-set property 14 + DAV:group-membership property 14 + DAV:inherited-acl-set property 29 + DAV:limited-number-of-aces precondition 41 + DAV:missing-required-principal precondition 42 + DAV:no-abstract precondition 41 + DAV:no-ace-conflict precondition 41 + DAV:no-inherited-ace-conflict precondition 41 + DAV:no-invert precondition 41 + DAV:no-protected-ace-conflict precondition 41 + DAV:not-supported-privilege precondition 42 + DAV:number-of-matches-within-limits postcondition 48, 53 + DAV:owner property 15 + + + +Clemm, et al. Standards Track [Page 69] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + DAV:principal resource type 13 + DAV:principal-collection-set property 30 + DAV:principal-match report 50 + DAV:principal-property-search 51 + DAV:principal-search-property-set 56 + DAV:principal-URL property 14 + DAV:read privilege 10 + DAV:read-acl privilege 11 + DAV:read-current-user-privilege-set privilege 12 + DAV:recognized-principal precondition 42 + DAV:supported-privilege-set property 18 + DAV:unbind privilege 12 + DAV:unlock privilege 11 + DAV:write privilege 10 + DAV:write-acl privilege 12 + DAV:write-content privilege 10 + DAV:write-properties privilege 10 + + M + Methods + ACL 40 + + P + Privileges + DAV:all 13 + DAV:bind 12 + DAV:read 10 + DAV:read-acl 11 + DAV:read-current-user-privilege-set 12 + DAV:unbind 12 + DAV:unlock 11 + DAV:write 10 + DAV:write-acl 12 + DAV:write-content 11 + DAV:write-properties 10 + Properties + DAV:acl 23 + DAV:acl-restrictions 27 + DAV:alternate-URI-set 14 + DAV:current-user-privilege-set 21 + DAV:group 18 + DAV:group-member-set 14 + DAV:group-membership 14 + DAV:inherited-acl-set 29 + DAV:owner 15 + DAV:principal-collection-set 30 + DAV:principal-URL 14 + DAV:supported-privilege-set 18 + + + +Clemm, et al. Standards Track [Page 70] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + + R + Reports + DAV:acl-principal-prop-set 47 + DAV:principal-match 49 + DAV:principal-property-search 51 + DAV:principal-search-property-set 56 + Resource Types + DAV:principal 13 + +Authors' Addresses + + Geoffrey Clemm + IBM + 20 Maguire Road + Lexington, MA 02421 + + EMail: geoffrey.clemm@us.ibm.com + + + Julian F. Reschke + greenbytes GmbH + Salzmannstrasse 152 + Muenster, NW 48159 + Germany + + EMail: julian.reschke@greenbytes.de + + + Eric Sedlar + Oracle Corporation + 500 Oracle Parkway + Redwood Shores, CA 94065 + + EMail: eric.sedlar@oracle.com + + + Jim Whitehead + U.C. Santa Cruz, Dept. of Computer Science + 1156 High Street + Santa Cruz, CA 95064 + + EMail: ejw@cse.ucsc.edu + + + + + + + + + +Clemm, et al. Standards Track [Page 71] + +RFC 3744 WebDAV Access Control Protocol May 2004 + + +Full Copyright Statement + + Copyright (C) The Internet Society (2004). This document is subject + to the rights, licenses and restrictions contained in BCP 78, and + except as set forth therein, the authors retain all their rights. + + This document and the information contained herein are provided on an + "AS IS" basis and THE CONTRIBUTOR, THE ORGANIZATION HE/SHE + REPRESENTS OR IS SPONSORED BY (IF ANY), THE INTERNET SOCIETY AND THE + INTERNET ENGINEERING TASK FORCE DISCLAIM ALL WARRANTIES, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF + THE INFORMATION HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED + WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +Intellectual Property + + The IETF takes no position regarding the validity or scope of any + Intellectual Property Rights or other rights that might be claimed + to pertain to the implementation or use of the technology + described in this document or the extent to which any license + under such rights might or might not be available; nor does it + represent that it has made any independent effort to identify any + such rights. Information on the procedures with respect to + rights in RFC documents can be found in BCP 78 and BCP 79. + + Copies of IPR disclosures made to the IETF Secretariat and any + assurances of licenses to be made available, or the result of an + attempt made to obtain a general license or permission for the use + of such proprietary rights by implementers or users of this + specification can be obtained from the IETF on-line IPR repository + at http://www.ietf.org/ipr. + + The IETF invites any interested party to bring to its attention + any copyrights, patents or patent applications, or other + proprietary rights that may cover technology that may be required + to implement this standard. Please address the information to the + IETF at ietf-ipr@ietf.org. + +Acknowledgement + + Funding for the RFC Editor function is currently provided by the + Internet Society. + + + + + + + + + +Clemm, et al. Standards Track [Page 72] + diff --git a/doc/rfc4791-caldav.txt b/doc/rfc4791-caldav.txt new file mode 100644 index 0000000..7a30bb2 --- /dev/null +++ b/doc/rfc4791-caldav.txt @@ -0,0 +1,5995 @@ + + + + + + +Network Working Group C. Daboo +Request for Comments: 4791 Apple +Category: Standards Track B. Desruisseaux + Oracle + L. Dusseault + CommerceNet + March 2007 + + + Calendaring Extensions to WebDAV (CalDAV) + +Status of This Memo + + This document specifies an Internet standards track protocol for the + Internet community, and requests discussion and suggestions for + improvements. Please refer to the current edition of the "Internet + Official Protocol Standards" (STD 1) for the standardization state + and status of this protocol. Distribution of this memo is unlimited. + +Copyright Notice + + Copyright (C) The IETF Trust (2007). + +Abstract + + This document defines extensions to the Web Distributed Authoring and + Versioning (WebDAV) protocol to specify a standard way of accessing, + managing, and sharing calendaring and scheduling information based on + the iCalendar format. This document defines the "calendar-access" + feature of CalDAV. + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 1] + +RFC 4791 CalDAV March 2007 + + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 5 + 1.1. Notational Conventions . . . . . . . . . . . . . . . . . . 5 + 1.2. XML Namespaces and Processing . . . . . . . . . . . . . . 5 + 1.3. Method Preconditions and Postconditions . . . . . . . . . 6 + 2. Requirements Overview . . . . . . . . . . . . . . . . . . . . 6 + 3. Calendaring Data Model . . . . . . . . . . . . . . . . . . . . 7 + 3.1. Calendar Server . . . . . . . . . . . . . . . . . . . . . 7 + 3.2. Recurrence and the Data Model . . . . . . . . . . . . . . 8 + 4. Calendar Resources . . . . . . . . . . . . . . . . . . . . . . 9 + 4.1. Calendar Object Resources . . . . . . . . . . . . . . . . 9 + 4.2. Calendar Collection . . . . . . . . . . . . . . . . . . . 10 + 5. Calendar Access Feature . . . . . . . . . . . . . . . . . . . 11 + 5.1. Calendar Access Support . . . . . . . . . . . . . . . . . 11 + 5.1.1. Example: Using OPTIONS for the Discovery of + Calendar Access Support . . . . . . . . . . . . . . . 12 + 5.2. Calendar Collection Properties . . . . . . . . . . . . . . 12 + 5.2.1. CALDAV:calendar-description Property . . . . . . . . . 12 + 5.2.2. CALDAV:calendar-timezone Property . . . . . . . . . . 13 + 5.2.3. CALDAV:supported-calendar-component-set Property . . . 14 + 5.2.4. CALDAV:supported-calendar-data Property . . . . . . . 15 + 5.2.5. CALDAV:max-resource-size Property . . . . . . . . . . 16 + 5.2.6. CALDAV:min-date-time Property . . . . . . . . . . . . 17 + 5.2.7. CALDAV:max-date-time Property . . . . . . . . . . . . 18 + 5.2.8. CALDAV:max-instances Property . . . . . . . . . . . . 19 + 5.2.9. CALDAV:max-attendees-per-instance Property . . . . . . 19 + 5.2.10. Additional Precondition for PROPPATCH . . . . . . . . 20 + 5.3. Creating Resources . . . . . . . . . . . . . . . . . . . . 20 + 5.3.1. MKCALENDAR Method . . . . . . . . . . . . . . . . . . 20 + 5.3.1.1. Status Codes . . . . . . . . . . . . . . . . . . . 22 + 5.3.1.2. Example: Successful MKCALENDAR Request . . . . . . 23 + 5.3.2. Creating Calendar Object Resources . . . . . . . . . . 25 + 5.3.2.1. Additional Preconditions for PUT, COPY, and + MOVE . . . . . . . . . . . . . . . . . . . . . . . 26 + 5.3.3. Non-Standard Components, Properties, and Parameters . 28 + 5.3.4. Calendar Object Resource Entity Tag . . . . . . . . . 28 + 6. Calendaring Access Control . . . . . . . . . . . . . . . . . . 29 + 6.1. Calendaring Privilege . . . . . . . . . . . . . . . . . . 29 + 6.1.1. CALDAV:read-free-busy Privilege . . . . . . . . . . . 29 + 6.2. Additional Principal Property . . . . . . . . . . . . . . 30 + 6.2.1. CALDAV:calendar-home-set Property . . . . . . . . . . 30 + 7. Calendaring Reports . . . . . . . . . . . . . . . . . . . . . 31 + 7.1. REPORT Method . . . . . . . . . . . . . . . . . . . . . . 31 + 7.2. Ordinary Collections . . . . . . . . . . . . . . . . . . . 31 + 7.3. Date and Floating Time . . . . . . . . . . . . . . . . . . 32 + 7.4. Time Range Filtering . . . . . . . . . . . . . . . . . . . 32 + 7.5. Searching Text: Collations . . . . . . . . . . . . . . . . 33 + + + +Daboo, et al. Standards Track [Page 2] + +RFC 4791 CalDAV March 2007 + + + 7.5.1. CALDAV:supported-collation-set Property . . . . . . . 34 + 7.6. Partial Retrieval . . . . . . . . . . . . . . . . . . . . 34 + 7.7. Non-Standard Components, Properties, and Parameters . . . 35 + 7.8. CALDAV:calendar-query REPORT . . . . . . . . . . . . . . . 36 + 7.8.1. Example: Partial Retrieval of Events by Time Range . . 38 + 7.8.2. Example: Partial Retrieval of Recurring Events . . . . 42 + 7.8.3. Example: Expanded Retrieval of Recurring Events . . . 45 + 7.8.4. Example: Partial Retrieval of Stored Free Busy + Components . . . . . . . . . . . . . . . . . . . . . . 48 + 7.8.5. Example: Retrieval of To-Dos by Alarm Time Range . . . 50 + 7.8.6. Example: Retrieval of Event by UID . . . . . . . . . . 51 + 7.8.7. Example: Retrieval of Events by PARTSTAT . . . . . . . 53 + 7.8.8. Example: Retrieval of Events Only . . . . . . . . . . 55 + 7.8.9. Example: Retrieval of All Pending To-Dos . . . . . . . 59 + 7.8.10. Example: Attempt to Query Unsupported Property . . . . 62 + 7.9. CALDAV:calendar-multiget REPORT . . . . . . . . . . . . . 63 + 7.9.1. Example: Successful CALDAV:calendar-multiget REPORT . 64 + 7.10. CALDAV:free-busy-query REPORT . . . . . . . . . . . . . . 66 + 7.10.1. Example: Successful CALDAV:free-busy-query REPORT . . 68 + 8. Guidelines . . . . . . . . . . . . . . . . . . . . . . . . . . 69 + 8.1. Client-to-Client Interoperability . . . . . . . . . . . . 69 + 8.2. Synchronization Operations . . . . . . . . . . . . . . . . 69 + 8.2.1. Use of Reports . . . . . . . . . . . . . . . . . . . . 69 + 8.2.1.1. Restrict the Time Range . . . . . . . . . . . . . 69 + 8.2.1.2. Synchronize by Time Range . . . . . . . . . . . . 70 + 8.2.1.3. Synchronization Process . . . . . . . . . . . . . 70 + 8.2.2. Restrict the Properties Returned . . . . . . . . . . . 72 + 8.3. Use of Locking . . . . . . . . . . . . . . . . . . . . . . 72 + 8.4. Finding Calendars . . . . . . . . . . . . . . . . . . . . 72 + 8.5. Storing and Using Attachments . . . . . . . . . . . . . . 74 + 8.5.1. Inline Attachments . . . . . . . . . . . . . . . . . . 74 + 8.5.2. External Attachments . . . . . . . . . . . . . . . . . 75 + 8.6. Storing and Using Alarms . . . . . . . . . . . . . . . . . 76 + 9. XML Element Definitions . . . . . . . . . . . . . . . . . . . 77 + 9.1. CALDAV:calendar XML Element . . . . . . . . . . . . . . . 77 + 9.2. CALDAV:mkcalendar XML Element . . . . . . . . . . . . . . 77 + 9.3. CALDAV:mkcalendar-response XML Element . . . . . . . . . . 78 + 9.4. CALDAV:supported-collation XML Element . . . . . . . . . . 78 + 9.5. CALDAV:calendar-query XML Element . . . . . . . . . . . . 78 + 9.6. CALDAV:calendar-data XML Element . . . . . . . . . . . . . 79 + 9.6.1. CALDAV:comp XML Element . . . . . . . . . . . . . . . 80 + 9.6.2. CALDAV:allcomp XML Element . . . . . . . . . . . . . . 81 + 9.6.3. CALDAV:allprop XML Element . . . . . . . . . . . . . . 81 + 9.6.4. CALDAV:prop XML Element . . . . . . . . . . . . . . . 82 + 9.6.5. CALDAV:expand XML Element . . . . . . . . . . . . . . 82 + 9.6.6. CALDAV:limit-recurrence-set XML Element . . . . . . . 83 + 9.6.7. CALDAV:limit-freebusy-set XML Element . . . . . . . . 84 + 9.7. CALDAV:filter XML Element . . . . . . . . . . . . . . . . 85 + + + +Daboo, et al. Standards Track [Page 3] + +RFC 4791 CalDAV March 2007 + + + 9.7.1. CALDAV:comp-filter XML Element . . . . . . . . . . . . 85 + 9.7.2. CALDAV:prop-filter XML Element . . . . . . . . . . . . 86 + 9.7.3. CALDAV:param-filter XML Element . . . . . . . . . . . 87 + 9.7.4. CALDAV:is-not-defined XML Element . . . . . . . . . . 88 + 9.7.5. CALDAV:text-match XML Element . . . . . . . . . . . . 88 + 9.8. CALDAV:timezone XML Element . . . . . . . . . . . . . . . 89 + 9.9. CALDAV:time-range XML Element . . . . . . . . . . . . . . 90 + 9.10. CALDAV:calendar-multiget XML Element . . . . . . . . . . . 94 + 9.11. CALDAV:free-busy-query XML Element . . . . . . . . . . . . 95 + 10. Internationalization Considerations . . . . . . . . . . . . . 95 + 11. Security Considerations . . . . . . . . . . . . . . . . . . . 95 + 12. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 96 + 12.1. Namespace Registration . . . . . . . . . . . . . . . . . . 96 + 13. Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . 96 + 14. References . . . . . . . . . . . . . . . . . . . . . . . . . . 97 + 14.1. Normative References . . . . . . . . . . . . . . . . . . . 97 + 14.2. Informative References . . . . . . . . . . . . . . . . . . 98 + Appendix A. CalDAV Method Privilege Table (Normative) . . . . . . 99 + Appendix B. Calendar Collections Used in the Examples . . . . . . 99 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 4] + +RFC 4791 CalDAV March 2007 + + +1. Introduction + + The concept of using HTTP [RFC2616] and WebDAV [RFC2518] as a basis + for a calendar access protocol is by no means a new concept: it was + discussed in the IETF CALSCH working group as early as 1997 or 1998. + Several companies have implemented calendar access protocols using + HTTP to upload and download iCalendar [RFC2445] objects, and using + WebDAV to get listings of resources. However, those implementations + do not interoperate because there are many small and big decisions to + be made in how to model calendaring data as WebDAV resources, as well + as how to implement required features that aren't already part of + WebDAV. This document proposes a way to model calendar data in + WebDAV, with additional features to make an interoperable calendar + access protocol. + +1.1. Notational Conventions + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + The term "protected" is used in the Conformance field of property + definitions as defined in Section 1.4.2 of [RFC3253]. + + When XML element types in the namespaces "DAV:" and + "urn:ietf:params:xml:ns:caldav" are referenced in this document + outside of the context of an XML fragment, the string "DAV:" and + "CALDAV:" will be prefixed to the element type names, respectively. + +1.2. XML Namespaces and Processing + + Definitions of XML elements in this document use XML element type + declarations (as found in XML Document Type Declarations), described + in Section 3.2 of [W3C.REC-xml-20060816]. + + The namespace "urn:ietf:params:xml:ns:caldav" is reserved for the XML + elements defined in this specification, its revisions, and related + CalDAV specifications. XML elements defined by individual + implementations MUST NOT use the "urn:ietf:params:xml:ns:caldav" + namespace, and instead should use a namespace that they control. + + The XML declarations used in this document do not include namespace + information. Thus, implementers must not use these declarations as + the only way to create valid CalDAV properties or to validate CalDAV + XML element types. Some of the declarations refer to XML elements + defined by WebDAV [RFC2518], which use the "DAV:" namespace. + Wherever such XML elements appear, they are explicitly prefixed with + "DAV:" to avoid confusion. + + + +Daboo, et al. Standards Track [Page 5] + +RFC 4791 CalDAV March 2007 + + + Also note that some CalDAV XML element names are identical to WebDAV + XML element names, though their namespace differs. Care must be + taken not to confuse the two sets of names. + + Processing of XML by CalDAV clients and servers MUST follow the rules + described in [RFC2518]; in particular, Section 14, and Appendix 3 of + that specification. + +1.3. Method Preconditions and Postconditions + + A "precondition" of a method describes the state of the server that + must be true for that method to be performed. A "postcondition" of a + method describes the state of the server that must be true after that + method has been completed. If a method precondition or postcondition + for a request is not satisfied, the response status of the request + MUST either be 403 (Forbidden), if the request should not be repeated + because it will always fail, or 409 (Conflict), if it is expected + that the user might be able to resolve the conflict and resubmit the + request. + + In order to allow better client handling of 403 and 409 responses, a + distinct XML element type is associated with each method precondition + and postcondition of a request. When a particular precondition is + not satisfied or a particular postcondition cannot be achieved, the + appropriate XML element MUST be returned as the child of a top-level + DAV:error element in the response body, unless otherwise negotiated + by the request. + +2. Requirements Overview + + This section lists what functionality is required of a CalDAV server. + To advertise support for CalDAV, a server: + + o MUST support iCalendar [RFC2445] as a media type for the calendar + object resource format; + + o MUST support WebDAV Class 1 [RFC2518] (note that [rfc2518bis] + describes clarifications to [RFC2518] that aid interoperability); + + o MUST support WebDAV ACL [RFC3744] with the additional privilege + defined in Section 6.1 of this document; + + o MUST support transport over TLS [RFC2246] as defined in [RFC2818] + (note that [RFC2246] has been obsoleted by [RFC4346]); + + o MUST support ETags [RFC2616] with additional requirements + specified in Section 5.3.4 of this document; + + + + +Daboo, et al. Standards Track [Page 6] + +RFC 4791 CalDAV March 2007 + + + o MUST support all calendaring reports defined in Section 7 of this + document; and + + o MUST advertise support on all calendar collections and calendar + object resources for the calendaring reports in the DAV:supported- + report-set property, as defined in Versioning Extensions to WebDAV + [RFC3253]. + + In addition, a server: + + o SHOULD support the MKCALENDAR method defined in Section 5.3.1 of + this document. + +3. Calendaring Data Model + + One of the features that has made WebDAV a successful protocol is its + firm data model. This makes it a useful framework for other + applications such as calendaring. This specification follows the + same pattern by developing all features based on a well-described + data model. + + As a brief overview, a CalDAV calendar is modeled as a WebDAV + collection with a defined structure; each calendar collection + contains a number of resources representing calendar objects as its + direct child resource. Each resource representing a calendar object + (event, to-do, journal entry, or other calendar components) is called + a "calendar object resource". Each calendar object resource and each + calendar collection can be individually locked and have individual + WebDAV properties. Requirements derived from this model are provided + in Section 4.1 and Section 4.2. + +3.1. Calendar Server + + A CalDAV server is a calendaring-aware engine combined with a WebDAV + repository. A WebDAV repository is a set of WebDAV collections, + containing other WebDAV resources, within a unified URL namespace. + For example, the repository "http://www.example.com/webdav/" may + contain WebDAV collections and resources, all of which have URLs + beginning with "http://www.example.com/webdav/". Note that the root + URL, "http://www.example.com/", may not itself be a WebDAV repository + (for example, if the WebDAV support is implemented through a servlet + or other Web server extension). + + A WebDAV repository MAY include calendar data in some parts of its + URL namespace, and non-calendaring data in other parts. + + A WebDAV repository can advertise itself as a CalDAV server if it + supports the functionality defined in this specification at any point + + + +Daboo, et al. Standards Track [Page 7] + +RFC 4791 CalDAV March 2007 + + + within the root of the repository. That might mean that calendaring + data is spread throughout the repository and mixed with non-calendar + data in nearby collections (e.g., calendar data may be found in + /home/lisa/calendars/ as well as in /home/bernard/calendars/, and + non-calendar data in /home/lisa/contacts/). Or, it might mean that + calendar data can be found only in certain sections of the repository + (e.g., /calendar/). Calendaring features are only required in the + repository sections that are or contain calendar object resources. + Therefore, a repository confining calendar data to the /calendar/ + collection would only need to support the CalDAV required features + within that collection. + + The CalDAV server or repository is the canonical location for + calendar data and state information. Clients may submit requests to + change data or download data. Clients may store calendar objects + offline and attempt to synchronize at a later time. However, clients + MUST be prepared for calendar data on the server to change between + the time of last synchronization and when attempting an update, as + calendar collections may be shared and accessible via multiple + clients. Entity tags and other features make this possible. + +3.2. Recurrence and the Data Model + + Recurrence is an important part of the data model because it governs + how many resources are expected to exist. This specification models + a recurring calendar component and its recurrence exceptions as a + single resource. In this model, recurrence rules, recurrence dates, + exception rules, and exception dates are all part of the data in a + single calendar object resource. This model avoids problems of + limiting how many recurrence instances to store in the repository, + how to keep recurrence instances in sync with the recurring calendar + component, and how to link recurrence exceptions with the recurring + calendar component. It also results in less data to synchronize + between client and server, and makes it easier to make changes to all + recurrence instances or to a recurrence rule. It makes it easier to + create a recurring calendar component and to delete all recurrence + instances. + + Clients are not forced to retrieve information about all recurrence + instances of a recurring component. The CALDAV:calendar-query and + CALDAV:calendar-multiget reports defined in this document allow + clients to retrieve only recurrence instances that overlap a given + time range. + + + + + + + + +Daboo, et al. Standards Track [Page 8] + +RFC 4791 CalDAV March 2007 + + +4. Calendar Resources + +4.1. Calendar Object Resources + + Calendar object resources contained in calendar collections MUST NOT + contain more than one type of calendar component (e.g., VEVENT, + VTODO, VJOURNAL, VFREEBUSY, etc.) with the exception of VTIMEZONE + components, which MUST be specified for each unique TZID parameter + value specified in the iCalendar object. For instance, a calendar + object resource can contain one VEVENT component and one VTIMEZONE + component, but it cannot contain one VEVENT component and one VTODO + component. Instead, the VEVENT and VTODO components would have to be + stored in separate calendar object resources in the same collection. + + Calendar object resources contained in calendar collections MUST NOT + specify the iCalendar METHOD property. + + The UID property value of the calendar components contained in a + calendar object resource MUST be unique in the scope of the calendar + collection in which they are stored. + + Calendar components in a calendar collection that have different UID + property values MUST be stored in separate calendar object resources. + + Calendar components with the same UID property value, in a given + calendar collection, MUST be contained in the same calendar object + resource. This ensures that all components in a recurrence "set" are + contained in the same calendar object resource. It is possible for a + calendar object resource to just contain components that represent + "overridden" instances (ones that modify the behavior of a regular + instance, and thus include a RECURRENCE-ID property) without also + including the "master" recurring component (the one that defines the + recurrence "set" and does not contain any RECURRENCE-ID property). + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 9] + +RFC 4791 CalDAV March 2007 + + + For example, given the following iCalendar object: + + BEGIN:VCALENDAR + PRODID:-//Example Corp.//CalDAV Client//EN + VERSION:2.0 + BEGIN:VEVENT + UID:1@example.com + SUMMARY:One-off Meeting + DTSTAMP:20041210T183904Z + DTSTART:20041207T120000Z + DTEND:20041207T130000Z + END:VEVENT + BEGIN:VEVENT + UID:2@example.com + SUMMARY:Weekly Meeting + DTSTAMP:20041210T183838Z + DTSTART:20041206T120000Z + DTEND:20041206T130000Z + RRULE:FREQ=WEEKLY + END:VEVENT + BEGIN:VEVENT + UID:2@example.com + SUMMARY:Weekly Meeting + RECURRENCE-ID:20041213T120000Z + DTSTAMP:20041210T183838Z + DTSTART:20041213T130000Z + DTEND:20041213T140000Z + END:VEVENT + END:VCALENDAR + + The VEVENT component with the UID value "1@example.com" would be + stored in its own calendar object resource. The two VEVENT + components with the UID value "2@example.com", which represent a + recurring event where one recurrence instance has been overridden, + would be stored in the same calendar object resource. + +4.2. Calendar Collection + + A calendar collection contains calendar object resources that + represent calendar components within a calendar. A calendar + collection is manifested to clients as a WebDAV resource collection + identified by a URL. A calendar collection MUST report the DAV: + collection and CALDAV:calendar XML elements in the value of the DAV: + resourcetype property. The element type declaration for CALDAV: + calendar is: + + + + + + +Daboo, et al. Standards Track [Page 10] + +RFC 4791 CalDAV March 2007 + + + A calendar collection can be created through provisioning (i.e., + automatically created when a user's account is provisioned), or it + can be created with the MKCALENDAR method (see Section 5.3.1). This + method can be useful for a user to create additional calendars (e.g., + soccer schedule) or for users to share a calendar (e.g., team events + or conference rooms). However, note that this document doesn't + define the purpose of extra calendar collections. Users must rely on + non-standard cues to find out what a calendar collection is for, or + use the CALDAV:calendar-description property defined in Section 5.2.1 + to provide such a cue. + + The following restrictions are applied to the resources within a + calendar collection: + + a. Calendar collections MUST only contain calendar object resources + and collections that are not calendar collections, i.e., the only + "top-level" non-collection resources allowed in a calendar + collection are calendar object resources. This ensures that + calendar clients do not have to deal with non-calendar data in a + calendar collection, though they do have to distinguish between + calendar object resources and collections when using standard + WebDAV techniques to examine the contents of a collection. + + b. Collections contained in calendar collections MUST NOT contain + calendar collections at any depth, i.e., "nesting" of calendar + collections within other calendar collections at any depth is not + allowed. This specification does not define how collections + contained in a calendar collection are used or how they relate to + any calendar object resources contained in the calendar + collection. + + Multiple calendar collections MAY be children of the same collection. + +5. Calendar Access Feature + +5.1. Calendar Access Support + + A server supporting the features described in this document MUST + include "calendar-access" as a field in the DAV response header from + an OPTIONS request on any resource that supports any calendar + properties, reports, method, or privilege. A value of "calendar- + access" in the DAV response header MUST indicate that the server + supports all MUST level requirements specified in this document. + + + + + + + + +Daboo, et al. Standards Track [Page 11] + +RFC 4791 CalDAV March 2007 + + +5.1.1. Example: Using OPTIONS for the Discovery of Calendar Access + Support + + >> Request << + + OPTIONS /home/bernard/calendars/ HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 200 OK + Allow: OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE + Allow: PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL + DAV: 1, 2, access-control, calendar-access + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Length: 0 + + In this example, the OPTIONS method returns the value "calendar- + access" in the DAV response header to indicate that the collection + "/home/bernard/calendars/" supports the properties, reports, method, + or privilege defined in this specification. + +5.2. Calendar Collection Properties + + This section defines properties for calendar collections. + +5.2.1. CALDAV:calendar-description Property + + Name: calendar-description + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Provides a human-readable description of the calendar + collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MAY be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). An xml:lang attribute indicating the human + language of the description SHOULD be set for this property by + clients or through server provisioning. Servers MUST return any + xml:lang attribute if set for the property. + + Description: If present, the property contains a description of the + calendar collection that is suitable for presentation to a user. + If not present, the client should assume no description for the + calendar collection. + + + + +Daboo, et al. Standards Track [Page 12] + +RFC 4791 CalDAV March 2007 + + + Definition: + + + PCDATA value: string + + Example: + + Calendrier de Mathilde Desruisseaux + +5.2.2. CALDAV:calendar-timezone Property + + Name: calendar-timezone + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a time zone on a calendar collection. + + Conformance: This property SHOULD be defined on all calendar + collections. If defined, it SHOULD NOT be returned by a PROPFIND + DAV:allprop request (as defined in Section 12.14.1 of [RFC2518]). + + Description: The CALDAV:calendar-timezone property is used to + specify the time zone the server should rely on to resolve "date" + values and "date with local time" values (i.e., floating time) to + "date with UTC time" values. The server will require this + information to determine if a calendar component scheduled with + "date" values or "date with local time" values overlaps a CALDAV: + time-range specified in a CALDAV:calendar-query REPORT. The + server will also require this information to compute the proper + FREEBUSY time period as "date with UTC time" in the VFREEBUSY + component returned in a response to a CALDAV:free-busy-query + REPORT request that takes into account calendar components + scheduled with "date" values or "date with local time" values. In + the absence of this property, the server MAY rely on the time zone + of their choice. + + Note: The iCalendar data embedded within the CALDAV:calendar- + timezone XML element MUST follow the standard XML character data + encoding rules, including use of <, >, & etc. entity + encoding or the use of a construct. In the + later case, the iCalendar data cannot contain the character + sequence "]]>", which is the end delimiter for the CDATA section. + + + + + + + +Daboo, et al. Standards Track [Page 13] + +RFC 4791 CalDAV March 2007 + + + Definition: + + + PCDATA value: an iCalendar object with exactly one VTIMEZONE + component. + + Example: + + BEGIN:VCALENDAR + PRODID:-//Example Corp.//CalDAV Client//EN + VERSION:2.0 + BEGIN:VTIMEZONE + TZID:US-Eastern + LAST-MODIFIED:19870101T000000Z + BEGIN:STANDARD + DTSTART:19671029T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + TZNAME:Eastern Standard Time (US & Canada) + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:19870405T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + TZNAME:Eastern Daylight Time (US & Canada) + END:DAYLIGHT + END:VTIMEZONE + END:VCALENDAR + + +5.2.3. CALDAV:supported-calendar-component-set Property + + Name: supported-calendar-component-set + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies the calendar component types (e.g., VEVENT, + VTODO, etc.) that calendar object resources can contain in the + calendar collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + + + +Daboo, et al. Standards Track [Page 14] + +RFC 4791 CalDAV March 2007 + + + Description: The CALDAV:supported-calendar-component-set property is + used to specify restrictions on the calendar component types that + calendar object resources may contain in a calendar collection. + Any attempt by the client to store calendar object resources with + component types not listed in this property, if it exists, MUST + result in an error, with the CALDAV:supported-calendar-component + precondition (Section 5.3.2.1) being violated. Since this + property is protected, it cannot be changed by clients using a + PROPPATCH request. However, clients can initialize the value of + this property when creating a new calendar collection with + MKCALENDAR. The empty-element tag MUST + only be specified if support for calendar object resources that + only contain VTIMEZONE components is provided or desired. Support + for VTIMEZONE components in calendar object resources that contain + VEVENT or VTODO components is always assumed. In the absence of + this property, the server MUST accept all component types, and the + client can assume that all component types are accepted. + + Definition: + + + + Example: + + + + + + +5.2.4. CALDAV:supported-calendar-data Property + + Name: supported-calendar-data + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies what media types are allowed for calendar object + resources in a calendar collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:supported-calendar-data property is used to + specify the media type supported for the calendar object resources + contained in a given calendar collection (e.g., iCalendar version + 2.0). Any attempt by the client to store calendar object + + + +Daboo, et al. Standards Track [Page 15] + +RFC 4791 CalDAV March 2007 + + + resources with a media type not listed in this property MUST + result in an error, with the CALDAV:supported-calendar-data + precondition (Section 5.3.2.1) being violated. In the absence of + this property, the server MUST only accept data with the media + type "text/calendar" and iCalendar version 2.0, and clients can + assume that the server will only accept this data. + + Definition: + + + + Example: + + + + + +5.2.5. CALDAV:max-resource-size Property + + Name: max-resource-size + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Provides a numeric value indicating the maximum size of a + resource in octets that the server is willing to accept when a + calendar object resource is stored in a calendar collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:max-resource-size is used to specify a + numeric value that represents the maximum size in octets that the + server is willing to accept when a calendar object resource is + stored in a calendar collection. Any attempt to store a calendar + object resource exceeding this size MUST result in an error, with + the CALDAV:max-resource-size precondition (Section 5.3.2.1) being + violated. In the absence of this property, the client can assume + that the server will allow storing a resource of any reasonable + size. + + Definition: + + + PCDATA value: a numeric value (positive integer) + + + + +Daboo, et al. Standards Track [Page 16] + +RFC 4791 CalDAV March 2007 + + + Example: + + 102400 + +5.2.6. CALDAV:min-date-time Property + + Name: min-date-time + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Provides a DATE-TIME value indicating the earliest date and + time (in UTC) that the server is willing to accept for any DATE or + DATE-TIME value in a calendar object resource stored in a calendar + collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:min-date-time is used to specify an + iCalendar DATE-TIME value in UTC that indicates the earliest + inclusive date that the server is willing to accept for any + explicit DATE or DATE-TIME value in a calendar object resource + stored in a calendar collection. Any attempt to store a calendar + object resource using a DATE or DATE-TIME value earlier than this + value MUST result in an error, with the CALDAV:min-date-time + precondition (Section 5.3.2.1) being violated. Note that servers + MUST accept recurring components that specify instances beyond + this limit, provided none of those instances have been overridden. + In that case, the server MAY simply ignore those instances outside + of the acceptable range when processing reports on the calendar + object resource. In the absence of this property, the client can + assume any valid iCalendar date may be used at least up to the + CALDAV:max-date-time value, if that is defined. + + Definition: + + + PCDATA value: an iCalendar format DATE-TIME value in UTC + + Example: + + 19000101T000000Z + + + + + +Daboo, et al. Standards Track [Page 17] + +RFC 4791 CalDAV March 2007 + + +5.2.7. CALDAV:max-date-time Property + + Name: max-date-time + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Provides a DATE-TIME value indicating the latest date and + time (in UTC) that the server is willing to accept for any DATE or + DATE-TIME value in a calendar object resource stored in a calendar + collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:max-date-time is used to specify an + iCalendar DATE-TIME value in UTC that indicates the inclusive + latest date that the server is willing to accept for any date or + time value in a calendar object resource stored in a calendar + collection. Any attempt to store a calendar object resource using + a DATE or DATE-TIME value later than this value MUST result in an + error, with the CALDAV:max-date-time precondition + (Section 5.3.2.1) being violated. Note that servers MUST accept + recurring components that specify instances beyond this limit, + provided none of those instances have been overridden. In that + case, the server MAY simply ignore those instances outside of the + acceptable range when processing reports on the calendar object + resource. In the absence of this property, the client can assume + any valid iCalendar date may be used at least down to the CALDAV: + min-date-time value, if that is defined. + + Definition: + + + PCDATA value: an iCalendar format DATE-TIME value in UTC + + Example: + + 20491231T235959Z + + + + + + + + + + +Daboo, et al. Standards Track [Page 18] + +RFC 4791 CalDAV March 2007 + + +5.2.8. CALDAV:max-instances Property + + Name: max-instances + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Provides a numeric value indicating the maximum number of + recurrence instances that a calendar object resource stored in a + calendar collection can generate. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:max-instances is used to specify a numeric + value that indicates the maximum number of recurrence instances + that a calendar object resource stored in a calendar collection + can generate. Any attempt to store a calendar object resource + with a recurrence pattern that generates more instances than this + value MUST result in an error, with the CALDAV:max-instances + precondition (Section 5.3.2.1) being violated. In the absence of + this property, the client can assume that the server has no limits + on the number of recurrence instances it can handle or expand. + + Definition: + + + PCDATA value: a numeric value (integer greater than zero) + + Example: + + 100 + +5.2.9. CALDAV:max-attendees-per-instance Property + + Name: max-attendees-per-instance + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Provides a numeric value indicating the maximum number of + ATTENDEE properties in any instance of a calendar object resource + stored in a calendar collection. + + Conformance: This property MAY be defined on any calendar + collection. If defined, it MUST be protected and SHOULD NOT be + + + + +Daboo, et al. Standards Track [Page 19] + +RFC 4791 CalDAV March 2007 + + + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:max-attendees-per-instance is used to + specify a numeric value that indicates the maximum number of + iCalendar ATTENDEE properties on any one instance of a calendar + object resource stored in a calendar collection. Any attempt to + store a calendar object resource with more ATTENDEE properties per + instance than this value MUST result in an error, with the CALDAV: + max-attendees-per-instance precondition (Section 5.3.2.1) being + violated. In the absence of this property, the client can assume + that the server can handle any number of ATTENDEE properties in a + calendar component. + + Definition: + + + PCDATA value: a numeric value (integer greater than zero) + + Example: + + 25 + +5.2.10. Additional Precondition for PROPPATCH + + This specification requires an additional Precondition for the + PROPPATCH method. The precondition is: + + (CALDAV:valid-calendar-data): The time zone specified in CALDAV: + calendar-timezone property MUST be a valid iCalendar object + containing a single valid VTIMEZONE component. + +5.3. Creating Resources + + Calendar collections and calendar object resources may be created by + either a CalDAV client or by the CalDAV server. This specification + defines restrictions and a data model that both clients and servers + MUST adhere to when manipulating such calendar data. + +5.3.1. MKCALENDAR Method + + An HTTP request using the MKCALENDAR method creates a new calendar + collection resource. A server MAY restrict calendar collection + creation to particular collections. + + + + + +Daboo, et al. Standards Track [Page 20] + +RFC 4791 CalDAV March 2007 + + + Support for MKCALENDAR on the server is only RECOMMENDED and not + REQUIRED because some calendar stores only support one calendar per + user (or principal), and those are typically pre-created for each + account. However, servers and clients are strongly encouraged to + support MKCALENDAR whenever possible to allow users to create + multiple calendar collections to help organize their data better. + + Clients SHOULD use the DAV:displayname property for a human-readable + name of the calendar. Clients can either specify the value of the + DAV:displayname property in the request body of the MKCALENDAR + request, or alternatively issue a PROPPATCH request to change the + DAV:displayname property to the appropriate value immediately after + issuing the MKCALENDAR request. Clients SHOULD NOT set the DAV: + displayname property to be the same as any other calendar collection + at the same URI "level". When displaying calendar collections to + users, clients SHOULD check the DAV:displayname property and use that + value as the name of the calendar. In the event that the DAV: + displayname property is empty, the client MAY use the last part of + the calendar collection URI as the name; however, that path segment + may be "opaque" and not represent any meaningful human-readable text. + + If a MKCALENDAR request fails, the server state preceding the request + MUST be restored. + + Marshalling: + If a request body is included, it MUST be a CALDAV:mkcalendar XML + element. Instruction processing MUST occur in the order + instructions are received (i.e., from top to bottom). + Instructions MUST either all be executed or none executed. Thus, + if any error occurs during processing, all executed instructions + MUST be undone and a proper error result returned. Instruction + processing details can be found in the definition of the DAV:set + instruction in Section 12.13.2 of [RFC2518]. + + + + If a response body for a successful request is included, it MUST + be a CALDAV:mkcalendar-response XML element. + + + + The response MUST include a Cache-Control:no-cache header. + + Preconditions: + + (DAV:resource-must-be-null): A resource MUST NOT exist at the + Request-URI; + + + + +Daboo, et al. Standards Track [Page 21] + +RFC 4791 CalDAV March 2007 + + + (CALDAV:calendar-collection-location-ok): The Request-URI MUST + identify a location where a calendar collection can be created; + + (CALDAV:valid-calendar-data): The time zone specified in the + CALDAV:calendar-timezone property MUST be a valid iCalendar object + containing a single valid VTIMEZONE component; + + (DAV:needs-privilege): The DAV:bind privilege MUST be granted to + the current user on the parent collection of the Request-URI. + + Postconditions: + + (CALDAV:initialize-calendar-collection): A new calendar collection + exists at the Request-URI. The DAV:resourcetype of the calendar + collection MUST contain both DAV:collection and CALDAV:calendar + XML elements. + +5.3.1.1. Status Codes + + The following are examples of response codes one would expect to get + in a response to a MKCALENDAR request. Note that this list is by no + means exhaustive. + + 201 (Created) - The calendar collection resource was created in + its entirety; + + 207 (Multi-Status) - The calendar collection resource was not + created since one or more DAV:set instructions specified in the + request body could not be processed successfully. The following + are examples of response codes one would expect to be used in a + 207 (Multi-Status) response in this situation: + + 403 (Forbidden) - The client, for reasons the server chooses + not to specify, cannot alter one of the properties; + + 409 (Conflict) - The client has provided a value whose + semantics are not appropriate for the property. This includes + trying to set read-only properties; + + 424 (Failed Dependency) - The DAV:set instruction on the + specified resource would have succeeded if it were not for the + failure of another DAV:set instruction specified in the request + body; + + 423 (Locked) - The specified resource is locked and the client + either is not a lock owner or the lock type requires a lock + token to be submitted and the client did not submit it; and + + + + +Daboo, et al. Standards Track [Page 22] + +RFC 4791 CalDAV March 2007 + + + 507 (Insufficient Storage) - The server did not have sufficient + space to record the property; + + 403 (Forbidden) - This indicates at least one of two conditions: + 1) the server does not allow the creation of calendar collections + at the given location in its namespace, or 2) the parent + collection of the Request-URI exists but cannot accept members; + + 409 (Conflict) - A collection cannot be made at the Request-URI + until one or more intermediate collections have been created; + + 415 (Unsupported Media Type) - The server does not support the + request type of the body; and + + 507 (Insufficient Storage) - The resource does not have sufficient + space to record the state of the resource after the execution of + this method. + +5.3.1.2. Example: Successful MKCALENDAR Request + + This example creates a calendar collection called /home/lisa/ + calendars/events/ on the server cal.example.com with specific values + for the properties DAV:displayname, CALDAV:calendar-description, + CALDAV:supported-calendar-component-set, and CALDAV:calendar- + timezone. + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 23] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + MKCALENDAR /home/lisa/calendars/events/ HTTP/1.1 + Host: cal.example.com + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + Lisa's Events + Calendar restricted to events. + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 24] + +RFC 4791 CalDAV March 2007 + + + >> Response << + + HTTP/1.1 201 Created + Cache-Control: no-cache + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Length: 0 + +5.3.2. Creating Calendar Object Resources + + Clients populate calendar collections with calendar object resources. + The URL for each calendar object resource is entirely arbitrary and + does not need to bear a specific relationship to the calendar object + resource's iCalendar properties or other metadata. New calendar + object resources MUST be created with a PUT request targeted at an + unmapped URI. A PUT request targeted at a mapped URI updates an + existing calendar object resource. + + When servers create new resources, it's not hard for the server to + choose an unmapped URI. It's slightly tougher for clients, because a + client might not want to examine all resources in the collection and + might not want to lock the entire collection to ensure that a new + resource isn't created with a name collision. However, there is an + HTTP feature to mitigate this. If the client intends to create a new + non-collection resource, such as a new VEVENT, the client SHOULD use + the HTTP request header "If-None-Match: *" on the PUT request. The + Request-URI on the PUT request MUST include the target collection, + where the resource is to be created, plus the name of the resource in + the last path segment. The "If-None-Match: *" request header ensures + that the client will not inadvertently overwrite an existing resource + if the last path segment turned out to already be used. + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 25] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + PUT /home/lisa/calendars/events/qwue23489.ics HTTP/1.1 + If-None-Match: * + Host: cal.example.com + Content-Type: text/calendar + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + UID:20010712T182145Z-123401@example.com + DTSTAMP:20060712T182145Z + DTSTART:20060714T170000Z + DTEND:20060715T040000Z + SUMMARY:Bastille Day Party + END:VEVENT + END:VCALENDAR + + >> Response << + + HTTP/1.1 201 Created + Content-Length: 0 + Date: Sat, 11 Nov 2006 09:32:12 GMT + ETag: "123456789-000-111" + + The request to change an existing event is the same, but with a + specific ETag in the "If-Match" header, rather than the "If-None- + Match" header. + + As indicated in Section 3.10 of [RFC2445], the URL of calendar object + resources containing (an arbitrary set of) calendaring and scheduling + information may be suffixed by ".ics", and the URL of calendar object + resources containing free or busy time information may be suffixed by + ".ifb". + +5.3.2.1. Additional Preconditions for PUT, COPY, and MOVE + + This specification creates additional Preconditions for PUT, COPY, + and MOVE methods. These preconditions apply when a PUT operation of + a calendar object resource into a calendar collection occurs, or when + a COPY or MOVE operation of a calendar object resource into a + calendar collection occurs, or when a COPY or MOVE operation occurs + on a calendar collection. + + + + + + +Daboo, et al. Standards Track [Page 26] + +RFC 4791 CalDAV March 2007 + + + The new preconditions are: + + (CALDAV:supported-calendar-data): The resource submitted in the + PUT request, or targeted by a COPY or MOVE request, MUST be a + supported media type (i.e., iCalendar) for calendar object + resources; + + (CALDAV:valid-calendar-data): The resource submitted in the PUT + request, or targeted by a COPY or MOVE request, MUST be valid data + for the media type being specified (i.e., MUST contain valid + iCalendar data); + + (CALDAV:valid-calendar-object-resource): The resource submitted in + the PUT request, or targeted by a COPY or MOVE request, MUST obey + all restrictions specified in Section 4.1 (e.g., calendar object + resources MUST NOT contain more than one type of calendar + component, calendar object resources MUST NOT specify the + iCalendar METHOD property, etc.); + + (CALDAV:supported-calendar-component): The resource submitted in + the PUT request, or targeted by a COPY or MOVE request, MUST + contain a type of calendar component that is supported in the + targeted calendar collection; + + (CALDAV:no-uid-conflict): The resource submitted in the PUT + request, or targeted by a COPY or MOVE request, MUST NOT specify + an iCalendar UID property value already in use in the targeted + calendar collection or overwrite an existing calendar object + resource with one that has a different UID property value. + Servers SHOULD report the URL of the resource that is already + making use of the same UID property value in the DAV:href element; + + + + (CALDAV:calendar-collection-location-ok): In a COPY or MOVE + request, when the Request-URI is a calendar collection, the + Destination-URI MUST identify a location where a calendar + collection can be created; + + (CALDAV:max-resource-size): The resource submitted in the PUT + request, or targeted by a COPY or MOVE request, MUST have an octet + size less than or equal to the value of the CALDAV:max-resource- + size property value (Section 5.2.5) on the calendar collection + where the resource will be stored; + + (CALDAV:min-date-time): The resource submitted in the PUT request, + or targeted by a COPY or MOVE request, MUST have all of its + iCalendar DATE or DATE-TIME property values (for each recurring + + + +Daboo, et al. Standards Track [Page 27] + +RFC 4791 CalDAV March 2007 + + + instance) greater than or equal to the value of the CALDAV:min- + date-time property value (Section 5.2.6) on the calendar + collection where the resource will be stored; + + (CALDAV:max-date-time): The resource submitted in the PUT request, + or targeted by a COPY or MOVE request, MUST have all of its + iCalendar DATE or DATE-TIME property values (for each recurring + instance) less than the value of the CALDAV:max-date-time property + value (Section 5.2.7) on the calendar collection where the + resource will be stored; + + (CALDAV:max-instances): The resource submitted in the PUT request, + or targeted by a COPY or MOVE request, MUST generate a number of + recurring instances less than or equal to the value of the CALDAV: + max-instances property value (Section 5.2.8) on the calendar + collection where the resource will be stored; + + (CALDAV:max-attendees-per-instance): The resource submitted in the + PUT request, or targeted by a COPY or MOVE request, MUST have a + number of ATTENDEE properties on any one instance less than or + equal to the value of the CALDAV:max-attendees-per-instance + property value (Section 5.2.9) on the calendar collection where + the resource will be stored; + +5.3.3. Non-Standard Components, Properties, and Parameters + + iCalendar provides a "standard mechanism for doing non-standard + things". This extension support allows implementers to make use of + non-standard components, properties, and parameters whose names are + prefixed with the text "X-". + + Servers MUST support the use of non-standard components, properties, + and parameters in calendar object resources stored via the PUT + method. + + Servers may need to enforce rules for their own "private" components, + properties, or parameters, so servers MAY reject any attempt by the + client to change those or use values for those outside of any + restrictions the server may have. Servers SHOULD ensure that any + "private" components, properties, or parameters it uses follow the + convention of including a vendor id in the "X-" name, as described in + Section 4.2 of [RFC2445], e.g., "X-ABC-PRIVATE". + +5.3.4. Calendar Object Resource Entity Tag + + The DAV:getetag property MUST be defined and set to a strong entity + tag on all calendar object resources. + + + + +Daboo, et al. Standards Track [Page 28] + +RFC 4791 CalDAV March 2007 + + + A response to a GET request targeted at a calendar object resource + MUST contain an ETag response header field indicating the current + value of the strong entity tag of the calendar object resource. + + Servers SHOULD return a strong entity tag (ETag header) in a PUT + response when the stored calendar object resource is equivalent by + octet equality to the calendar object resource submitted in the body + of the PUT request. This allows clients to reliably use the returned + strong entity tag for data synchronization purposes. For instance, + the client can do a PROPFIND request on the stored calendar object + resource and have the DAV:getetag property returned, and compare that + value with the strong entity tag it received on the PUT response, and + know that if they are equal, then the calendar object resource on the + server has not been changed. + + In the case where the data stored by a server as a result of a PUT + request is not equivalent by octet equality to the submitted calendar + object resource, the behavior of the ETag response header is not + specified here, with the exception that a strong entity tag MUST NOT + be returned in the response. As a result, clients may need to + retrieve the modified calendar object resource (and ETag) as a basis + for further changes, rather than use the calendar object resource it + had sent with the PUT request. + +6. Calendaring Access Control + +6.1. Calendaring Privilege + + CalDAV servers MUST support and adhere to the requirements of WebDAV + ACL [RFC3744]. WebDAV ACL provides a framework for an extensible set + of privileges that can be applied to WebDAV collections and ordinary + resources. CalDAV servers MUST also support the calendaring + privilege defined in this section. + +6.1.1. CALDAV:read-free-busy Privilege + + Calendar users often wish to allow other users to see their busy time + information, without viewing the other details of the calendar + components (e.g., location, summary, attendees). This allows a + significant amount of privacy while still allowing other users to + schedule meetings at times when the user is likely to be free. + + The CALDAV:read-free-busy privilege controls which calendar + collections, regular collections, and calendar object resources are + examined when a CALDAV:free-busy-query REPORT request is processed + (see Section 7.10). This privilege can be granted on calendar + collections, regular collections, or calendar object resources. + + + + +Daboo, et al. Standards Track [Page 29] + +RFC 4791 CalDAV March 2007 + + + Servers MUST support this privilege on all calendar collections, + regular collections, and calendar object resources. + + + + + The CALDAV:read-free-busy privilege MUST be aggregated in the DAV: + read privilege. Servers MUST allow the CALDAV:read-free-busy to be + granted without the DAV:read privilege being granted. + + Clients should note that when only the CALDAV:read-free-busy + privilege has been granted on a resource, access to GET, HEAD, + OPTIONS, and PROPFIND on the resource is not implied (those + operations are governed by the DAV:read privilege). + +6.2. Additional Principal Property + + This section defines an additional property for WebDAV principal + resources, as defined in [RFC3744]. + +6.2.1. CALDAV:calendar-home-set Property + + Name: calendar-home-set + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identifies the URL of any WebDAV collections that contain + calendar collections owned by the associated principal resource. + + Conformance: This property SHOULD be defined on a principal + resource. If defined, it MAY be protected and SHOULD NOT be + returned by a PROPFIND DAV:allprop request (as defined in Section + 12.14.1 of [RFC2518]). + + Description: The CALDAV:calendar-home-set property is meant to allow + users to easily find the calendar collections owned by the + principal. Typically, users will group all the calendar + collections that they own under a common collection. This + property specifies the URL of collections that are either calendar + collections or ordinary collections that have child or descendant + calendar collections owned by the principal. + + Definition: + + + + + + + + +Daboo, et al. Standards Track [Page 30] + +RFC 4791 CalDAV March 2007 + + + Example: + + + http://cal.example.com/home/bernard/calendars/ + + +7. Calendaring Reports + + This section defines the reports that CalDAV servers MUST support on + calendar collections and calendar object resources. + + CalDAV servers MUST advertise support for these reports on all + calendar collections and calendar object resources with the DAV: + supported-report-set property, defined in Section 3.1.5 of [RFC3253]. + CalDAV servers MAY also advertise support for these reports on + ordinary collections. + + Some of these reports allow calendar data (from possibly multiple + resources) to be returned. + +7.1. REPORT Method + + The REPORT method (defined in Section 3.6 of [RFC3253]) provides an + extensible mechanism for obtaining information about one or more + resources. Unlike the PROPFIND method, which returns the value of + one or more named properties, the REPORT method can involve more + complex processing. REPORT is valuable in cases where the server has + access to all of the information needed to perform the complex + request (such as a query), and where it would require multiple + requests for the client to retrieve the information needed to perform + the same request. + + CalDAV servers MUST support the DAV:expand-property REPORT defined in + Section 3.8 of [RFC3253]. + +7.2. Ordinary Collections + + Servers MAY support the reports defined in this document on ordinary + collections (collections that are not calendar collections), in + addition to calendar collections or calendar object resources. In + computing responses to the reports on ordinary collections, servers + MUST only consider calendar object resources contained in calendar + collections that are targeted by the REPORT request, based on the + value of the Depth request header. + + + + + + +Daboo, et al. Standards Track [Page 31] + +RFC 4791 CalDAV March 2007 + + +7.3. Date and Floating Time + + iCalendar provides a way to specify DATE and DATE-TIME values that + are not bound to any time zone in particular, hereafter called + "floating date" and "floating time", respectively. These values are + used to represent the same day, hour, minute, and second value, + regardless of which time zone is being observed. For instance, the + DATE value "20051111", represents November 11, 2005 in no specific + time zone, while the DATE-TIME value "20051111T111100" represents + November 11, 2005, at 11:11 A.M. in no specific time zone. + + CalDAV servers may need to convert "floating date" and "floating + time" values in date with UTC time values in the processing of + calendaring REPORT requests. + + For the CALDAV:calendar-query REPORT, CalDAV servers MUST rely on the + value of the CALDAV:timezone XML element, if specified as part of the + request body, to perform the proper conversion of "floating date" and + "floating time" values to date with UTC time values. If the CALDAV: + timezone XML element is not specified in the request body, CalDAV + servers MUST rely on the value of the CALDAV:calendar-timezone + property, if defined, or else the CalDAV servers MAY rely on the time + zone of their choice. + + For the CALDAV:free-busy-query REPORT, CalDAV servers MUST rely on + the value of the CALDAV:calendar-timezone property, if defined, to + compute the proper FREEBUSY time period value as date with UTC time + for calendar components scheduled with "floating date" or "floating + time". If the CALDAV:calendar-timezone property is not defined, + CalDAV servers MAY rely on the time zone of their choice. + +7.4. Time Range Filtering + + Some of the reports defined in this section can include a time range + filter that is used to restrict the set of calendar object resources + returned to just those that overlap the specified time range. The + time range filter can be applied to a calendar component as a whole, + or to specific calendar component properties with DATE or DATE-TIME + value types. + + To determine whether a calendar object resource matches the time + range filter element, the start and end times for the targeted + component or property are determined and then compared to the + requested time range. If there is an overlap with the requested time + range, then the calendar object resource matches the filter element. + The rules defined in [RFC2445] for determining the actual start and + end times of calendar components MUST be used, and these are fully + enumerated in Section 9.9 of this document. + + + +Daboo, et al. Standards Track [Page 32] + +RFC 4791 CalDAV March 2007 + + + When such time range filtering is used, special consideration must be + given to recurring calendar components, such as VEVENT and VTODO. + The server MUST expand recurring components to determine whether any + recurrence instances overlap the specified time range. If one or + more recurrence instances overlap the time range, then the calendar + object resource matches the filter element. + +7.5. Searching Text: Collations + + Some of the reports defined in this section do text matches of + character strings provided by the client and are compared to stored + calendar data. Since iCalendar data is, by default, encoded in the + UTF-8 charset and may include characters outside the US-ASCII charset + range in some property and parameter values, there is a need to + ensure that text matching follows well-defined rules. + + To deal with this, this specification makes use of the IANA Collation + Registry defined in [RFC4790] to specify collations that may be used + to carry out the text comparison operations with a well-defined rule. + + The comparisons used in CalDAV are all "substring" matches, as per + [RFC4790], Section 4.2. Collations supported by the server MUST + support "substring" match operations. + + CalDAV servers are REQUIRED to support the "i;ascii-casemap" and + "i;octet" collations, as described in [RFC4790], and MAY support + other collations. + + Servers MUST advertise the set of collations that they support via + the CALDAV:supported-collation-set property defined on any resource + that supports reports that use collations. + + Clients MUST only use collations from the list advertised by the + server. + + In the absence of a collation explicitly specified by the client, or + if the client specifies the "default" collation identifier (as + defined in [RFC4790], Section 3.1), the server MUST default to using + "i;ascii-casemap" as the collation. + + Wildcards (as defined in [RFC4790], Section 3.2) MUST NOT be used in + the collation identifier. + + If the client chooses a collation not supported by the server, the + server MUST respond with a CALDAV:supported-collation precondition + error response. + + + + + +Daboo, et al. Standards Track [Page 33] + +RFC 4791 CalDAV March 2007 + + +7.5.1. CALDAV:supported-collation-set Property + + Name: supported-collation-set + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identifies the set of collations supported by the server + for text matching operations. + + Conformance: This property MUST be defined on any resource that + supports a report that does text matching. If defined, it MUST be + protected and SHOULD NOT be returned by a PROPFIND DAV:allprop + request (as defined in Section 12.14.1 of [RFC2518]). + + Description: The CALDAV:supported-collation-set property contains + zero or more CALDAV:supported-collation elements, which specify + the collection identifiers of the collations supported by the + server. + + Definition: + + + + + + Example: + + + i;ascii-casemap + i;octet + + +7.6. Partial Retrieval + + Some calendaring reports defined in this document allow partial + retrieval of calendar object resources. A CalDAV client can specify + what information to return in the body of a calendaring REPORT + request. + + A CalDAV client can request particular WebDAV property values, all + WebDAV property values, or a list of the names of the resource's + WebDAV properties. A CalDAV client can also request calendar data to + be returned and specify whether all calendar components and + properties should be returned, or only particular ones. See CALDAV: + calendar-data in Section 9.6. + + + + + +Daboo, et al. Standards Track [Page 34] + +RFC 4791 CalDAV March 2007 + + + By default, the returned calendar data will include the component + that defines the recurrence set, referred to as the "master + component", as well as the components that define exceptions to the + recurrence set, referred to as the "overridden components". + + A CalDAV client that is only interested in the recurrence instances + that overlap a specified time range can request to receive only the + "master component", along with the "overridden components" that + impact the specified time range, and thus, limit the data returned by + the server (see CALDAV:limit-recurrence-set in Section 9.6.6). An + overridden component impacts a time range if its current start and + end times overlap the time range, or if the original start and end + times -- the ones that would have been used if the instance were not + overridden -- overlap the time range, or if it affects other + instances that overlap the time range. + + A CalDAV client with no support for recurrence properties (i.e., + EXDATE, EXRULE, RDATE, and RRULE) and possibly VTIMEZONE components, + or a client unwilling to perform recurrence expansion because of + limited processing capability, can request to receive only the + recurrence instances that overlap a specified time range as separate + calendar components that each define exactly one recurrence instance + (see CALDAV:expand in Section 9.6.5.) + + Finally, in the case of VFREEBUSY components, a CalDAV client can + request to receive only the FREEBUSY property values that overlap a + specified time range (see CALDAV:limit-freebusy-set in + Section 9.6.7.) + +7.7. Non-Standard Components, Properties, and Parameters + + Servers MUST support the use of non-standard component, property, or + parameter names in the CALDAV:calendar-data XML element in + calendaring REPORT requests to allow clients to request that non- + standard components, properties, and parameters be returned in the + calendar data provided in the response. + + Servers MAY support the use of non-standard component, property, or + parameter names in the CALDAV:comp-filter, CALDAV:prop-filter, and + CALDAV:param-filter XML elements specified in the CALDAV:filter XML + element of calendaring REPORT requests. + + Servers MUST fail with the CALDAV:supported-filter precondition if a + calendaring REPORT request uses a CALDAV:comp-filter, CALDAV:prop- + filter, or CALDAV:param-filter XML element that makes reference to a + non-standard component, property, or parameter name on which the + server does not support queries. + + + + +Daboo, et al. Standards Track [Page 35] + +RFC 4791 CalDAV March 2007 + + +7.8. CALDAV:calendar-query REPORT + + The CALDAV:calendar-query REPORT performs a search for all calendar + object resources that match a specified filter. The response of this + report will contain all the WebDAV properties and calendar object + resource data specified in the request. In the case of the CALDAV: + calendar-data XML element, one can explicitly specify the calendar + components and properties that should be returned in the calendar + object resource data that matches the filter. + + The format of this report is modeled on the PROPFIND method. The + request and response bodies of the CALDAV:calendar-query REPORT use + XML elements that are also used by PROPFIND. In particular, the + request can include XML elements to request WebDAV properties to be + returned. When that occurs, the response should follow the same + behavior as PROPFIND with respect to the DAV:multistatus response + elements used to return specific property results. For instance, a + request to retrieve the value of a property that does not exist is an + error and MUST be noted with a response XML element that contains a + 404 (Not Found) status value. + + Support for the CALDAV:calendar-query REPORT is REQUIRED. + + Marshalling: + + The request body MUST be a CALDAV:calendar-query XML element, as + defined in Section 9.5. + + The request MAY include a Depth header. If no Depth header is + included, Depth:0 is assumed. + + The response body for a successful request MUST be a DAV: + multistatus XML element (i.e., the response uses the same format + as the response for PROPFIND). In the case where there are no + response elements, the returned DAV:multistatus XML element is + empty. + + The response body for a successful CALDAV:calendar-query REPORT + request MUST contain a DAV:response element for each iCalendar + object that matched the search filter. Calendar data is being + returned in the CALDAV:calendar-data XML element inside the DAV: + propstat XML element. + + Preconditions: + + (CALDAV:supported-calendar-data): The attributes "content-type" + and "version" of the CALDAV:calendar-data XML element (see + + + + +Daboo, et al. Standards Track [Page 36] + +RFC 4791 CalDAV March 2007 + + + Section 9.6) specify a media type supported by the server for + calendar object resources. + + (CALDAV:valid-filter): The CALDAV:filter XML element (see + Section 9.7) specified in the REPORT request MUST be valid. For + instance, a CALDAV:filter cannot nest a + element in a element, and a CALDAV:filter + cannot nest a element in a + element. + + (CALDAV:supported-filter): The CALDAV:comp-filter (see + Section 9.7.1), CALDAV:prop-filter (see Section 9.7.2), and + CALDAV:param-filter (see Section 9.7.3) XML elements used in the + CALDAV:filter XML element (see Section 9.7) in the REPORT request + only make reference to components, properties, and parameters for + which queries are supported by the server, i.e., if the CALDAV: + filter element attempts to reference an unsupported component, + property, or parameter, this precondition is violated. Servers + SHOULD report the CALDAV:comp-filter, CALDAV:prop-filter, or + CALDAV:param-filter for which it does not provide support. + + + + (CALDAV:valid-calendar-data): The time zone specified in the + REPORT request MUST be a valid iCalendar object containing a + single valid VTIMEZONE component. + + (CALDAV:min-date-time): Any XML element specifying a range of time + MUST have its start or end DATE or DATE-TIME values greater than + or equal to the value of the CALDAV:min-date-time property value + (Section 5.2.6) on the calendar collections being targeted by the + REPORT request; + + (CALDAV:max-date-time): Any XML element specifying a range of time + MUST have its start or end DATE or DATE-TIME values less than or + equal to the value of the CALDAV:max-date-time property value + (Section 5.2.7) on the calendar collections being targeted by the + REPORT request; + + (CALDAV:supported-collation): Any XML attribute specifying a + collation MUST specify a collation supported by the server as + described in Section 7.5. + + + + + + + +Daboo, et al. Standards Track [Page 37] + +RFC 4791 CalDAV March 2007 + + + Postconditions: + + (DAV:number-of-matches-within-limits): The number of matching + calendar object resources must fall within server-specific, + predefined limits. For example, this condition might be triggered + if a search specification would cause the return of an extremely + large number of responses. + +7.8.1. Example: Partial Retrieval of Events by Time Range + + In this example, the client requests the server to return specific + components and properties of the VEVENT components that overlap the + time range from January 4, 2006, at 00:00:00 A.M. UTC to January 5, + 2006, at 00:00:00 A.M. UTC. In addition, the DAV:getetag property is + also requested and returned as part of the response. Note that the + first calendar object returned is a recurring event whose first + instance lies outside the requested time range, but whose third + instance does overlap the time range. Note that due to the CALDAV: + calendar-data element restrictions, the DTSTAMP property in VEVENT + components has not been returned, and the only property returned in + the VCALENDAR object is VERSION. + + See Appendix B for the calendar data being targeted by this example. + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 38] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + +Daboo, et al. Standards Track [Page 39] + +RFC 4791 CalDAV March 2007 + + + + + + http://cal.example.com/bernard/work/abcd2.ics + + + "fffff-abcd2" + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTART;TZID=US/Eastern:20060102T120000 + DURATION:PT1H + RRULE:FREQ=DAILY;COUNT=5 + SUMMARY:Event #2 + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTART;TZID=US/Eastern:20060104T140000 + DURATION:PT1H + RECURRENCE-ID;TZID=US/Eastern:20060104T120000 + SUMMARY:Event #2 bis + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTART;TZID=US/Eastern:20060106T140000 + DURATION:PT1H + RECURRENCE-ID;TZID=US/Eastern:20060106T120000 + SUMMARY:Event #2 bis bis + UID:00959BC664CA650E933C892C@example.com + + + +Daboo, et al. Standards Track [Page 40] + +RFC 4791 CalDAV March 2007 + + + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTART;TZID=US/Eastern:20060104T100000 + DURATION:PT1H + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + + + +Daboo, et al. Standards Track [Page 41] + +RFC 4791 CalDAV March 2007 + + +7.8.2. Example: Partial Retrieval of Recurring Events + + In this example, the client requests the server to return VEVENT + components that overlap the time range from January 3, 2006, at 00: + 00:00 A.M. UTC to January 5, 2006, at 00:00:00 A.M. UTC. Use of the + CALDAV:limit-recurrence-set element causes the server to only return + overridden recurrence components that overlap the time range + specified in that element or that affect other instances that overlap + the time range (e.g., in the case of a THISANDFUTURE behavior). In + this example, the first overridden component in the matching resource + is returned, but the second one is not. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + +Daboo, et al. Standards Track [Page 42] + +RFC 4791 CalDAV March 2007 + + + + + + http://cal.example.com/bernard/work/abcd2.ics + + + "fffff-abcd2" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060102T120000 + DURATION:PT1H + RRULE:FREQ=DAILY;COUNT=5 + SUMMARY:Event #2 + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060104T140000 + DURATION:PT1H + RECURRENCE-ID;TZID=US/Eastern:20060104T120000 + SUMMARY:Event #2 bis + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + END:VCALENDAR + + + + + +Daboo, et al. Standards Track [Page 43] + +RFC 4791 CalDAV March 2007 + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com + DTSTAMP:20060206T001220Z + DTSTART;TZID=US/Eastern:20060104T100000 + DURATION:PT1H + LAST-MODIFIED:20060206T001330Z + ORGANIZER:mailto:cyrus@example.com + SEQUENCE:1 + STATUS:TENTATIVE + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +Daboo, et al. Standards Track [Page 44] + +RFC 4791 CalDAV March 2007 + + + + + +7.8.3. Example: Expanded Retrieval of Recurring Events + + In this example, the client requests the server to return VEVENT + components that overlap the time range from January 2, 2006, at 00: + 00:00 A.M. UTC to January 5, 2006, at 00:00:00 A.M. UTC and to return + recurring calendar components expanded into individual recurrence + instance calendar components. Use of the CALDAV:expand element + causes the server to only return overridden recurrence instances that + overlap the time range specified in that element. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + +Daboo, et al. Standards Track [Page 45] + +RFC 4791 CalDAV March 2007 + + + + + + http://cal.example.com/bernard/work/abcd2.ics + + + "fffff-abcd2" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART:20060103T170000 + DURATION:PT1H + RECURRENCE-ID:20060103T170000 + SUMMARY:Event #2 + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART:20060104T190000 + DURATION:PT1H + RECURRENCE-ID:20060104T170000 + SUMMARY:Event #2 bis + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com + DTSTAMP:20060206T001220Z + DTSTART:20060104T150000 + DURATION:PT1H + LAST-MODIFIED:20060206T001330Z + + + +Daboo, et al. Standards Track [Page 46] + +RFC 4791 CalDAV March 2007 + + + ORGANIZER:mailto:cyrus@example.com + SEQUENCE:1 + STATUS:TENTATIVE + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 47] + +RFC 4791 CalDAV March 2007 + + +7.8.4. Example: Partial Retrieval of Stored Free Busy Components + + In this example, the client requests the server to return the + VFREEBUSY components that have free busy information that overlap the + time range from January 2, 2006, at 00:00:00 A.M. UTC (inclusively) + to January 3, 2006, at 00:00:00 A.M. UTC (exclusively). Use of the + CALDAV:limit-freebusy-set element causes the server to only return + the FREEBUSY property values that overlap the time range specified in + that element. Note that this is not an example of discovering when + the calendar owner is busy. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 48] + +RFC 4791 CalDAV March 2007 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd8.ics + + + "fffff-abcd8" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VFREEBUSY + ORGANIZER;CN="Bernard Desruisseaux":mailto:bernard@example.com + UID:76ef34-54a3d2@example.com + DTSTAMP:20050530T123421Z + DTSTART:20060101T100000Z + DTEND:20060108T100000Z + FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z + END:VFREEBUSY + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 49] + +RFC 4791 CalDAV March 2007 + + +7.8.5. Example: Retrieval of To-Dos by Alarm Time Range + + In this example, the client requests the server to return the VTODO + components that have an alarm trigger scheduled in the specified time + range. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 50] + +RFC 4791 CalDAV March 2007 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd4.ics + + + "fffff-abcd4" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + DTSTAMP:20060205T235300Z + DUE;TZID=US/Eastern:20060106T120000 + LAST-MODIFIED:20060205T235308Z + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Task #2 + UID:E10BA47467C5C69BB74E8720@example.com + BEGIN:VALARM + ACTION:AUDIO + TRIGGER;RELATED=START:-PT10M + END:VALARM + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +7.8.6. Example: Retrieval of Event by UID + + In this example, the client requests the server to return the VEVENT + component that has the UID property set to + "DC6C50A017428C5216A2F1CD@example.com". + + See Appendix B for the calendar data being targeted by this example. + + + + + +Daboo, et al. Standards Track [Page 51] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + DC6C50A017428C5216A2F1CD@example.com + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + + + +Daboo, et al. Standards Track [Page 52] + +RFC 4791 CalDAV March 2007 + + + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com + DTSTAMP:20060206T001220Z + DTSTART;TZID=US/Eastern:20060104T100000 + DURATION:PT1H + LAST-MODIFIED:20060206T001330Z + ORGANIZER:mailto:cyrus@example.com + SEQUENCE:1 + STATUS:TENTATIVE + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +7.8.7. Example: Retrieval of Events by PARTSTAT + + In this example, the client requests the server to return the VEVENT + components that have the ATTENDEE property with the value + "mailto:lisa@example.com" and for which the PARTSTAT parameter is set + to NEEDS-ACTION. + + See Appendix B for the calendar data being targeted by this example. + + + + + + + +Daboo, et al. Standards Track [Page 53] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + mailto:lisa@example.com + + NEEDS-ACTION + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + + + +Daboo, et al. Standards Track [Page 54] + +RFC 4791 CalDAV March 2007 + + + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com + DTSTAMP:20060206T001220Z + DTSTART;TZID=US/Eastern:20060104T100000 + DURATION:PT1H + LAST-MODIFIED:20060206T001330Z + ORGANIZER:mailto:cyrus@example.com + SEQUENCE:1 + STATUS:TENTATIVE + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +7.8.8. Example: Retrieval of Events Only + + In this example, the client requests the server to return all VEVENT + components. + + See Appendix B for the calendar data being targeted by this example. + + + + + +Daboo, et al. Standards Track [Page 55] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd1.ics + + + "fffff-abcd1" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + + + +Daboo, et al. Standards Track [Page 56] + +RFC 4791 CalDAV March 2007 + + + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20060206T001102Z + DTSTART;TZID=US/Eastern:20060102T100000 + DURATION:PT1H + SUMMARY:Event #1 + Description:Go Steelers! + UID:74855313FA803DA593CD579A@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/abcd2.ics + + + "fffff-abcd2" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + + + +Daboo, et al. Standards Track [Page 57] + +RFC 4791 CalDAV March 2007 + + + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060102T120000 + DURATION:PT1H + RRULE:FREQ=DAILY;COUNT=5 + SUMMARY:Event #2 + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060104T140000 + DURATION:PT1H + RECURRENCE-ID;TZID=US/Eastern:20060104T120000 + SUMMARY:Event #2 bis + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060106T140000 + DURATION:PT1H + RECURRENCE-ID;TZID=US/Eastern:20060106T120000 + SUMMARY:Event #2 bis bis + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + + + +Daboo, et al. Standards Track [Page 58] + +RFC 4791 CalDAV March 2007 + + + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com + DTSTAMP:20060206T001220Z + DTSTART;TZID=US/Eastern:20060104T100000 + DURATION:PT1H + LAST-MODIFIED:20060206T001330Z + ORGANIZER:mailto:cyrus@example.com + SEQUENCE:1 + STATUS:TENTATIVE + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +7.8.9. Example: Retrieval of All Pending To-Dos + + In this example, the client requests the server to return all VTODO + components that do not include a COMPLETED property and do not have a + STATUS property value matching CANCELLED, i.e., VTODOs that still + need to be worked on. + + See Appendix B for the calendar data being targeted by this example. + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 59] + +RFC 4791 CalDAV March 2007 + + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + CANCELLED + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd4.ics + + + "fffff-abcd4" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + + + +Daboo, et al. Standards Track [Page 60] + +RFC 4791 CalDAV March 2007 + + + DTSTAMP:20060205T235335Z + DUE;VALUE=DATE:20060104 + STATUS:NEEDS-ACTION + SUMMARY:Task #1 + UID:DDDEEB7915FA61233B861457@example.com + BEGIN:VALARM + ACTION:AUDIO + TRIGGER;RELATED=START:-PT10M + END:VALARM + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + http://cal.example.com/bernard/work/abcd5.ics + + + "fffff-abcd5" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + DTSTAMP:20060205T235300Z + DUE;VALUE=DATE:20060106 + LAST-MODIFIED:20060205T235308Z + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Task #2 + UID:E10BA47467C5C69BB74E8720@example.com + BEGIN:VALARM + ACTION:AUDIO + TRIGGER;RELATED=START:-PT10M + END:VALARM + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + + + + +Daboo, et al. Standards Track [Page 61] + +RFC 4791 CalDAV March 2007 + + +7.8.10. Example: Attempt to Query Unsupported Property + + In this example, the client requests the server to return all VEVENT + components that include an X-ABC-GUID property with a value matching + "ABC". However, the server does not support querying that non- + standard property, and instead returns an error response. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + ABC + + + + + + + >> Response << + + HTTP/1.1 403 Forbidden + Date: Sat, 11 Nov 2005 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + +Daboo, et al. Standards Track [Page 62] + +RFC 4791 CalDAV March 2007 + + +7.9. CALDAV:calendar-multiget REPORT + + The CALDAV:calendar-multiget REPORT is used to retrieve specific + calendar object resources from within a collection, if the Request- + URI is a collection, or to retrieve a specific calendar object + resource, if the Request-URI is a calendar object resource. This + report is similar to the CALDAV:calendar-query REPORT (see + Section 7.8), except that it takes a list of DAV:href elements, + instead of a CALDAV:filter element, to determine which calendar + object resources to return. + + Support for the CALDAV:calendar-multiget REPORT is REQUIRED. + + Marshalling: + + The request body MUST be a CALDAV:calendar-multiget XML element + (see Section 9.10). If the Request-URI is a collection resource, + then the DAV:href elements MUST refer to calendar object resources + within that collection, and they MAY refer to calendar object + resources at any depth within the collection. As a result, the + "Depth" header MUST be ignored by the server and SHOULD NOT be + sent by the client. If the Request-URI refers to a non-collection + resource, then there MUST be a single DAV:href element that is + equivalent to the Request-URI. + + The response body for a successful request MUST be a DAV: + multistatus XML element. + + The response body for a successful CALDAV:calendar-multiget REPORT + request MUST contain a DAV:response element for each calendar + object resource referenced by the provided set of DAV:href + elements. Calendar data is being returned in the CALDAV:calendar- + data element inside the DAV:prop element. + + In the case of an error accessing any of the provided DAV:href + resources, the server MUST return the appropriate error status + code in the DAV:status element of the corresponding DAV:response + element. + + Preconditions: + + (CALDAV:supported-calendar-data): The attributes "content-type" + and "version" of the CALDAV:calendar-data XML elements (see + Section 9.6) specify a media type supported by the server for + calendar object resources. + + (CALDAV:min-date-time): Any XML element specifying a range of time + MUST have its start or end DATE or DATE-TIME values greater than + + + +Daboo, et al. Standards Track [Page 63] + +RFC 4791 CalDAV March 2007 + + + or equal to the value of the CALDAV:min-date-time property value + (Section 5.2.6) on the calendar collections being targeted by the + REPORT request; + + (CALDAV:max-date-time): Any XML element specifying a range of time + MUST have its start or end DATE or DATE-TIME values less than or + equal to the value of the CALDAV:max-date-time property value + (Section 5.2.7) on the calendar collections being targeted by the + REPORT request; + + Postconditions: + + None. + +7.9.1. Example: Successful CALDAV:calendar-multiget REPORT + + In this example, the client requests the server to return specific + properties of the VEVENT components referenced by specific URIs. In + addition, the DAV:getetag property is also requested and returned as + part of the response. Note that in this example, the resource at + http://cal.example.com/bernard/work/mtg1.ics does not exist, + resulting in an error status response. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + /bernard/work/abcd1.ics + /bernard/work/mtg1.ics + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + + + +Daboo, et al. Standards Track [Page 64] + +RFC 4791 CalDAV March 2007 + + + Content-Length: xxxx + + + + + http://cal.example.com/bernard/work/abcd1.ics + + + "fffff-abcd1" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20060206T001102Z + DTSTART;TZID=US/Eastern:20060102T100000 + DURATION:PT1H + SUMMARY:Event #1 + Description:Go Steelers! + UID:74855313FA803DA593CD579A@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/mtg1.ics + HTTP/1.1 404 Not Found + + + +Daboo, et al. Standards Track [Page 65] + +RFC 4791 CalDAV March 2007 + + + + + +7.10. CALDAV:free-busy-query REPORT + + The CALDAV:free-busy-query REPORT generates a VFREEBUSY component + containing free busy information for all the calendar object + resources targeted by the request and that have the CALDAV:read-free- + busy or DAV:read privilege granted to the current user. + + Only VEVENT components without a TRANSP property or with the TRANSP + property set to OPAQUE, and VFREEBUSY components SHOULD be considered + in generating the free busy time information. + + In the case of VEVENT components, the free or busy time type (FBTYPE) + of the FREEBUSY properties in the returned VFREEBUSY component SHOULD + be derived from the value of the TRANSP and STATUS properties, as + outlined in the table below: + + +---------------------------++------------------+ + | VEVENT || VFREEBUSY | + +-------------+-------------++------------------+ + | TRANSP | STATUS || FBTYPE | + +=============+=============++==================+ + | | CONFIRMED || BUSY | + | | (default) || | + | OPAQUE +-------------++------------------+ + | (default) | CANCELLED || FREE | + | +-------------++------------------+ + | | TENTATIVE || BUSY-TENTATIVE | + | +-------------++------------------+ + | | x-name || BUSY or | + | | || x-name | + +-------------+-------------++------------------+ + | | CONFIRMED || | + | TRANSPARENT | CANCELLED || FREE | + | | TENTATIVE || | + | | x-name || | + +-------------+-------------++------------------+ + + Duplicate busy time periods with the same FBTYPE parameter value + SHOULD NOT be specified in the returned VFREEBUSY component. Servers + SHOULD coalesce consecutive or overlapping busy time periods of the + same type. Busy time periods with different FBTYPE parameter values + MAY overlap. + + Support for the CALDAV:free-busy-query REPORT is REQUIRED. + + + + +Daboo, et al. Standards Track [Page 66] + +RFC 4791 CalDAV March 2007 + + + Marshalling: + + The request body MUST be a CALDAV:free-busy-query XML element (see + Section 9.11), which MUST contain exactly one CALDAV:time-range + XML element, as defined in Section 9.9. + + The request MAY include a Depth header. If no Depth header is + included, Depth:0 is assumed. + + The response body for a successful request MUST be an iCalendar + object that contains exactly one VFREEBUSY component that + describes the busy time intervals for the calendar object + resources containing VEVENT, or VFREEBUSY components that satisfy + the Depth value and for which the current user is at least granted + the CALDAV:read-free-busy privilege. If no calendar object + resources are found to satisfy these conditions, a VFREEBUSY + component with no FREEBUSY property MUST be returned. This report + only returns busy time information. Free time information can be + inferred from the returned busy time information. + + If the current user is not granted the CALDAV:read-free-busy or + DAV:read privileges on the Request-URI, the CALDAV:free-busy-query + REPORT request MUST fail and return a 404 (Not Found) status + value. This restriction will prevent users from discovering URLs + of resources for which they are only granted the CALDAV:read-free- + busy privilege. + + The CALDAV:free-busy-query REPORT request can only be run against + a collection (either a regular collection or a calendar + collection). An attempt to run the report on a calendar object + resource MUST fail and return a 403 (Forbidden) status value. + + Preconditions: + + None. + + Postconditions: + + (DAV:number-of-matches-within-limits): The number of matching + calendar object resources must fall within server-specific, + predefined limits. For example, this postcondition might fail if + the specified CALDAV:time-range would cause an extremely large + number of calendar object resources to be considered in computing + the response. + + + + + + + +Daboo, et al. Standards Track [Page 67] + +RFC 4791 CalDAV March 2007 + + +7.10.1. Example: Successful CALDAV:free-busy-query REPORT + + In this example, the client requests the server to return free busy + information on the calendar collection /bernard/work/, between 9:00 + A.M. and 5:00 P.M. EST (2:00 P.M. and 10:00 P.M. UTC) on the January + 4, 2006. The server responds, indicating two busy time intervals of + one hour, one of which is tentative. + + See Appendix B for the calendar data being targeted by this example. + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + >> Response << + + HTTP/1.1 200 OK + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: text/calendar + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + BEGIN:VFREEBUSY + DTSTAMP:20050125T090000Z + DTSTART:20060104T140000Z + DTEND:20060105T220000Z + FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060104T150000Z/PT1H + FREEBUSY:20060104T190000Z/PT1H + END:VFREEBUSY + END:VCALENDAR + + + + + + + + + +Daboo, et al. Standards Track [Page 68] + +RFC 4791 CalDAV March 2007 + + +8. Guidelines + +8.1. Client-to-Client Interoperability + + There are a number of actions clients can take that will be legal + (the server will not return errors), but that can degrade + interoperability with other client implementations accessing the same + data. For example, a recurrence rule could be replaced with a set of + recurrence dates, a single recurring event could be replaced with a + set of independent resources to represent each recurrence, or the + start/end time values can be translated from the original time zone + to another time zone. Although this advice amounts to iCalendar + interoperability best practices and is not limited only to CalDAV + usage, interoperability problems are likely to be more evident in + CalDAV use cases. + +8.2. Synchronization Operations + + WebDAV already provides functionality required to synchronize a + collection or set of collections, to make changes offline, and + provides a simple way to resolve conflicts when reconnected. ETags + are the key to making this work, but these are not required of all + WebDAV servers. Since offline functionality is more important to + calendar applications than to some other WebDAV applications, CalDAV + servers MUST support ETags, as specified in Section 5.3.4. + +8.2.1. Use of Reports + +8.2.1.1. Restrict the Time Range + + The reports provided in CalDAV can be used by clients to optimize + their performance in terms of network bandwidth usage and resource + consumption on the local client machine. Both are certainly major + considerations for mobile or handheld devices with limited capacity, + but they are also relevant to desktop client applications in cases + where the calendar collections contain large amounts of data. + + Typically, clients present calendar data to users in views that span + a finite time interval, so whenever possible, clients should only + retrieve calendar components from the server using CALDAV:calendar- + query REPORT, combined with a CALDAV:time-range element, to limit the + set of returned components to just those needed to populate the + current view. + + + + + + + + +Daboo, et al. Standards Track [Page 69] + +RFC 4791 CalDAV March 2007 + + +8.2.1.2. Synchronize by Time Range + + Typically in a calendar, historical data (events, to-dos, etc. that + have completed prior to the current date) do not change, though they + may be deleted. As a result, a client can speed up the + synchronization process by only considering data for the present time + and the future up to a reasonable limit (e.g., one week, one month). + If the user then tries to examine a portion of the calendar outside + the range that has been synchronized, the client can perform another + synchronization operation on the new time interval being examined. + This "just-in-time" synchronization can minimize bandwidth for common + user interaction behaviors. + +8.2.1.3. Synchronization Process + + If a client wants to support calendar data synchronization, as + opposed to downloading calendar data each time it is needed, the + client needs to cache the calendar object resource's URI and ETag, + along with the actual calendar data. While the URI remains static + for the lifetime of the calendar object resource, the ETag will + change with each successive change to the calendar object resource. + Thus, to synchronize a local data cache with the server, the client + can first fetch the URI/ETag pairs for the time interval being + considered, and compare those results with the cached data. Any + cached component whose ETag differs from that on the server needs to + be refreshed. + + In order to properly detect the changes between the server and client + data, the client will need to keep a record of which calendar object + resources have been created, changed, or deleted since the last + synchronization operation so that it can reconcile those changes with + the data on the server. + + Here's an example of how to do that: + + The client issues a CALDAV:calendar-query REPORT request for a + specific time range and asks for only the DAV:getetag property to be + returned: + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 70] + +RFC 4791 CalDAV March 2007 + + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + The client then uses the results to determine which calendar object + resources have changed, been created, or deleted on the server, and + how those relate to locally cached calendar object resources that may + have changed, been created, or deleted. If the client determines + that there are calendar object resources on the server that need to + be fetched, the client issues a CALDAV:calendar-multiget REPORT + request to fetch its calendar data: + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + /bernard/work/abcd1.ics + /bernard/work/mtg1.ics + + + + + + + +Daboo, et al. Standards Track [Page 71] + +RFC 4791 CalDAV March 2007 + + +8.2.2. Restrict the Properties Returned + + A client may not need all the calendar properties of a calendar + object resource when presenting information to the user. Since some + calendar property values can be large (e.g., ATTACH or ATTENDEE), a + client can choose to restrict the calendar properties to be returned + in a calendaring REPORT request to those it knows it will use. + + However, if a client needs to make a change to a calendar object + resource, it can only change the entire calendar object resource via + a PUT request. There is currently no way to incrementally make a + change to a set of calendar properties of a calendar object resource. + As a result, the client will have to get the entire calendar object + resource that is being changed. + +8.3. Use of Locking + + WebDAV locks can be used to prevent two clients that are modifying + the same resource from either overwriting each others' changes + (though that problem can also be solved by using ETags) or wasting + time making changes that will conflict with another set of changes. + In a multi-user calendar system, an interactive calendar client could + lock an event while the user is editing the event, and unlock the + event when the user finishes or cancels. Locks can also be used to + prevent changes while data is being reorganized. For example, a + calendar client might lock two calendar collections prior to moving a + bunch of calendar resources from one to another. + + Clients are responsible for requesting a lock timeout period that is + appropriate to the use case. When the user explicitly decides to + reserve a resource and prevent other changes, a long timeout might be + appropriate, but in cases where the client automatically decides to + lock the resource, the timeout should be short (and the client can + always refresh the lock should it need to). A short lock timeout + means that if the client is unable to remove the lock, the other + calendar users aren't prevented from making changes. + +8.4. Finding Calendars + + Much of the time, a calendar client (or agent) will discover a new + calendar's location by being provided directly with the URL. For + example, a user will type his or her own calendar location into + client configuration information or copy and paste a URL from email + into the calendar application. The client need only confirm that the + URL points to a resource that is a calendar collection. The client + may also be able to browse WebDAV collections to find calendar + collections. + + + + +Daboo, et al. Standards Track [Page 72] + +RFC 4791 CalDAV March 2007 + + + The choice of HTTP URLs means that calendar object resources are + backward compatible with existing software, but does have the + disadvantage that existing software does not usually know to look at + the OPTIONS response to that URL to determine what can be done with + it. This is somewhat of a barrier for WebDAV usage as well as with + CalDAV usage. This specification does not offer a way through this + other than making the information available in the OPTIONS response + should this be requested. + + For calendar sharing and scheduling use cases, one might wish to find + the calendar belonging to another user. If the other user has a + calendar in the same repository, that calendar can be found by using + the principal namespace required by WebDAV ACL support. For other + cases, the authors have no universal solution, but implementers can + consider whether to use vCard [RFC2426] or LDAP [RFC4511] standards + together with calendar attributes [RFC2739]. + + Because CalDAV requires servers to support WebDAV ACL [RFC3744], + including principal namespaces, and with the addition of the CALDAV: + calendar-home-set property, there are a couple options for CalDAV + clients to find one's own calendar or another user's calendar. + + In this case, a DAV:principal-match REPORT is used to find a named + property (the CALDAV:calendar-home-set) on the Principal-URL of the + current user. Using this, a WebDAV client can learn "who am I" and + "where are my calendars". The REPORT request body looks like this: + + + + + + + + + + To find other users' calendars, the DAV:principal-property-search + REPORT can be used to filter on some properties and return others. + To search for a calendar owned by a user named "Laurie", the REPORT + request body would look like this: + + + + + + + + + + + +Daboo, et al. Standards Track [Page 73] + +RFC 4791 CalDAV March 2007 + + + + + + + + + Laurie + + + + + + + + The server performs a case-sensitive or caseless search for a + matching string subset of "Laurie" within the DAV:displayname + property. Thus, the server might return "Laurie Dusseault", "Laurier + Desruisseaux", or "Wilfrid Laurier" as matching DAV:displayname + values, and return the calendars for each of these. + +8.5. Storing and Using Attachments + + CalDAV clients MAY create attachments in calendar components either + as inline or external. This section contains some guidelines for + creating and managing attachments. + +8.5.1. Inline Attachments + + CalDAV clients MUST support inline attachments as specified in + iCalendar [RFC2445]. CalDAV servers MUST support inline attachments, + so clients can rely on being able to create attachments this way. On + the other hand, inline attachments have some drawbacks: + + o Servers MAY impose limitations on the size of calendar object + resources (i.e., refusing PUT requests of very large iCalendar + objects). Servers that impose such limitations MUST use the + CALDAV:max-resource-size property on a calendar collection to + inform the client as to what the limitation is (see + Section 5.2.5). + + o Servers MAY impose storage quota limitations on calendar + collections (See [RFC4331]). + + o Any change to a calendar object resource containing an inline + attachment requires the entire inline attachment to be re- + uploaded. + + + + +Daboo, et al. Standards Track [Page 74] + +RFC 4791 CalDAV March 2007 + + + o Clients synchronizing a changed calendar object resource have to + download the entire calendar object resource, even if the + attachment is unchanged. + +8.5.2. External Attachments + + CalDAV clients SHOULD support downloading of external attachments + referenced by arbitrary URI schemes, by either processing them + directly, or by passing the attachment URI to a suitable "helper + application" for processing, if such an application exists. CalDAV + clients MUST support downloading of external attachments referenced + by the "http" or "https" URI schemes. An external attachment could + be: + + o In a collection in the calendar collection containing the calendar + object resource; + + o Somewhere else in the same repository that hosts the calendar + collection; or + + o On an HTTP or FTP server elsewhere. + + CalDAV servers MAY provide support for child collections in calendar + collections. CalDAV servers MAY allow the MKCOL method to create + child collections in calendar collections. Child collections of + calendar collections MAY contain any type of resource except calendar + collections that they MUST NOT contain. Some CalDAV servers won't + allow child collections in calendar collections, and it may be + possible on such a server to discover other locations where + attachments can be stored. + + Clients are entirely responsible for maintaining reference + consistency with calendar components that link to external + attachments. A client deleting a calendar component with an external + attachment might therefore also delete the attachment if that's + appropriate; however, appropriateness can be very hard to determine. + A new component might easily reference some pre-existing Web resource + that is intended to have independent existence from the calendar + component (the "attachment" could be a major proposal to be discussed + in a meeting, for instance). Best practices will probably emerge and + should probably be documented, but for now, clients should be wary of + engaging in aggressive "cleanup" of external attachments. A client + could involve the user in making decisions about removing + unreferenced documents, or a client could be conservative in only + deleting attachments it had created. + + Also, clients are responsible for consistency of permissions when + using external attachments. One reason for servers to support the + + + +Daboo, et al. Standards Track [Page 75] + +RFC 4791 CalDAV March 2007 + + + storage of attachments within child collections of calendar + collections is that ACL inheritance might make it easier to grant the + same permissions to attachments that are granted on the calendar + collection. Otherwise, it can be very difficult to keep permissions + synchronized. With attachments stored on separate repositories, it + can be impossible to keep permissions consistent -- the two + repositories may not support the same permissions or have the same + set of principals. Some systems have used tickets or other anonymous + access control mechanisms to provide partially satisfactory solutions + to these kinds of problems. + +8.6. Storing and Using Alarms + + Note that all CalDAV calendar collections (including those the user + might treat as public or group calendars) can contain alarm + information on events and to-dos. Users can synchronize a calendar + between multiple devices and decide to have alarms execute on a + different device than the device that created the alarm. Not all + alarm action types are completely interoperable (e.g., those that + name a sound file to play). + + When the action is AUDIO and the client is configured to execute + the alarm, the client SHOULD play the suggested sound if it's + available or play another sound, but SHOULD NOT rewrite the alarm + just to replace the suggested sound with a sound that's locally + available. + + When the action is DISPLAY and the client is configured to execute + the alarm, the client SHOULD execute a display alarm by displaying + according to the suggested description or some reasonable + replacement, but SHOULD NOT rewrite the alarm for its own + convenience. + + When the action is EMAIL and the client is incapable of sending + email, it SHOULD ignore the alarm, but it MUST continue to + synchronize the alarm itself. + + This specification makes no recommendations about executing alarms + of type PROCEDURE, except to note that clients are advised to take + care to avoid creating security holes by executing these. + + Non-interoperable alarm information (e.g., should somebody define a + color to be used in a display alarm) should be put in non-standard + properties inside the VALARM component in order to keep the basic + alarm usable on all devices. + + Clients that allow changes to calendar object resources MUST + synchronize the alarm data that already exists in the resources. + + + +Daboo, et al. Standards Track [Page 76] + +RFC 4791 CalDAV March 2007 + + + Clients MAY execute alarms that are downloaded in this fashion, + possibly based on user preference. If a client is only doing read + operations on a calendar and there is no risk of losing alarm + information, then the client MAY discard alarm information. + + This specification makes no attempt to provide multi-user alarms on + group calendars or to find out for whom an alarm is intended. + Addressing those issues might require extensions to iCalendar; for + example, to store alarms per-user, or to indicate for which user a + VALARM was intended. In the meantime, clients might maximize + interoperability by generally not uploading alarm information to + public, group, or resource calendars. + +9. XML Element Definitions + +9.1. CALDAV:calendar XML Element + + Name: calendar + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies the resource type of a calendar collection. + + Description: See Section 4.2. + + Definition: + + + +9.2. CALDAV:mkcalendar XML Element + + Name: mkcalendar + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a request that includes the WebDAV property + values to be set for a calendar collection resource when it is + created. + + Description: See Section 5.3.1. + + Definition: + + + + + + + + + +Daboo, et al. Standards Track [Page 77] + +RFC 4791 CalDAV March 2007 + + +9.3. CALDAV:mkcalendar-response XML Element + + Name: mkcalendar-response + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a response body for a successful MKCALENDAR + request. + + Description: See Section 5.3.1. + + Definition: + + + +9.4. CALDAV:supported-collation XML Element + + Name: supported-collation + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identifies a single collation via its collation identifier, + as defined by [RFC4790]. + + Description: The CALDAV:supported-collation contains the text of a + collation identifier, as described in Section 7.5.1. + + Definition: + + + PCDATA value: collation identifier + +9.5. CALDAV:calendar-query XML Element + + Name: calendar-query + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Defines a report for querying calendar object resources. + + Description: See Section 7.8. + + Definition: + + + + + + +Daboo, et al. Standards Track [Page 78] + +RFC 4791 CalDAV March 2007 + + +9.6. CALDAV:calendar-data XML Element + + Name: calendar-data + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specified one of the following: + + 1. A supported media type for calendar object resources when + nested in the CALDAV:supported-calendar-data property; + + 2. The parts of a calendar object resource should be returned by + a calendaring report; + + 3. The content of a calendar object resource in a response to a + calendaring report. + + Description: When nested in the CALDAV:supported-calendar-data + property, the CALDAV:calendar-data XML element specifies a media + type supported by the CalDAV server for calendar object resources. + + When used in a calendaring REPORT request, the CALDAV:calendar- + data XML element specifies which parts of calendar object + resources need to be returned in the response. If the CALDAV: + calendar-data XML element doesn't contain any CALDAV:comp element, + calendar object resources will be returned in their entirety. + + Finally, when used in a calendaring REPORT response, the CALDAV: + calendar-data XML element specifies the content of a calendar + object resource. Given that XML parsers normalize the two- + character sequence CRLF (US-ASCII decimal 13 and US-ASCII decimal + 10) to a single LF character (US-ASCII decimal 10), the CR + character (US-ASCII decimal 13) MAY be omitted in calendar object + resources specified in the CALDAV:calendar-data XML element. + Furthermore, calendar object resources specified in the CALDAV: + calendar-data XML element MAY be invalid per their media type + specification if the CALDAV:calendar-data XML element part of the + calendaring REPORT request did not specify required properties + (e.g., UID, DTSTAMP, etc.), or specified a CALDAV:prop XML element + with the "novalue" attribute set to "yes". + + Note: The CALDAV:calendar-data XML element is specified in requests + and responses inside the DAV:prop XML element as if it were a + WebDAV property. However, the CALDAV:calendar-data XML element is + not a WebDAV property and, as such, is not returned in PROPFIND + responses, nor used in PROPPATCH requests. + + + + + +Daboo, et al. Standards Track [Page 79] + +RFC 4791 CalDAV March 2007 + + + Note: The iCalendar data embedded within the CALDAV:calendar-data + XML element MUST follow the standard XML character data encoding + rules, including use of <, >, & etc. entity encoding or + the use of a construct. In the later case, the + iCalendar data cannot contain the character sequence "]]>", which + is the end delimiter for the CDATA section. + + Definition: + + + + when nested in the CALDAV:supported-calendar-data property + to specify a supported media type for calendar object + resources; + + + + when nested in the DAV:prop XML element in a calendaring + REPORT request to specify which parts of calendar object + resources should be returned in the response; + + + PCDATA value: iCalendar object + + when nested in the DAV:prop XML element in a calendaring + REPORT response to specify the content of a returned + calendar object resource. + + + content-type value: a MIME media type + version value: a version string + + attributes can be used on all three variants of the + CALDAV:calendar-data XML element. + +9.6.1. CALDAV:comp XML Element + + Name: comp + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Defines which component types to return. + + + + + + +Daboo, et al. Standards Track [Page 80] + +RFC 4791 CalDAV March 2007 + + + Description: The name value is a calendar component name (e.g., + VEVENT). + + Definition: + + + + + name value: a calendar component name + + Note: The CALDAV:prop and CALDAV:allprop elements have the same name + as the DAV:prop and DAV:allprop elements defined in [RFC2518]. + However, the CALDAV:prop and CALDAV:allprop elements are defined + in the "urn:ietf:params:xml:ns:caldav" namespace instead of the + "DAV:" namespace. + +9.6.2. CALDAV:allcomp XML Element + + Name: allcomp + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies that all components shall be returned. + + Description: The CALDAV:allcomp XML element can be used when the + client wants all types of components returned by a calendaring + REPORT request. + + Definition: + + + +9.6.3. CALDAV:allprop XML Element + + Name: allprop + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies that all properties shall be returned. + + Description: The CALDAV:allprop XML element can be used when the + client wants all properties of components returned by a + calendaring REPORT request. + + Definition: + + + + + + +Daboo, et al. Standards Track [Page 81] + +RFC 4791 CalDAV March 2007 + + + Note: The CALDAV:allprop element has the same name as the DAV: + allprop element defined in [RFC2518]. However, the CALDAV:allprop + element is defined in the "urn:ietf:params:xml:ns:caldav" + namespace instead of the "DAV:" namespace. + +9.6.4. CALDAV:prop XML Element + + Name: prop + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Defines which properties to return in the response. + + Description: The "name" attribute specifies the name of the calendar + property to return (e.g., ATTENDEE). The "novalue" attribute can + be used by clients to request that the actual value of the + property not be returned (if the "novalue" attribute is set to + "yes"). In that case, the server will return just the iCalendar + property name and any iCalendar parameters and a trailing ":" + without the subsequent value data. + + Definition: + + + + + name value: a calendar property name + novalue value: "yes" or "no" + + Note: The CALDAV:prop element has the same name as the DAV:prop + element defined in [RFC2518]. However, the CALDAV:prop element is + defined in the "urn:ietf:params:xml:ns:caldav" namespace instead + of the "DAV:" namespace. + +9.6.5. CALDAV:expand XML Element + + Name: expand + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Forces the server to expand recurring components into + individual recurrence instances. + + Description: The CALDAV:expand XML element specifies that for a + given calendaring REPORT request, the server MUST expand the + recurrence set into calendar components that define exactly one + + + + +Daboo, et al. Standards Track [Page 82] + +RFC 4791 CalDAV March 2007 + + + recurrence instance, and MUST return only those whose scheduled + time intersect a specified time range. + + The "start" attribute specifies the inclusive start of the time + range, and the "end" attribute specifies the non-inclusive end of + the time range. Both attributes are specified as date with UTC + time value. The value of the "end" attribute MUST be greater than + the value of the "start" attribute. + + The server MUST use the same logic as defined for CALDAV:time- + range to determine if a recurrence instance intersects the + specified time range. + + Recurring components, other than the initial instance, MUST + include a RECURRENCE-ID property indicating which instance they + refer to. + + The returned calendar components MUST NOT use recurrence + properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT + have reference to or include VTIMEZONE components. Date and local + time with reference to time zone information MUST be converted + into date with UTC time. + + Definition: + + + + + start value: an iCalendar "date with UTC time" + end value: an iCalendar "date with UTC time" + +9.6.6. CALDAV:limit-recurrence-set XML Element + + Name: limit-recurrence-set + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a time range to limit the set of "overridden + components" returned by the server. + + Description: The CALDAV:limit-recurrence-set XML element specifies + that for a given calendaring REPORT request, the server MUST + return, in addition to the "master component", only the + "overridden components" that impact a specified time range. An + overridden component impacts a time range if its current start and + end times overlap the time range, or if the original start and end + + + + +Daboo, et al. Standards Track [Page 83] + +RFC 4791 CalDAV March 2007 + + + times -- the ones that would have been used if the instance were + not overridden -- overlap the time range. + + The "start" attribute specifies the inclusive start of the time + range, and the "end" attribute specifies the non-inclusive end of + the time range. Both attributes are specified as date with UTC + time value. The value of the "end" attribute MUST be greater than + the value of the "start" attribute. + + The server MUST use the same logic as defined for CALDAV:time- + range to determine if the current or original scheduled time of an + "overridden" recurrence instance intersects the specified time + range. + + Overridden components that have a RANGE parameter on their + RECURRENCE-ID property may specify one or more instances in the + recurrence set, and some of those instances may fall within the + specified time range or may have originally fallen within the + specified time range prior to being overridden. If that is the + case, the overridden component MUST be included in the results, as + it has a direct impact on the interpretation of instances within + the specified time range. + + Definition: + + + + + start value: an iCalendar "date with UTC time" + end value: an iCalendar "date with UTC time" + +9.6.7. CALDAV:limit-freebusy-set XML Element + + Name: limit-freebusy-set + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a time range to limit the set of FREEBUSY values + returned by the server. + + Description: The CALDAV:limit-freebusy-set XML element specifies + that for a given calendaring REPORT request, the server MUST only + return the FREEBUSY property values of a VFREEBUSY component that + intersects a specified time range. + + The "start" attribute specifies the inclusive start of the time + range, and the "end" attribute specifies the non-inclusive end of + + + +Daboo, et al. Standards Track [Page 84] + +RFC 4791 CalDAV March 2007 + + + the time range. Both attributes are specified as "date with UTC + time" value. The value of the "end" attribute MUST be greater + than the value of the "start" attribute. + + The server MUST use the same logic as defined for CALDAV:time- + range to determine if a FREEBUSY property value intersects the + specified time range. + + Definition: + + + + + start value: an iCalendar "date with UTC time" + end value: an iCalendar "date with UTC time" + +9.7. CALDAV:filter XML Element + + Name: filter + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a filter to limit the set of calendar components + returned by the server. + + Description: The CALDAV:filter XML element specifies the search + filter used to limit the calendar components returned by a + calendaring REPORT request. + + Definition: + + + +9.7.1. CALDAV:comp-filter XML Element + + Name: comp-filter + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies search criteria on calendar components. + + Description: The CALDAV:comp-filter XML element specifies a query + targeted at the calendar object (i.e., VCALENDAR) or at a specific + calendar component type (e.g., VEVENT). The scope of the + CALDAV:comp-filter XML element is the calendar object when used as + a child of the CALDAV:filter XML element. The scope of the + CALDAV:comp-filter XML element is the enclosing calendar component + + + +Daboo, et al. Standards Track [Page 85] + +RFC 4791 CalDAV March 2007 + + + when used as a child of another CALDAV:comp-filter XML element. A + CALDAV:comp-filter is said to match if: + + * The CALDAV:comp-filter XML element is empty and the calendar + object or calendar component type specified by the "name" + attribute exists in the current scope; + + or: + + * The CALDAV:comp-filter XML element contains a CALDAV:is-not- + defined XML element and the calendar object or calendar + component type specified by the "name" attribute does not exist + in the current scope; + + or: + + * The CALDAV:comp-filter XML element contains a CALDAV:time-range + XML element and at least one recurrence instance in the + targeted calendar component is scheduled to overlap the + specified time range, and all specified CALDAV:prop-filter and + CALDAV:comp-filter child XML elements also match the targeted + calendar component; + + or: + + * The CALDAV:comp-filter XML element only contains CALDAV:prop- + filter and CALDAV:comp-filter child XML elements that all match + the targeted calendar component. + + Definition: + + + + + name value: a calendar object or calendar component + type (e.g., VEVENT) + +9.7.2. CALDAV:prop-filter XML Element + + Name: prop-filter + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies search criteria on calendar properties. + + Description: The CALDAV:prop-filter XML element specifies a query + targeted at a specific calendar property (e.g., CATEGORIES) in the + + + +Daboo, et al. Standards Track [Page 86] + +RFC 4791 CalDAV March 2007 + + + scope of the enclosing calendar component. A calendar property is + said to match a CALDAV:prop-filter if: + + * The CALDAV:prop-filter XML element is empty and a property of + the type specified by the "name" attribute exists in the + enclosing calendar component; + + or: + + * The CALDAV:prop-filter XML element contains a CALDAV:is-not- + defined XML element and no property of the type specified by + the "name" attribute exists in the enclosing calendar + component; + + or: + + * The CALDAV:prop-filter XML element contains a CALDAV:time-range + XML element and the property value overlaps the specified time + range, and all specified CALDAV:param-filter child XML elements + also match the targeted property; + + or: + + * The CALDAV:prop-filter XML element contains a CALDAV:text-match + XML element and the property value matches it, and all + specified CALDAV:param-filter child XML elements also match the + targeted property; + + Definition: + + + + + name value: a calendar property name (e.g., ATTENDEE) + +9.7.3. CALDAV:param-filter XML Element + + Name: param-filter + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Limits the search to specific parameter values. + + Description: The CALDAV:param-filter XML element specifies a query + targeted at a specific calendar property parameter (e.g., + PARTSTAT) in the scope of the calendar property on which it is + + + +Daboo, et al. Standards Track [Page 87] + +RFC 4791 CalDAV March 2007 + + + defined. A calendar property parameter is said to match a CALDAV: + param-filter if: + + * The CALDAV:param-filter XML element is empty and a parameter of + the type specified by the "name" attribute exists on the + calendar property being examined; + + or: + + * The CALDAV:param-filter XML element contains a CALDAV:is-not- + defined XML element and no parameter of the type specified by + the "name" attribute exists on the calendar property being + examined; + + Definition: + + + + + name value: a property parameter name (e.g., PARTSTAT) + +9.7.4. CALDAV:is-not-defined XML Element + + Name: is-not-defined + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies that a match should occur if the enclosing + component, property, or parameter does not exist. + + Description: The CALDAV:is-not-defined XML element specifies that a + match occurs if the enclosing component, property, or parameter + value specified in a calendaring REPORT request does not exist in + the calendar data being tested. + + Definition: + + + +9.7.5. CALDAV:text-match XML Element + + Name: text-match + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a substring match on a property or parameter + value. + + + + +Daboo, et al. Standards Track [Page 88] + +RFC 4791 CalDAV March 2007 + + + Description: The CALDAV:text-match XML element specifies text used + for a substring match against the property or parameter value + specified in a calendaring REPORT request. + + The "collation" attribute is used to select the collation that the + server MUST use for character string matching. In the absence of + this attribute, the server MUST use the "i;ascii-casemap" + collation. + + The "negate-condition" attribute is used to indicate that this + test returns a match if the text matches when the attribute value + is set to "no", or return a match if the text does not match, if + the attribute value is set to "yes". For example, this can be + used to match components with a STATUS property not set to + CANCELLED. + + Definition: + + + PCDATA value: string + + + +9.8. CALDAV:timezone XML Element + + Name: timezone + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies the time zone component to use when determining + the results of a report. + + Description: The CALDAV:timezone XML element specifies that for a + given calendaring REPORT request, the server MUST rely on the + specified VTIMEZONE component instead of the CALDAV:calendar- + timezone property of the calendar collection, in which the + calendar object resource is contained to resolve "date" values and + "date with local time" values (i.e., floating time) to "date with + UTC time" values. The server will require this information to + determine if a calendar component scheduled with "date" values or + "date with local time" values intersects a CALDAV:time-range + specified in a CALDAV:calendar-query REPORT. + + Note: The iCalendar data embedded within the CALDAV:timezone XML + element MUST follow the standard XML character data encoding + rules, including use of <, >, & etc. entity encoding or + the use of a construct. In the later case, the + + + +Daboo, et al. Standards Track [Page 89] + +RFC 4791 CalDAV March 2007 + + + iCalendar data cannot contain the character sequence "]]>", which + is the end delimiter for the CDATA section. + + Definition: + + + PCDATA value: an iCalendar object with exactly one VTIMEZONE + +9.9. CALDAV:time-range XML Element + + Name: time-range + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a time range to limit the set of calendar + components returned by the server. + + Description: The CALDAV:time-range XML element specifies that for a + given calendaring REPORT request, the server MUST only return the + calendar object resources that, depending on the context, have a + component or property whose value intersects a specified time + range. + + The "start" attribute specifies the inclusive start of the time + range, and the "end" attribute specifies the non-inclusive end of + the time range. Both attributes MUST be specified as "date with + UTC time" value. Time ranges open at one end can be specified by + including only one attribute; however, at least one attribute MUST + always be present in the CALDAV:time-range element. If either the + "start" or "end" attribute is not specified in the CALDAV:time- + range XML element, assume "-infinity" and "+infinity" as their + value, respectively. If both "start" and "end" are present, the + value of the "end" attribute MUST be greater than the value of the + "start" attribute. + + Time range tests MUST consider every recurrence instance when + testing the time range condition; if any one instance matches, + then the test returns true. Testing recurrence instances requires + the server to infer an effective value for DTSTART, DTEND, + DURATION, and DUE properties for an instance based on the + recurrence patterns and any overrides. + + A VEVENT component overlaps a given time range if the condition + for the corresponding component state specified in the table below + is satisfied. Note that, as specified in [RFC2445], the DTSTART + property is REQUIRED in the VEVENT component. The conditions + depend on the presence of the DTEND and DURATION properties in the + VEVENT component. Furthermore, the value of the DTEND property + + + +Daboo, et al. Standards Track [Page 90] + +RFC 4791 CalDAV March 2007 + + + MUST be later in time than the value of the DTSTART property. The + duration of a VEVENT component with no DTEND and DURATION + properties is 1 day (+P1D) when the DTSTART is a DATE value, and 0 + seconds when the DTSTART is a DATE-TIME value. + + +---------------------------------------------------------------+ + | VEVENT has the DTEND property? | + | +-----------------------------------------------------------+ + | | VEVENT has the DURATION property? | + | | +-------------------------------------------------------+ + | | | DURATION property value is greater than 0 seconds? | + | | | +---------------------------------------------------+ + | | | | DTSTART property is a DATE-TIME value? | + | | | | +-----------------------------------------------+ + | | | | | Condition to evaluate | + +---+---+---+---+-----------------------------------------------+ + | Y | N | N | * | (start < DTEND AND end > DTSTART) | + +---+---+---+---+-----------------------------------------------+ + | N | Y | Y | * | (start < DTSTART+DURATION AND end > DTSTART) | + | | +---+---+-----------------------------------------------+ + | | | N | * | (start <= DTSTART AND end > DTSTART) | + +---+---+---+---+-----------------------------------------------+ + | N | N | N | Y | (start <= DTSTART AND end > DTSTART) | + +---+---+---+---+-----------------------------------------------+ + | N | N | N | N | (start < DTSTART+P1D AND end > DTSTART) | + +---+---+---+---+-----------------------------------------------+ + + A VTODO component is said to overlap a given time range if the + condition for the corresponding component state specified in the + table below is satisfied. The conditions depend on the presence + of the DTSTART, DURATION, DUE, COMPLETED, and CREATED properties + in the VTODO component. Note that, as specified in [RFC2445], the + DUE value MUST be a DATE-TIME value equal to or after the DTSTART + value if specified. + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 91] + +RFC 4791 CalDAV March 2007 + + + +-------------------------------------------------------------------+ + | VTODO has the DTSTART property? | + | +---------------------------------------------------------------+ + | | VTODO has the DURATION property? | + | | +-----------------------------------------------------------+ + | | | VTODO has the DUE property? | + | | | +-------------------------------------------------------+ + | | | | VTODO has the COMPLETED property? | + | | | | +---------------------------------------------------+ + | | | | | VTODO has the CREATED property? | + | | | | | +-----------------------------------------------+ + | | | | | | Condition to evaluate | + +---+---+---+---+---+-----------------------------------------------+ + | Y | Y | N | * | * | (start <= DTSTART+DURATION) AND | + | | | | | | ((end > DTSTART) OR | + | | | | | | (end >= DTSTART+DURATION)) | + +---+---+---+---+---+-----------------------------------------------+ + | Y | N | Y | * | * | ((start < DUE) OR (start <= DTSTART)) | + | | | | | | AND | + | | | | | | ((end > DTSTART) OR (end >= DUE)) | + +---+---+---+---+---+-----------------------------------------------+ + | Y | N | N | * | * | (start <= DTSTART) AND (end > DTSTART) | + +---+---+---+---+---+-----------------------------------------------+ + | N | N | Y | * | * | (start < DUE) AND (end >= DUE) | + +---+---+---+---+---+-----------------------------------------------+ + | N | N | N | Y | Y | ((start <= CREATED) OR (start <= COMPLETED))| + | | | | | | AND | + | | | | | | ((end >= CREATED) OR (end >= COMPLETED))| + +---+---+---+---+---+-----------------------------------------------+ + | N | N | N | Y | N | (start <= COMPLETED) AND (end >= COMPLETED) | + +---+---+---+---+---+-----------------------------------------------+ + | N | N | N | N | Y | (end > CREATED) | + +---+---+---+---+---+-----------------------------------------------+ + | N | N | N | N | N | TRUE | + +---+---+---+---+---+-----------------------------------------------+ + + A VJOURNAL component overlaps a given time range if the condition + for the corresponding component state specified in the table below + is satisfied. The conditions depend on the presence of the + DTSTART property in the VJOURNAL component and on whether the + DTSTART is a DATE-TIME or DATE value. The effective "duration" of + a VJOURNAL component is 1 day (+P1D) when the DTSTART is a DATE + value, and 0 seconds when the DTSTART is a DATE-TIME value. + + + + + + + + +Daboo, et al. Standards Track [Page 92] + +RFC 4791 CalDAV March 2007 + + + +----------------------------------------------------+ + | VJOURNAL has the DTSTART property? | + | +------------------------------------------------+ + | | DTSTART property is a DATE-TIME value? | + | | +--------------------------------------------+ + | | | Condition to evaluate | + +---+---+--------------------------------------------+ + | Y | Y | (start <= DTSTART) AND (end > DTSTART) | + +---+---+--------------------------------------------+ + | Y | N | (start < DTSTART+P1D) AND (end > DTSTART) | + +---+---+--------------------------------------------+ + | N | * | FALSE | + +---+---+--------------------------------------------+ + + A VFREEBUSY component overlaps a given time range if the condition + for the corresponding component state specified in the table below + is satisfied. The conditions depend on the presence in the + VFREEBUSY component of the DTSTART and DTEND properties, and any + FREEBUSY properties in the absence of DTSTART and DTEND. Any + DURATION property is ignored, as it has a special meaning when + used in a VFREEBUSY component. + + When only FREEBUSY properties are used, each period in each + FREEBUSY property is compared against the time range, irrespective + of the type of free busy information (free, busy, busy-tentative, + busy-unavailable) represented by the property. + + + +------------------------------------------------------+ + | VFREEBUSY has both the DTSTART and DTEND properties? | + | +--------------------------------------------------+ + | | VFREEBUSY has the FREEBUSY property? | + | | +----------------------------------------------+ + | | | Condition to evaluate | + +---+---+----------------------------------------------+ + | Y | * | (start <= DTEND) AND (end > DTSTART) | + +---+---+----------------------------------------------+ + | N | Y | (start < freebusy-period-end) AND | + | | | (end > freebusy-period-start) | + +---+---+----------------------------------------------+ + | N | N | FALSE | + +---+---+----------------------------------------------+ + + A VALARM component is said to overlap a given time range if the + following condition holds: + + (start <= trigger-time) AND (end > trigger-time) + + + + +Daboo, et al. Standards Track [Page 93] + +RFC 4791 CalDAV March 2007 + + + A VALARM component can be defined such that it triggers repeatedly. + Such a VALARM component is said to overlap a given time range if at + least one of its triggers overlaps the time range. + + The calendar properties COMPLETED, CREATED, DTEND, DTSTAMP, + DTSTART, DUE, and LAST-MODIFIED overlap a given time range if the + following condition holds: + + (start <= date-time) AND (end > date-time) + + Note that if DTEND is not present in a VEVENT, but DURATION is, then + the test should instead operate on the 'effective' DTEND, i.e., + DTSTART+DURATION. Similarly, if DUE is not present in a VTODO, but + DTSTART and DURATION are, then the test should instead operate on the + 'effective' DUE, i.e., DTSTART+DURATION. + + The semantic of CALDAV:time-range is not defined for any other + calendar components and properties. + + Definition: + + + + + start value: an iCalendar "date with UTC time" + end value: an iCalendar "date with UTC time" + +9.10. CALDAV:calendar-multiget XML Element + + Name: calendar-multiget + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: CalDAV report used to retrieve specific calendar object + resources. + + Description: See Section 7.9. + + Definition: + + + + + + + + + +Daboo, et al. Standards Track [Page 94] + +RFC 4791 CalDAV March 2007 + + +9.11. CALDAV:free-busy-query XML Element + + Name: free-busy-query + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: CalDAV report used to generate a VFREEBUSY to determine + busy time over a specific time range. + + Description: See Section 7.10. + + Definition: + + + +10. Internationalization Considerations + + CalDAV allows internationalized strings to be stored and retrieved + for the description of calendar collections (see Section 5.2.1). + + The CALDAV:calendar-query REPORT (Section 7.8) includes a text + searching option controlled by the CALDAV:text-match element, and + details of character handling are covered in the description of that + element (see Section 9.7.5). + +11. Security Considerations + + HTTP protocol transactions are sent in the clear over the network + unless protection from snooping is negotiated. This can be + accomplished by use of TLS, as defined in [RFC2818]. In particular, + HTTP Basic authentication MUST NOT be used unless TLS is in effect. + + Servers MUST take adequate precautions to ensure that malicious + clients cannot consume excessive server resources (CPU, memory, disk, + etc.) through carefully crafted reports. For example, a client could + upload an event with a recurrence rule that specifies a recurring + event occurring every second for the next 100 years, which would + result in approximately 3 x 10^9 instances! A report that asks for + recurrences to be expanded over that range would likely constitute a + denial-of-service attack on the server. + + When creating new resources (including calendar collections), clients + MUST ensure that the resource name (the last path segment of the + resource URI) assigned to the new resource does not expose any data + from within the iCalendar resource itself or information about the + nature of a calendar collection. This is required to ensure that the + presence of a specific iCalendar component or nature of components in + a collection cannot be inferred based on the name of a resource. + + + +Daboo, et al. Standards Track [Page 95] + +RFC 4791 CalDAV March 2007 + + + When rolling up free-busy information, more information about a + user's events is exposed if busy periods overlap or are adjacent + (this tells the client requesting the free-busy information that the + calendar owner has at least two events, rather than knowing only that + the calendar owner has one or more events during the busy period). + Thus, a conservative approach to calendar data privacy would have + servers always coalesce such busy periods when they are the same + type. + + Procedure alarms are a known security risk for either clients or + servers to handle, particularly when the alarm was created by another + agent. Clients and servers are not required to execute such + procedure alarms. + + Security considerations described in iCalendar [RFC2445] and iTIP + [RFC2446] are also applicable to CalDAV. + + Beyond these, CalDAV does not raise any security considerations that + are not present in HTTP [RFC2616] and WebDAV [RFC2518], [RFC3253], + [RFC3744]. + +12. IANA Considerations + + This document uses one new URN to identify a new XML namespace. The + URN conforms to a registry mechanism described in [RFC3688]. + +12.1. Namespace Registration + + Registration request for the CalDAV namespace: + + URI: urn:ietf:params:xml:ns:caldav + + Registrant Contact: See the "Authors' Addresses" section of this + document. + + XML: None. Namespace URIs do not represent an XML specification. + +13. Acknowledgements + + The authors would like to thank the following individuals for + contributing their ideas and support for writing this specification: + Michael Arick, Mario Bonin, Chris Bryant, Scott Carr, Andre + Courtemanche, Mike Douglass, Ted Hardie, Marten den Haring, Jeffrey + Harris, Sam Hartman, Helge Hess, Jeff McCullough, Alexey Melnikov, + Dan Mosedale, Brian Moseley, Francois Perrault, Kervin L. Pierre, + Julian F. Reschke, Wilfredo Sanchez Vega, Mike Shaver, Jari + Urpalainen, Simon Vaillancourt, and Jim Whitehead. + + + + +Daboo, et al. Standards Track [Page 96] + +RFC 4791 CalDAV March 2007 + + + The authors would also like to thank the Calendaring and Scheduling + Consortium for advice with this specification, and for organizing + interoperability testing events to help refine it. + +14. References + +14.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to + Indicate Requirement Levels", BCP 14, + RFC 2119, March 1997. + + [RFC2246] Dierks, T. and C. Allen, "The TLS Protocol + Version 1.0", RFC 2246, January 1999. + + [RFC2445] Dawson, F. and Stenerson, D., "Internet + Calendaring and Scheduling Core Object + Specification (iCalendar)", RFC 2445, + November 1998. + + [RFC2446] Silverberg, S., Mansour, S., Dawson, F., and + R. Hopson, "iCalendar Transport-Independent + Interoperability Protocol (iTIP) Scheduling + Events, BusyTime, To-dos and Journal + Entries", RFC 2446, November 1998. + + [RFC2518] Goland, Y., Whitehead, E., Faizi, A., Carter, + S., and D. Jensen, "HTTP Extensions for + Distributed Authoring -- WEBDAV", RFC 2518, + February 1999. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, + H., Masinter, L., Leach, P., and T. Berners- + Lee, "Hypertext Transfer Protocol -- + HTTP/1.1", RFC 2616, June 1999. + + [RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, + May 2000. + + [RFC3253] Clemm, G., Amsden, J., Ellison, T., Kaler, + C., and J. Whitehead, "Versioning Extensions + to WebDAV (Web Distributed Authoring and + Versioning)", RFC 3253, March 2002. + + [RFC3688] Mealling, M., "The IETF XML Registry", + BCP 81, RFC 3688, January 2004. + + + + + +Daboo, et al. Standards Track [Page 97] + +RFC 4791 CalDAV March 2007 + + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. + Whitehead, "Web Distributed Authoring and + Versioning (WebDAV) Access Control Protocol", + RFC 3744, May 2004. + + [RFC4346] Dierks, T. and E. Rescorla, "The Transport + Layer Security (TLS) Protocol Version 1.1", + RFC 4346, April 2006. + + [RFC4790] Newman, C., Duerst, M., and A. Gulbrandsen, + "Internet Application Protocol Collation + Registry", RFC 4790, March 2007. + + [W3C.REC-xml-20060816] Paoli, J., Maler, E., Yergeau, F., Sperberg- + McQueen, C., and T. Bray, "Extensible Markup + Language (XML) 1.0 (Fourth Edition)", World + Wide Web Consortium Recommendation REC-xml- + 20060816, August 2006, + . + +14.2. Informative References + + [RFC2426] Dawson, F. and T. Howes, "vCard MIME + Directory Profile", RFC 2426, September 1998. + + [RFC2739] Small, T., Hennessy, D., and F. Dawson, + "Calendar Attributes for vCard and LDAP", + RFC 2739, January 2000. + + [RFC4331] Korver, B. and L. Dusseault, "Quota and Size + Properties for Distributed Authoring and + Versioning (DAV) Collections", RFC 4331, + February 2006. + + [RFC4511] Sermersheim, J., "Lightweight Directory + Access Protocol (LDAP): The Protocol", + RFC 4511, June 2006. + + [rfc2518bis] Dusseault, L., "HTTP Extensions for + Distributed Authoring - WebDAV", Work + in Progress, December 2006. + + + + + + + + + + +Daboo, et al. Standards Track [Page 98] + +RFC 4791 CalDAV March 2007 + + +Appendix A. CalDAV Method Privilege Table (Normative) + + The following table extends the WebDAV Method Privilege Table + specified in Appendix B of [RFC3744]. + + +------------+------------------------------------------------------+ + | METHOD | PRIVILEGES | + +------------+------------------------------------------------------+ + | MKCALENDAR | DAV:bind | + | REPORT | DAV:read or CALDAV:read-free-busy (on all referenced | + | | resources) | + +------------+------------------------------------------------------+ + +Appendix B. Calendar Collections Used in the Examples + + This appendix shows the calendar object resources contained in the + calendar collection queried in the examples throughout this document. + + The content of the calendar collection is being shown as if it were + returned by a CALDAV:calendar-query REPORT request designed to return + all the calendar data in the collection: + + >> Request << + + REPORT /bernard/work/ HTTP/1.1 + Host: cal.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + +Daboo, et al. Standards Track [Page 99] + +RFC 4791 CalDAV March 2007 + + + + + + + http://cal.example.com/bernard/work/abcd1.ics + + + "fffff-abcd1" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20060206T001102Z + DTSTART;TZID=US/Eastern:20060102T100000 + DURATION:PT1H + SUMMARY:Event #1 + Description:Go Steelers! + UID:74855313FA803DA593CD579A@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + http://cal.example.com/bernard/work/abcd2.ics + + + + +Daboo, et al. Standards Track [Page 100] + +RFC 4791 CalDAV March 2007 + + + + "fffff-abcd2" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060102T120000 + DURATION:PT1H + RRULE:FREQ=DAILY;COUNT=5 + SUMMARY:Event #2 + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + BEGIN:VEVENT + DTSTAMP:20060206T001121Z + DTSTART;TZID=US/Eastern:20060104T140000 + DURATION:PT1H + RECURRENCE-ID;TZID=US/Eastern:20060104T120000 + SUMMARY:Event #2 bis + UID:00959BC664CA650E933C892C@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + http://cal.example.com/bernard/work/abcd3.ics + + + +Daboo, et al. Standards Track [Page 101] + +RFC 4791 CalDAV March 2007 + + + + + "fffff-abcd3" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + LAST-MODIFIED:20040110T032845Z + TZID:US/Eastern + BEGIN:DAYLIGHT + DTSTART:20000404T020000 + RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + BEGIN:STANDARD + DTSTART:20001026T020000 + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com + ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com + DTSTAMP:20060206T001220Z + DTSTART;TZID=US/Eastern:20060104T100000 + DURATION:PT1H + LAST-MODIFIED:20060206T001330Z + ORGANIZER:mailto:cyrus@example.com + SEQUENCE:1 + STATUS:TENTATIVE + SUMMARY:Event #3 + UID:DC6C50A017428C5216A2F1CD@example.com + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + http://cal.example.com/bernard/work/abcd4.ics + + + + + +Daboo, et al. Standards Track [Page 102] + +RFC 4791 CalDAV March 2007 + + + "fffff-abcd4" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + DTSTAMP:20060205T235335Z + DUE;VALUE=DATE:20060104 + STATUS:NEEDS-ACTION + SUMMARY:Task #1 + UID:DDDEEB7915FA61233B861457@example.com + BEGIN:VALARM + ACTION:AUDIO + TRIGGER;RELATED=START:-PT10M + END:VALARM + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + http://cal.example.com/bernard/work/abcd5.ics + + + "fffff-abcd5" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + DTSTAMP:20060205T235300Z + DUE;VALUE=DATE:20060106 + LAST-MODIFIED:20060205T235308Z + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Task #2 + UID:E10BA47467C5C69BB74E8720@example.com + BEGIN:VALARM + ACTION:AUDIO + TRIGGER;RELATED=START:-PT10M + END:VALARM + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +Daboo, et al. Standards Track [Page 103] + +RFC 4791 CalDAV March 2007 + + + + + + http://cal.example.com/bernard/work/abcd6.ics + + + "fffff-abcd6" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + COMPLETED:20051223T122322Z + DTSTAMP:20060205T235400Z + DUE;VALUE=DATE:20051225 + LAST-MODIFIED:20060205T235308Z + SEQUENCE:1 + STATUS:COMPLETED + SUMMARY:Task #3 + UID:E10BA47467C5C69BB74E8722@example.com + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + http://cal.example.com/bernard/work/abcd7.ics + + + "fffff-abcd7" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTODO + DTSTAMP:20060205T235600Z + DUE;VALUE=DATE:20060101 + LAST-MODIFIED:20060205T235308Z + SEQUENCE:1 + STATUS:CANCELLED + SUMMARY:Task #4 + UID:E10BA47467C5C69BB74E8725@example.com + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + +Daboo, et al. Standards Track [Page 104] + +RFC 4791 CalDAV March 2007 + + + + + + + http://cal.example.com/bernard/work/abcd8.ics + + + "fffff-abcd8" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VFREEBUSY + ORGANIZER;CN="Bernard Desruisseaux":mailto:bernard@example.com + UID:76ef34-54a3d2@example.com + DTSTAMP:20050530T123421Z + DTSTART:20060101T000000Z + DTEND:20060108T000000Z + FREEBUSY:20050531T230000Z/20050601T010000Z + FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z + FREEBUSY:20060103T100000Z/20060103T120000Z + FREEBUSY:20060104T100000Z/20060104T120000Z + FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:20060105T100000Z/20060105T120000Z + FREEBUSY:20060106T100000Z/20060106T120000Z + END:VFREEBUSY + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 105] + +RFC 4791 CalDAV March 2007 + + +Authors' Addresses + + Cyrus Daboo + Apple Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + EMail: cyrus@daboo.name + URI: http://www.apple.com/ + + + Bernard Desruisseaux + Oracle Corporation + 600 Blvd. de Maisonneuve West + Suite 1900 + Montreal, QC H3A 3J2 + CANADA + + EMail: bernard.desruisseaux@oracle.com + URI: http://www.oracle.com/ + + + Lisa Dusseault + CommerceNet + 169 University Ave. + Palo Alto, CA 94301 + USA + + EMail: ldusseault@commerce.net + URI: http://commerce.net/ + + + + + + + + + + + + + + + + + + + + +Daboo, et al. Standards Track [Page 106] + +RFC 4791 CalDAV March 2007 + + +Full Copyright Statement + + Copyright (C) The IETF Trust (2007). + + This document is subject to the rights, licenses and restrictions + contained in BCP 78, and except as set forth therein, the authors + retain all their rights. + + This document and the information contained herein are provided on an + "AS IS" basis and THE CONTRIBUTOR, THE ORGANIZATION HE/SHE REPRESENTS + OR IS SPONSORED BY (IF ANY), THE INTERNET SOCIETY, THE IETF TRUST AND + THE INTERNET ENGINEERING TASK FORCE DISCLAIM ALL WARRANTIES, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF + THE INFORMATION HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED + WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +Intellectual Property + + The IETF takes no position regarding the validity or scope of any + Intellectual Property Rights or other rights that might be claimed to + pertain to the implementation or use of the technology described in + this document or the extent to which any license under such rights + might or might not be available; nor does it represent that it has + made any independent effort to identify any such rights. Information + on the procedures with respect to rights in RFC documents can be + found in BCP 78 and BCP 79. + + Copies of IPR disclosures made to the IETF Secretariat and any + assurances of licenses to be made available, or the result of an + attempt made to obtain a general license or permission for the use of + such proprietary rights by implementers or users of this + specification can be obtained from the IETF on-line IPR repository at + http://www.ietf.org/ipr. + + The IETF invites any interested party to bring to its attention any + copyrights, patents or patent applications, or other proprietary + rights that may cover technology that may be required to implement + this standard. Please address the information to the IETF at + ietf-ipr@ietf.org. + +Acknowledgement + + Funding for the RFC Editor function is currently provided by the + Internet Society. + + + + + + + +Daboo, et al. Standards Track [Page 107] + diff --git a/doc/rfc4918-webdav.txt b/doc/rfc4918-webdav.txt new file mode 100644 index 0000000..4ef181b --- /dev/null +++ b/doc/rfc4918-webdav.txt @@ -0,0 +1,7115 @@ + + + + + + +Network Working Group L. Dusseault, Ed. +Request for Comments: 4918 CommerceNet +Obsoletes: 2518 June 2007 +Category: Standards Track + + + HTTP Extensions for Web Distributed Authoring and Versioning (WebDAV) + +Status of This Memo + + This document specifies an Internet standards track protocol for the + Internet community, and requests discussion and suggestions for + improvements. Please refer to the current edition of the "Internet + Official Protocol Standards" (STD 1) for the standardization state + and status of this protocol. Distribution of this memo is unlimited. + +Copyright Notice + + Copyright (C) The IETF Trust (2007). + +Abstract + + Web Distributed Authoring and Versioning (WebDAV) consists of a set + of methods, headers, and content-types ancillary to HTTP/1.1 for the + management of resource properties, creation and management of + resource collections, URL namespace manipulation, and resource + locking (collision avoidance). + + RFC 2518 was published in February 1999, and this specification + obsoletes RFC 2518 with minor revisions mostly due to + interoperability experience. + + + + + + + + + + + + + + + + + + + + +Dusseault Standards Track [Page 1] + +RFC 4918 WebDAV June 2007 + + +Table of Contents + + 1. Introduction ....................................................7 + 2. Notational Conventions ..........................................8 + 3. Terminology .....................................................8 + 4. Data Model for Resource Properties .............................10 + 4.1. The Resource Property Model ...............................10 + 4.2. Properties and HTTP Headers ...............................10 + 4.3. Property Values ...........................................10 + 4.3.1. Example - Property with Mixed Content ..............12 + 4.4. Property Names ............................................14 + 4.5. Source Resources and Output Resources .....................14 + 5. Collections of Web Resources ...................................14 + 5.1. HTTP URL Namespace Model ..................................15 + 5.2. Collection Resources ......................................15 + 6. Locking ........................................................17 + 6.1. Lock Model ................................................18 + 6.2. Exclusive vs. Shared Locks ................................19 + 6.3. Required Support ..........................................20 + 6.4. Lock Creator and Privileges ...............................20 + 6.5. Lock Tokens ...............................................21 + 6.6. Lock Timeout ..............................................21 + 6.7. Lock Capability Discovery .................................22 + 6.8. Active Lock Discovery .....................................22 + 7. Write Lock .....................................................23 + 7.1. Write Locks and Properties ................................24 + 7.2. Avoiding Lost Updates .....................................24 + 7.3. Write Locks and Unmapped URLs .............................25 + 7.4. Write Locks and Collections ...............................26 + 7.5. Write Locks and the If Request Header .....................28 + 7.5.1. Example - Write Lock and COPY ......................28 + 7.5.2. Example - Deleting a Member of a Locked + Collection .........................................29 + 7.6. Write Locks and COPY/MOVE .................................30 + 7.7. Refreshing Write Locks ....................................30 + 8. General Request and Response Handling ..........................31 + 8.1. Precedence in Error Handling ..............................31 + 8.2. Use of XML ................................................31 + 8.3. URL Handling ..............................................32 + 8.3.1. Example - Correct URL Handling .....................32 + 8.4. Required Bodies in Requests ...............................33 + 8.5. HTTP Headers for Use in WebDAV ............................33 + 8.6. ETag ......................................................33 + 8.7. Including Error Response Bodies ...........................34 + 8.8. Impact of Namespace Operations on Cache Validators ........34 + 9. HTTP Methods for Distributed Authoring .........................35 + 9.1. PROPFIND Method ...........................................35 + 9.1.1. PROPFIND Status Codes ..............................37 + + + +Dusseault Standards Track [Page 2] + +RFC 4918 WebDAV June 2007 + + + 9.1.2. Status Codes for Use in 'propstat' Element .........37 + 9.1.3. Example - Retrieving Named Properties ..............38 + 9.1.4. Example - Using 'propname' to Retrieve All + Property Names .....................................39 + 9.1.5. Example - Using So-called 'allprop' ................41 + 9.1.6. Example - Using 'allprop' with 'include' ...........43 + 9.2. PROPPATCH Method ..........................................44 + 9.2.1. Status Codes for Use in 'propstat' Element .........44 + 9.2.2. Example - PROPPATCH ................................45 + 9.3. MKCOL Method ..............................................46 + 9.3.1. MKCOL Status Codes .................................47 + 9.3.2. Example - MKCOL ....................................47 + 9.4. GET, HEAD for Collections .................................48 + 9.5. POST for Collections ......................................48 + 9.6. DELETE Requirements .......................................48 + 9.6.1. DELETE for Collections .............................49 + 9.6.2. Example - DELETE ...................................49 + 9.7. PUT Requirements ..........................................50 + 9.7.1. PUT for Non-Collection Resources ...................50 + 9.7.2. PUT for Collections ................................51 + 9.8. COPY Method ...............................................51 + 9.8.1. COPY for Non-collection Resources ..................51 + 9.8.2. COPY for Properties ................................52 + 9.8.3. COPY for Collections ...............................52 + 9.8.4. COPY and Overwriting Destination Resources .........53 + 9.8.5. Status Codes .......................................54 + 9.8.6. Example - COPY with Overwrite ......................55 + 9.8.7. Example - COPY with No Overwrite ...................55 + 9.8.8. Example - COPY of a Collection .....................56 + 9.9. MOVE Method ...............................................56 + 9.9.1. MOVE for Properties ................................57 + 9.9.2. MOVE for Collections ...............................57 + 9.9.3. MOVE and the Overwrite Header ......................58 + 9.9.4. Status Codes .......................................59 + 9.9.5. Example - MOVE of a Non-Collection .................60 + 9.9.6. Example - MOVE of a Collection .....................60 + 9.10. LOCK Method ..............................................61 + 9.10.1. Creating a Lock on an Existing Resource ...........61 + 9.10.2. Refreshing Locks ..................................62 + 9.10.3. Depth and Locking .................................62 + 9.10.4. Locking Unmapped URLs .............................63 + 9.10.5. Lock Compatibility Table ..........................63 + 9.10.6. LOCK Responses ....................................63 + 9.10.7. Example - Simple Lock Request .....................64 + 9.10.8. Example - Refreshing a Write Lock .................65 + 9.10.9. Example - Multi-Resource Lock Request .............66 + 9.11. UNLOCK Method ............................................68 + 9.11.1. Status Codes ......................................68 + + + +Dusseault Standards Track [Page 3] + +RFC 4918 WebDAV June 2007 + + + 9.11.2. Example - UNLOCK ..................................69 + 10. HTTP Headers for Distributed Authoring ........................69 + 10.1. DAV Header ...............................................69 + 10.2. Depth Header .............................................70 + 10.3. Destination Header .......................................71 + 10.4. If Header ................................................72 + 10.4.1. Purpose ...........................................72 + 10.4.2. Syntax ............................................72 + 10.4.3. List Evaluation ...................................73 + 10.4.4. Matching State Tokens and ETags ...................74 + 10.4.5. If Header and Non-DAV-Aware Proxies ...............74 + 10.4.6. Example - No-tag Production .......................75 + 10.4.7. Example - Using "Not" with No-tag Production ......75 + 10.4.8. Example - Causing a Condition to Always + Evaluate to True ..................................75 + 10.4.9. Example - Tagged List If Header in COPY ...........76 + 10.4.10. Example - Matching Lock Tokens with + Collection Locks .................................76 + 10.4.11. Example - Matching ETags on Unmapped URLs ........76 + 10.5. Lock-Token Header ........................................77 + 10.6. Overwrite Header .........................................77 + 10.7. Timeout Request Header ...................................78 + 11. Status Code Extensions to HTTP/1.1 ............................78 + 11.1. 207 Multi-Status .........................................78 + 11.2. 422 Unprocessable Entity .................................78 + 11.3. 423 Locked ...............................................78 + 11.4. 424 Failed Dependency ....................................79 + 11.5. 507 Insufficient Storage .................................79 + 12. Use of HTTP Status Codes ......................................79 + 12.1. 412 Precondition Failed ..................................79 + 12.2. 414 Request-URI Too Long .................................79 + 13. Multi-Status Response .........................................80 + 13.1. Response Headers .........................................80 + 13.2. Handling Redirected Child Resources ......................81 + 13.3. Internal Status Codes ....................................81 + 14. XML Element Definitions .......................................81 + 14.1. activelock XML Element ...................................81 + 14.2. allprop XML Element ......................................82 + 14.3. collection XML Element ...................................82 + 14.4. depth XML Element ........................................82 + 14.5. error XML Element ........................................82 + 14.6. exclusive XML Element ....................................83 + 14.7. href XML Element .........................................83 + 14.8. include XML Element ......................................83 + 14.9. location XML Element .....................................83 + 14.10. lockentry XML Element ...................................84 + 14.11. lockinfo XML Element ....................................84 + 14.12. lockroot XML Element ....................................84 + + + +Dusseault Standards Track [Page 4] + +RFC 4918 WebDAV June 2007 + + + 14.13. lockscope XML Element ...................................84 + 14.14. locktoken XML Element ...................................85 + 14.15. locktype XML Element ....................................85 + 14.16. multistatus XML Element .................................85 + 14.17. owner XML Element .......................................85 + 14.18. prop XML Element ........................................86 + 14.19. propertyupdate XML Element ..............................86 + 14.20. propfind XML Element ....................................86 + 14.21. propname XML Element ....................................87 + 14.22. propstat XML Element ....................................87 + 14.23. remove XML Element ......................................87 + 14.24. response XML Element ....................................88 + 14.25. responsedescription XML Element .........................88 + 14.26. set XML Element .........................................88 + 14.27. shared XML Element ......................................89 + 14.28. status XML Element ......................................89 + 14.29. timeout XML Element .....................................89 + 14.30. write XML Element .......................................89 + 15. DAV Properties ................................................90 + 16. Precondition/Postcondition XML Elements .......................98 + 17. XML Extensibility in DAV .....................................101 + 18. DAV Compliance Classes .......................................103 + 18.1. Class 1 .................................................103 + 18.2. Class 2 .................................................103 + 18.3. Class 3 .................................................103 + 19. Internationalization Considerations ..........................104 + 20. Security Considerations ......................................105 + 20.1. Authentication of Clients ...............................105 + 20.2. Denial of Service .......................................106 + 20.3. Security through Obscurity ..............................106 + 20.4. Privacy Issues Connected to Locks .......................106 + 20.5. Privacy Issues Connected to Properties ..................107 + 20.6. Implications of XML Entities ............................107 + 20.7. Risks Connected with Lock Tokens ........................108 + 20.8. Hosting Malicious Content ...............................108 + 21. IANA Considerations ..........................................109 + 21.1. New URI Schemes .........................................109 + 21.2. XML Namespaces ..........................................109 + 21.3. Message Header Fields ...................................109 + 21.3.1. DAV ..............................................109 + 21.3.2. Depth ............................................110 + 21.3.3. Destination ......................................110 + 21.3.4. If ...............................................110 + 21.3.5. Lock-Token .......................................110 + 21.3.6. Overwrite ........................................111 + 21.3.7. Timeout ..........................................111 + 21.4. HTTP Status Codes .......................................111 + 22. Acknowledgements .............................................112 + + + +Dusseault Standards Track [Page 5] + +RFC 4918 WebDAV June 2007 + + + 23. Contributors to This Specification ...........................113 + 24. Authors of RFC 2518 ..........................................113 + 25. References ...................................................114 + 25.1. Normative References.....................................114 + 25.2. Informative References ..................................115 + Appendix A. Notes on Processing XML Elements ....................117 + A.1. Notes on Empty XML Elements ..............................117 + A.2. Notes on Illegal XML Processing ..........................117 + A.3. Example - XML Syntax Error ...............................117 + A.4. Example - Unexpected XML Element .........................118 + Appendix B. Notes on HTTP Client Compatibility ...................119 + Appendix C. The 'opaquelocktoken' Scheme and URIs ................120 + Appendix D. Lock-null Resources ..................................120 + D.1. Guidance for Clients Using LOCK to Create Resources ......121 + Appendix E. Guidance for Clients Desiring to Authenticate ........121 + Appendix F. Summary of Changes from RFC 2518 .....................123 + F.1. Changes for Both Client and Server Implementations .......123 + F.2. Changes for Server Implementations .......................125 + F.3. Other Changes ............................................126 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Dusseault Standards Track [Page 6] + +RFC 4918 WebDAV June 2007 + + +1. Introduction + + This document describes an extension to the HTTP/1.1 protocol that + allows clients to perform remote Web content authoring operations. + This extension provides a coherent set of methods, headers, request + entity body formats, and response entity body formats that provide + operations for: + + Properties: The ability to create, remove, and query information + about Web pages, such as their authors, creation dates, etc. + + Collections: The ability to create sets of documents and to retrieve + a hierarchical membership listing (like a directory listing in a file + system). + + Locking: The ability to keep more than one person from working on a + document at the same time. This prevents the "lost update problem", + in which modifications are lost as first one author, then another, + writes changes without merging the other author's changes. + + Namespace Operations: The ability to instruct the server to copy and + move Web resources, operations that change the mapping from URLs to + resources. + + Requirements and rationale for these operations are described in a + companion document, "Requirements for a Distributed Authoring and + Versioning Protocol for the World Wide Web" [RFC2291]. + + This document does not specify the versioning operations suggested by + [RFC2291]. That work was done in a separate document, "Versioning + Extensions to WebDAV" [RFC3253]. + + The sections below provide a detailed introduction to various WebDAV + abstractions: resource properties (Section 4), collections of + resources (Section 5), locks (Section 6) in general, and write locks + (Section 7) specifically. + + These abstractions are manipulated by the WebDAV-specific HTTP + methods (Section 9) and the extra HTTP headers (Section 10) used with + WebDAV methods. General considerations for handling HTTP requests + and responses in WebDAV are found in Section 8. + + While the status codes provided by HTTP/1.1 are sufficient to + describe most error conditions encountered by WebDAV methods, there + are some errors that do not fall neatly into the existing categories. + This specification defines extra status codes developed for WebDAV + methods (Section 11) and describes existing HTTP status codes + (Section 12) as used in WebDAV. Since some WebDAV methods may + + + +Dusseault Standards Track [Page 7] + +RFC 4918 WebDAV June 2007 + + + operate over many resources, the Multi-Status response (Section 13) + has been introduced to return status information for multiple + resources. Finally, this version of WebDAV introduces precondition + and postcondition (Section 16) XML elements in error response bodies. + + WebDAV uses XML ([REC-XML]) for property names and some values, and + also uses XML to marshal complicated requests and responses. This + specification contains DTD and text definitions of all properties + (Section 15) and all other XML elements (Section 14) used in + marshalling. WebDAV includes a few special rules on extending WebDAV + XML marshalling in backwards-compatible ways (Section 17). + + Finishing off the specification are sections on what it means for a + resource to be compliant with this specification (Section 18), on + internationalization support (Section 19), and on security + (Section 20). + +2. Notational Conventions + + Since this document describes a set of extensions to the HTTP/1.1 + protocol, the augmented BNF used herein to describe protocol elements + is exactly the same as described in Section 2.1 of [RFC2616], + including the rules about implied linear whitespace. Since this + augmented BNF uses the basic production rules provided in Section 2.2 + of [RFC2616], these rules apply to this document as well. Note this + is not the standard BNF syntax used in other RFCs. + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + Note that in natural language, a property like the "creationdate" + property in the "DAV:" XML namespace is sometimes referred to as + "DAV:creationdate" for brevity. + +3. Terminology + + URI/URL - A Uniform Resource Identifier and Uniform Resource Locator, + respectively. These terms (and the distinction between them) are + defined in [RFC3986]. + + URI/URL Mapping - A relation between an absolute URI and a resource. + Since a resource can represent items that are not network + retrievable, as well as those that are, it is possible for a resource + to have zero, one, or many URI mappings. Mapping a resource to an + "http" scheme URI makes it possible to submit HTTP protocol requests + to the resource using the URI. + + + + +Dusseault Standards Track [Page 8] + +RFC 4918 WebDAV June 2007 + + + Path Segment - Informally, the characters found between slashes ("/") + in a URI. Formally, as defined in Section 3.3 of [RFC3986]. + + Collection - Informally, a resource that also acts as a container of + references to child resources. Formally, a resource that contains a + set of mappings between path segments and resources and meets the + requirements defined in Section 5. + + Internal Member (of a Collection) - Informally, a child resource of a + collection. Formally, a resource referenced by a path segment + mapping contained in the collection. + + Internal Member URL (of a Collection) - A URL of an internal member, + consisting of the URL of the collection (including trailing slash) + plus the path segment identifying the internal member. + + Member (of a Collection) - Informally, a "descendant" of a + collection. Formally, an internal member of the collection, or, + recursively, a member of an internal member. + + Member URL (of a Collection) - A URL that is either an internal + member URL of the collection itself, or is an internal member URL of + a member of that collection. + + Property - A name/value pair that contains descriptive information + about a resource. + + Live Property - A property whose semantics and syntax are enforced by + the server. For example, the live property DAV:getcontentlength has + its value, the length of the entity returned by a GET request, + automatically calculated by the server. + + Dead Property - A property whose semantics and syntax are not + enforced by the server. The server only records the value of a dead + property; the client is responsible for maintaining the consistency + of the syntax and semantics of a dead property. + + Principal - A distinct human or computational actor that initiates + access to network resources. + + State Token - A URI that represents a state of a resource. Lock + tokens are the only state tokens defined in this specification. + + + + + + + + + +Dusseault Standards Track [Page 9] + +RFC 4918 WebDAV June 2007 + + +4. Data Model for Resource Properties + +4.1. The Resource Property Model + + Properties are pieces of data that describe the state of a resource. + Properties are data about data. + + Properties are used in distributed authoring environments to provide + for efficient discovery and management of resources. For example, a + 'subject' property might allow for the indexing of all resources by + their subject, and an 'author' property might allow for the discovery + of what authors have written which documents. + + The DAV property model consists of name/value pairs. The name of a + property identifies the property's syntax and semantics, and provides + an address by which to refer to its syntax and semantics. + + There are two categories of properties: "live" and "dead". A live + property has its syntax and semantics enforced by the server. Live + properties include cases where a) the value of a property is + protected and maintained by the server, and b) the value of the + property is maintained by the client, but the server performs syntax + checking on submitted values. All instances of a given live property + MUST comply with the definition associated with that property name. + A dead property has its syntax and semantics enforced by the client; + the server merely records the value of the property verbatim. + +4.2. Properties and HTTP Headers + + Properties already exist, in a limited sense, in HTTP message + headers. However, in distributed authoring environments, a + relatively large number of properties are needed to describe the + state of a resource, and setting/returning them all through HTTP + headers is inefficient. Thus, a mechanism is needed that allows a + principal to identify a set of properties in which the principal is + interested and to set or retrieve just those properties. + +4.3. Property Values + + The value of a property is always a (well-formed) XML fragment. + + XML has been chosen because it is a flexible, self-describing, + structured data format that supports rich schema definitions, and + because of its support for multiple character sets. XML's self- + describing nature allows any property's value to be extended by + adding elements. Clients will not break when they encounter + extensions because they will still have the data specified in the + original schema and MUST ignore elements they do not understand. + + + +Dusseault Standards Track [Page 10] + +RFC 4918 WebDAV June 2007 + + + XML's support for multiple character sets allows any human-readable + property to be encoded and read in a character set familiar to the + user. XML's support for multiple human languages, using the "xml: + lang" attribute, handles cases where the same character set is + employed by multiple human languages. Note that xml:lang scope is + recursive, so an xml:lang attribute on any element containing a + property name element applies to the property value unless it has + been overridden by a more locally scoped attribute. Note that a + property only has one value, in one language (or language MAY be left + undefined); a property does not have multiple values in different + languages or a single value in multiple languages. + + A property is always represented with an XML element consisting of + the property name, called the "property name element". The simplest + example is an empty property, which is different from a property that + does not exist: + + + + The value of the property appears inside the property name element. + The value may be any kind of well-formed XML content, including both + text-only and mixed content. Servers MUST preserve the following XML + Information Items (using the terminology from [REC-XML-INFOSET]) in + storage and transmission of dead properties: + + For the property name Element Information Item itself: + + [namespace name] + + [local name] + + [attributes] named "xml:lang" or any such attribute in scope + + [children] of type element or character + + On all Element Information Items in the property value: + + [namespace name] + + [local name] + + [attributes] + + [children] of type element or character + + + + + + + +Dusseault Standards Track [Page 11] + +RFC 4918 WebDAV June 2007 + + + On Attribute Information Items in the property value: + + [namespace name] + + [local name] + + [normalized value] + + On Character Information Items in the property value: + + [character code] + + Since prefixes are used in some XML vocabularies (XPath and XML + Schema, for example), servers SHOULD preserve, for any Information + Item in the value: + + [prefix] + + XML Infoset attributes not listed above MAY be preserved by the + server, but clients MUST NOT rely on them being preserved. The above + rules would also apply by default to live properties, unless defined + otherwise. + + Servers MUST ignore the XML attribute xml:space if present and never + use it to change whitespace handling. Whitespace in property values + is significant. + +4.3.1. Example - Property with Mixed Content + + Consider a dead property 'author' created by the client as follows: + + + + Jane Doe + + mailto:jane.doe@example.com + http://www.example.com + + Jane has been working way too long on the + long-awaited revision of ]]>. + + + + + + + + + +Dusseault Standards Track [Page 12] + +RFC 4918 WebDAV June 2007 + + + When this property is requested, a server might return: + + + Jane Doe + mailto:jane.doe@example.com + http://www.example.com + + Jane has been working way too long on the + long-awaited revision of <RFC2518>. + + + + + Note in this example: + + o The [prefix] for the property name itself was not preserved, being + non-significant, whereas all other [prefix] values have been + preserved, + + o attribute values have been rewritten with double quotes instead of + single quotes (quoting style is not significant), and attribute + order has not been preserved, + + o the xml:lang attribute has been returned on the property name + element itself (it was in scope when the property was set, but the + exact position in the response is not considered significant as + long as it is in scope), + + o whitespace between tags has been preserved everywhere (whitespace + between attributes not so), + + o CDATA encapsulation was replaced with character escaping (the + reverse would also be legal), + + o the comment item was stripped (as would have been a processing + instruction item). + + Implementation note: there are cases such as editing scenarios where + clients may require that XML content is preserved character by + character (such as attribute ordering or quoting style). In this + case, clients should consider using a text-only property value by + escaping all characters that have a special meaning in XML parsing. + + + +Dusseault Standards Track [Page 13] + +RFC 4918 WebDAV June 2007 + + +4.4. Property Names + + A property name is a universally unique identifier that is associated + with a schema that provides information about the syntax and + semantics of the property. + + Because a property's name is universally unique, clients can depend + upon consistent behavior for a particular property across multiple + resources, on the same and across different servers, so long as that + property is "live" on the resources in question, and the + implementation of the live property is faithful to its definition. + + The XML namespace mechanism, which is based on URIs ([RFC3986]), is + used to name properties because it prevents namespace collisions and + provides for varying degrees of administrative control. + + The property namespace is flat; that is, no hierarchy of properties + is explicitly recognized. Thus, if a property A and a property A/B + exist on a resource, there is no recognition of any relationship + between the two properties. It is expected that a separate + specification will eventually be produced that will address issues + relating to hierarchical properties. + + Finally, it is not possible to define the same property twice on a + single resource, as this would cause a collision in the resource's + property namespace. + +4.5. Source Resources and Output Resources + + Some HTTP resources are dynamically generated by the server. For + these resources, there presumably exists source code somewhere + governing how that resource is generated. The relationship of source + files to output HTTP resources may be one to one, one to many, many + to one, or many to many. There is no mechanism in HTTP to determine + whether a resource is even dynamic, let alone where its source files + exist or how to author them. Although this problem would usefully be + solved, interoperable WebDAV implementations have been widely + deployed without actually solving this problem, by dealing only with + static resources. Thus, the source vs. output problem is not solved + in this specification and has been deferred to a separate document. + +5. Collections of Web Resources + + This section provides a description of a type of Web resource, the + collection, and discusses its interactions with the HTTP URL + namespace and with HTTP methods. The purpose of a collection + resource is to model collection-like objects (e.g., file system + directories) within a server's namespace. + + + +Dusseault Standards Track [Page 14] + +RFC 4918 WebDAV June 2007 + + + All DAV-compliant resources MUST support the HTTP URL namespace model + specified herein. + +5.1. HTTP URL Namespace Model + + The HTTP URL namespace is a hierarchical namespace where the + hierarchy is delimited with the "/" character. + + An HTTP URL namespace is said to be consistent if it meets the + following conditions: for every URL in the HTTP hierarchy there + exists a collection that contains that URL as an internal member URL. + The root, or top-level collection of the namespace under + consideration, is exempt from the previous rule. The top-level + collection of the namespace under consideration is not necessarily + the collection identified by the absolute path '/' -- it may be + identified by one or more path segments (e.g., /servlets/webdav/...) + + Neither HTTP/1.1 nor WebDAV requires that the entire HTTP URL + namespace be consistent -- a WebDAV-compatible resource may not have + a parent collection. However, certain WebDAV methods are prohibited + from producing results that cause namespace inconsistencies. + + As is implicit in [RFC2616] and [RFC3986], any resource, including + collection resources, MAY be identified by more than one URI. For + example, a resource could be identified by multiple HTTP URLs. + +5.2. Collection Resources + + Collection resources differ from other resources in that they also + act as containers. Some HTTP methods apply only to a collection, but + some apply to some or all of the resources inside the container + defined by the collection. When the scope of a method is not clear, + the client can specify what depth to apply. Depth can be either zero + levels (only the collection), one level (the collection and directly + contained resources), or infinite levels (the collection and all + contained resources recursively). + + A collection's state consists of at least a set of mappings between + path segments and resources, and a set of properties on the + collection itself. In this document, a resource B will be said to be + contained in the collection resource A if there is a path segment + mapping that maps to B and that is contained in A. A collection MUST + contain at most one mapping for a given path segment, i.e., it is + illegal to have the same path segment mapped to more than one + resource. + + + + + + +Dusseault Standards Track [Page 15] + +RFC 4918 WebDAV June 2007 + + + Properties defined on collections behave exactly as do properties on + non-collection resources. A collection MAY have additional state + such as entity bodies returned by GET. + + For all WebDAV-compliant resources A and B, identified by URLs "U" + and "V", respectively, such that "V" is equal to "U/SEGMENT", A MUST + be a collection that contains a mapping from "SEGMENT" to B. So, if + resource B with URL "http://example.com/bar/blah" is WebDAV compliant + and if resource A with URL "http://example.com/bar/" is WebDAV + compliant, then resource A must be a collection and must contain + exactly one mapping from "blah" to B. + + Although commonly a mapping consists of a single segment and a + resource, in general, a mapping consists of a set of segments and a + resource. This allows a server to treat a set of segments as + equivalent (i.e., either all of the segments are mapped to the same + resource, or none of the segments are mapped to a resource). For + example, a server that performs case-folding on segments will treat + the segments "ab", "Ab", "aB", and "AB" as equivalent. A client can + then use any of these segments to identify the resource. Note that a + PROPFIND result will select one of these equivalent segments to + identify the mapping, so there will be one PROPFIND response element + per mapping, not one per segment in the mapping. + + Collection resources MAY have mappings to non-WebDAV-compliant + resources in the HTTP URL namespace hierarchy but are not required to + do so. For example, if resource X with URL + "http://example.com/bar/blah" is not WebDAV compliant and resource A + with "URL http://example.com/bar/" identifies a WebDAV collection, + then A may or may not have a mapping from "blah" to X. + + If a WebDAV-compliant resource has no WebDAV-compliant internal + members in the HTTP URL namespace hierarchy, then the WebDAV- + compliant resource is not required to be a collection. + + There is a standing convention that when a collection is referred to + by its name without a trailing slash, the server MAY handle the + request as if the trailing slash were present. In this case, it + SHOULD return a Content-Location header in the response, pointing to + the URL ending with the "/". For example, if a client invokes a + method on http://example.com/blah (no trailing slash), the server may + respond as if the operation were invoked on http://example.com/blah/ + (trailing slash), and should return a Content-Location header with + the value http://example.com/blah/. Wherever a server produces a URL + referring to a collection, the server SHOULD include the trailing + slash. In general, clients SHOULD use the trailing slash form of + collection names. If clients do not use the trailing slash form the + client needs to be prepared to see a redirect response. Clients will + + + +Dusseault Standards Track [Page 16] + +RFC 4918 WebDAV June 2007 + + + find the DAV:resourcetype property more reliable than the URL to find + out if a resource is a collection. + + Clients MUST be able to support the case where WebDAV resources are + contained inside non-WebDAV resources. For example, if an OPTIONS + response from "http://example.com/servlet/dav/collection" indicates + WebDAV support, the client cannot assume that + "http://example.com/servlet/dav/" or its parent necessarily are + WebDAV collections. + + A typical scenario in which mapped URLs do not appear as members of + their parent collection is the case where a server allows links or + redirects to non-WebDAV resources. For instance, "/col/link" might + not appear as a member of "/col/", although the server would respond + with a 302 status to a GET request to "/col/link"; thus, the URL + "/col/link" would indeed be mapped. Similarly, a dynamically- + generated page might have a URL mapping from "/col/index.html", thus + this resource might respond with a 200 OK to a GET request yet not + appear as a member of "/col/". + + Some mappings to even WebDAV-compliant resources might not appear in + the parent collection. An example for this case are servers that + support multiple alias URLs for each WebDAV-compliant resource. A + server may implement case-insensitive URLs, thus "/col/a" and + "/col/A" identify the same resource, yet only either "a" or "A" is + reported upon listing the members of "/col". In cases where a server + treats a set of segments as equivalent, the server MUST expose only + one preferred segment per mapping, consistently chosen, in PROPFIND + responses. + +6. Locking + + The ability to lock a resource provides a mechanism for serializing + access to that resource. Using a lock, an authoring client can + provide a reasonable guarantee that another principal will not modify + a resource while it is being edited. In this way, a client can + prevent the "lost update" problem. + + This specification allows locks to vary over two client-specified + parameters, the number of principals involved (exclusive vs. shared) + and the type of access to be granted. This document defines locking + for only one access type, write. However, the syntax is extensible, + and permits the eventual specification of locking for other access + types. + + + + + + + +Dusseault Standards Track [Page 17] + +RFC 4918 WebDAV June 2007 + + +6.1. Lock Model + + This section provides a concise model for how locking behaves. Later + sections will provide more detail on some of the concepts and refer + back to these model statements. Normative statements related to LOCK + and UNLOCK method handling can be found in the sections on those + methods, whereas normative statements that cover any method are + gathered here. + + 1. A lock either directly or indirectly locks a resource. + + 2. A resource becomes directly locked when a LOCK request to a URL + of that resource creates a new lock. The "lock-root" of the new + lock is that URL. If at the time of the request, the URL is not + mapped to a resource, a new empty resource is created and + directly locked. + + 3. An exclusive lock (Section 6.2) conflicts with any other kind of + lock on the same resource, whether either lock is direct or + indirect. A server MUST NOT create conflicting locks on a + resource. + + 4. For a collection that is locked with a depth-infinity lock L, all + member resources are indirectly locked. Changes in membership of + such a collection affect the set of indirectly locked resources: + + * If a member resource is added to the collection, the new + member resource MUST NOT already have a conflicting lock, + because the new resource MUST become indirectly locked by L. + + * If a member resource stops being a member of the collection, + then the resource MUST no longer be indirectly locked by L. + + 5. Each lock is identified by a single globally unique lock token + (Section 6.5). + + 6. An UNLOCK request deletes the lock with the specified lock token. + After a lock is deleted, no resource is locked by that lock. + + 7. A lock token is "submitted" in a request when it appears in an + "If" header (Section 7, "Write Lock", discusses when token + submission is required for write locks). + + 8. If a request causes the lock-root of any lock to become an + unmapped URL, then the lock MUST also be deleted by that request. + + + + + + +Dusseault Standards Track [Page 18] + +RFC 4918 WebDAV June 2007 + + +6.2. Exclusive vs. Shared Locks + + The most basic form of lock is an exclusive lock. Exclusive locks + avoid having to deal with content change conflicts, without requiring + any coordination other than the methods described in this + specification. + + However, there are times when the goal of a lock is not to exclude + others from exercising an access right but rather to provide a + mechanism for principals to indicate that they intend to exercise + their access rights. Shared locks are provided for this case. A + shared lock allows multiple principals to receive a lock. Hence any + principal that has both access privileges and a valid lock can use + the locked resource. + + With shared locks, there are two trust sets that affect a resource. + The first trust set is created by access permissions. Principals who + are trusted, for example, may have permission to write to the + resource. Among those who have access permission to write to the + resource, the set of principals who have taken out a shared lock also + must trust each other, creating a (typically) smaller trust set + within the access permission write set. + + Starting with every possible principal on the Internet, in most + situations the vast majority of these principals will not have write + access to a given resource. Of the small number who do have write + access, some principals may decide to guarantee their edits are free + from overwrite conflicts by using exclusive write locks. Others may + decide they trust their collaborators will not overwrite their work + (the potential set of collaborators being the set of principals who + have write permission) and use a shared lock, which informs their + collaborators that a principal may be working on the resource. + + The WebDAV extensions to HTTP do not need to provide all of the + communications paths necessary for principals to coordinate their + activities. When using shared locks, principals may use any out-of- + band communication channel to coordinate their work (e.g., face-to- + face interaction, written notes, post-it notes on the screen, + telephone conversation, email, etc.) The intent of a shared lock is + to let collaborators know who else may be working on a resource. + + Shared locks are included because experience from Web-distributed + authoring systems has indicated that exclusive locks are often too + rigid. An exclusive lock is used to enforce a particular editing + process: take out an exclusive lock, read the resource, perform + edits, write the resource, release the lock. This editing process + has the problem that locks are not always properly released, for + example, when a program crashes or when a lock creator leaves without + + + +Dusseault Standards Track [Page 19] + +RFC 4918 WebDAV June 2007 + + + unlocking a resource. While both timeouts (Section 6.6) and + administrative action can be used to remove an offending lock, + neither mechanism may be available when needed; the timeout may be + long or the administrator may not be available. + + A successful request for a new shared lock MUST result in the + generation of a unique lock associated with the requesting principal. + Thus, if five principals have taken out shared write locks on the + same resource, there will be five locks and five lock tokens, one for + each principal. + +6.3. Required Support + + A WebDAV-compliant resource is not required to support locking in any + form. If the resource does support locking, it may choose to support + any combination of exclusive and shared locks for any access types. + + The reason for this flexibility is that locking policy strikes to the + very heart of the resource management and versioning systems employed + by various storage repositories. These repositories require control + over what sort of locking will be made available. For example, some + repositories only support shared write locks, while others only + provide support for exclusive write locks, while yet others use no + locking at all. As each system is sufficiently different to merit + exclusion of certain locking features, this specification leaves + locking as the sole axis of negotiation within WebDAV. + +6.4. Lock Creator and Privileges + + The creator of a lock has special privileges to use the lock to + modify the resource. When a locked resource is modified, a server + MUST check that the authenticated principal matches the lock creator + (in addition to checking for valid lock token submission). + + The server MAY allow privileged users other than the lock creator to + destroy a lock (for example, the resource owner or an administrator). + The 'unlock' privilege in [RFC3744] was defined to provide that + permission. + + There is no requirement for servers to accept LOCK requests from all + users or from anonymous users. + + Note that having a lock does not confer full privilege to modify the + locked resource. Write access and other privileges MUST be enforced + through normal privilege or authentication mechanisms, not based on + the possible obscurity of lock token values. + + + + + +Dusseault Standards Track [Page 20] + +RFC 4918 WebDAV June 2007 + + +6.5. Lock Tokens + + A lock token is a type of state token that identifies a particular + lock. Each lock has exactly one unique lock token generated by the + server. Clients MUST NOT attempt to interpret lock tokens in any + way. + + Lock token URIs MUST be unique across all resources for all time. + This uniqueness constraint allows lock tokens to be submitted across + resources and servers without fear of confusion. Since lock tokens + are unique, a client MAY submit a lock token in an If header on a + resource other than the one that returned it. + + When a LOCK operation creates a new lock, the new lock token is + returned in the Lock-Token response header defined in Section 10.5, + and also in the body of the response. + + Servers MAY make lock tokens publicly readable (e.g., in the DAV: + lockdiscovery property). One use case for making lock tokens + readable is so that a long-lived lock can be removed by the resource + owner (the client that obtained the lock might have crashed or + disconnected before cleaning up the lock). Except for the case of + using UNLOCK under user guidance, a client SHOULD NOT use a lock + token created by another client instance. + + This specification encourages servers to create Universally Unique + Identifiers (UUIDs) for lock tokens, and to use the URI form defined + by "A Universally Unique Identifier (UUID) URN Namespace" + ([RFC4122]). However, servers are free to use any URI (e.g., from + another scheme) so long as it meets the uniqueness requirements. For + example, a valid lock token might be constructed using the + "opaquelocktoken" scheme defined in Appendix C. + + Example: "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6" + +6.6. Lock Timeout + + A lock MAY have a limited lifetime. The lifetime is suggested by the + client when creating or refreshing the lock, but the server + ultimately chooses the timeout value. Timeout is measured in seconds + remaining until lock expiration. + + The timeout counter MUST be restarted if a refresh lock request is + successful (see Section 9.10.2). The timeout counter SHOULD NOT be + restarted at any other time. + + If the timeout expires, then the lock SHOULD be removed. In this + case the server SHOULD act as if an UNLOCK method was executed by the + + + +Dusseault Standards Track [Page 21] + +RFC 4918 WebDAV June 2007 + + + server on the resource using the lock token of the timed-out lock, + performed with its override authority. + + Servers are advised to pay close attention to the values submitted by + clients, as they will be indicative of the type of activity the + client intends to perform. For example, an applet running in a + browser may need to lock a resource, but because of the instability + of the environment within which the applet is running, the applet may + be turned off without warning. As a result, the applet is likely to + ask for a relatively small timeout value so that if the applet dies, + the lock can be quickly harvested. However, a document management + system is likely to ask for an extremely long timeout because its + user may be planning on going offline. + + A client MUST NOT assume that just because the timeout has expired, + the lock has immediately been removed. + + Likewise, a client MUST NOT assume that just because the timeout has + not expired, the lock still exists. Clients MUST assume that locks + can arbitrarily disappear at any time, regardless of the value given + in the Timeout header. The Timeout header only indicates the + behavior of the server if extraordinary circumstances do not occur. + For example, a sufficiently privileged user may remove a lock at any + time, or the system may crash in such a way that it loses the record + of the lock's existence. + +6.7. Lock Capability Discovery + + Since server lock support is optional, a client trying to lock a + resource on a server can either try the lock and hope for the best, + or perform some form of discovery to determine what lock capabilities + the server supports. This is known as lock capability discovery. A + client can determine what lock types the server supports by + retrieving the DAV:supportedlock property. + + Any DAV-compliant resource that supports the LOCK method MUST support + the DAV:supportedlock property. + +6.8. Active Lock Discovery + + If another principal locks a resource that a principal wishes to + access, it is useful for the second principal to be able to find out + who the first principal is. For this purpose the DAV:lockdiscovery + property is provided. This property lists all outstanding locks, + describes their type, and MAY even provide the lock tokens. + + Any DAV-compliant resource that supports the LOCK method MUST support + the DAV:lockdiscovery property. + + + +Dusseault Standards Track [Page 22] + +RFC 4918 WebDAV June 2007 + + +7. Write Lock + + This section describes the semantics specific to the write lock type. + The write lock is a specific instance of a lock type, and is the only + lock type described in this specification. + + An exclusive write lock protects a resource: it prevents changes by + any principal other than the lock creator and in any case where the + lock token is not submitted (e.g., by a client process other than the + one holding the lock). + + Clients MUST submit a lock-token they are authorized to use in any + request that modifies a write-locked resource. The list of + modifications covered by a write-lock include: + + 1. A change to any of the following aspects of any write-locked + resource: + + * any variant, + + * any dead property, + + * any live property that is lockable (a live property is + lockable unless otherwise defined.) + + 2. For collections, any modification of an internal member URI. An + internal member URI of a collection is considered to be modified + if it is added, removed, or identifies a different resource. + More discussion on write locks and collections is found in + Section 7.4. + + 3. A modification of the mapping of the root of the write lock, + either to another resource or to no resource (e.g., DELETE). + + Of the methods defined in HTTP and WebDAV, PUT, POST, PROPPATCH, + LOCK, UNLOCK, MOVE, COPY (for the destination resource), DELETE, and + MKCOL are affected by write locks. All other HTTP/WebDAV methods + defined so far -- GET in particular -- function independently of a + write lock. + + The next few sections describe in more specific terms how write locks + interact with various operations. + + + + + + + + + +Dusseault Standards Track [Page 23] + +RFC 4918 WebDAV June 2007 + + +7.1. Write Locks and Properties + + While those without a write lock may not alter a property on a + resource it is still possible for the values of live properties to + change, even while locked, due to the requirements of their schemas. + Only dead properties and live properties defined as lockable are + guaranteed not to change while write locked. + +7.2. Avoiding Lost Updates + + Although the write locks provide some help in preventing lost + updates, they cannot guarantee that updates will never be lost. + Consider the following scenario: + + Two clients A and B are interested in editing the resource + 'index.html'. Client A is an HTTP client rather than a WebDAV + client, and so does not know how to perform locking. + + Client A doesn't lock the document, but does a GET, and begins + editing. + + Client B does LOCK, performs a GET and begins editing. + + Client B finishes editing, performs a PUT, then an UNLOCK. + + Client A performs a PUT, overwriting and losing all of B's changes. + + There are several reasons why the WebDAV protocol itself cannot + prevent this situation. First, it cannot force all clients to use + locking because it must be compatible with HTTP clients that do not + comprehend locking. Second, it cannot require servers to support + locking because of the variety of repository implementations, some of + which rely on reservations and merging rather than on locking. + Finally, being stateless, it cannot enforce a sequence of operations + like LOCK / GET / PUT / UNLOCK. + + WebDAV servers that support locking can reduce the likelihood that + clients will accidentally overwrite each other's changes by requiring + clients to lock resources before modifying them. Such servers would + effectively prevent HTTP 1.0 and HTTP 1.1 clients from modifying + resources. + + WebDAV clients can be good citizens by using a lock / retrieve / + write /unlock sequence of operations (at least by default) whenever + they interact with a WebDAV server that supports locking. + + + + + + +Dusseault Standards Track [Page 24] + +RFC 4918 WebDAV June 2007 + + + HTTP 1.1 clients can be good citizens, avoiding overwriting other + clients' changes, by using entity tags in If-Match headers with any + requests that would modify resources. + + Information managers may attempt to prevent overwrites by + implementing client-side procedures requiring locking before + modifying WebDAV resources. + +7.3. Write Locks and Unmapped URLs + + WebDAV provides the ability to send a LOCK request to an unmapped URL + in order to reserve the name for use. This is a simple way to avoid + the lost-update problem on the creation of a new resource (another + way is to use If-None-Match header specified in Section 14.26 of + [RFC2616]). It has the side benefit of locking the new resource + immediately for use of the creator. + + Note that the lost-update problem is not an issue for collections + because MKCOL can only be used to create a collection, not to + overwrite an existing collection. When trying to lock a collection + upon creation, clients can attempt to increase the likelihood of + getting the lock by pipelining the MKCOL and LOCK requests together + (but because this doesn't convert two separate operations into one + atomic operation, there's no guarantee this will work). + + A successful lock request to an unmapped URL MUST result in the + creation of a locked (non-collection) resource with empty content. + Subsequently, a successful PUT request (with the correct lock token) + provides the content for the resource. Note that the LOCK request + has no mechanism for the client to provide Content-Type or Content- + Language, thus the server will use defaults or empty values and rely + on the subsequent PUT request for correct values. + + A resource created with a LOCK is empty but otherwise behaves in + every way as a normal resource. It behaves the same way as a + resource created by a PUT request with an empty body (and where a + Content-Type and Content-Language was not specified), followed by a + LOCK request to the same resource. Following from this model, a + locked empty resource: + + o Can be read, deleted, moved, and copied, and in all ways behaves + as a regular non-collection resource. + + o Appears as a member of its parent collection. + + o SHOULD NOT disappear when its lock goes away (clients must + therefore be responsible for cleaning up their own mess, as with + any other operation or any non-empty resource). + + + +Dusseault Standards Track [Page 25] + +RFC 4918 WebDAV June 2007 + + + o MAY NOT have values for properties like DAV:getcontentlanguage + that haven't been specified yet by the client. + + o Can be updated (have content added) with a PUT request. + + o MUST NOT be converted into a collection. The server MUST fail a + MKCOL request (as it would with a MKCOL request to any existing + non-collection resource). + + o MUST have defined values for DAV:lockdiscovery and DAV: + supportedlock properties. + + o The response MUST indicate that a resource was created, by use of + the "201 Created" response code (a LOCK request to an existing + resource instead will result in 200 OK). The body must still + include the DAV:lockdiscovery property, as with a LOCK request to + an existing resource. + + The client is expected to update the locked empty resource shortly + after locking it, using PUT and possibly PROPPATCH. + + Alternatively and for backwards compatibility to [RFC2518], servers + MAY implement Lock-Null Resources (LNRs) instead (see definition in + Appendix D). Clients can easily interoperate both with servers that + support the old model LNRs and the recommended model of "locked empty + resources" by only attempting PUT after a LOCK to an unmapped URL, + not MKCOL or GET, and by not relying on specific properties of LNRs. + +7.4. Write Locks and Collections + + There are two kinds of collection write locks. A depth-0 write lock + on a collection protects the collection properties plus the internal + member URLs of that one collection, while not protecting the content + or properties of member resources (if the collection itself has any + entity bodies, those are also protected). A depth-infinity write + lock on a collection provides the same protection on that collection + and also provides write lock protection on every member resource. + + Expressed otherwise, a write lock of either kind protects any request + that would create a new resource in a write locked collection, any + request that would remove an internal member URL of a write locked + collection, and any request that would change the segment name of any + internal member. + + Thus, a collection write lock protects all the following actions: + + o DELETE a collection's direct internal member, + + + + +Dusseault Standards Track [Page 26] + +RFC 4918 WebDAV June 2007 + + + o MOVE an internal member out of the collection, + + o MOVE an internal member into the collection, + + o MOVE to rename an internal member within a collection, + + o COPY an internal member into a collection, and + + o PUT or MKCOL request that would create a new internal member. + + The collection's lock token is required in addition to the lock token + on the internal member itself, if it is locked separately. + + In addition, a depth-infinity lock affects all write operations to + all members of the locked collection. With a depth-infinity lock, + the resource identified by the root of the lock is directly locked, + and all its members are indirectly locked. + + o Any new resource added as a descendant of a depth-infinity locked + collection becomes indirectly locked. + + o Any indirectly locked resource moved out of the locked collection + into an unlocked collection is thereafter unlocked. + + o Any indirectly locked resource moved out of a locked source + collection into a depth-infinity locked target collection remains + indirectly locked but is now protected by the lock on the target + collection (the target collection's lock token will thereafter be + required to make further changes). + + If a depth-infinity write LOCK request is issued to a collection + containing member URLs identifying resources that are currently + locked in a manner that conflicts with the new lock (see Section 6.1, + point 3), the request MUST fail with a 423 (Locked) status code, and + the response SHOULD contain the 'no-conflicting-lock' precondition. + + If a lock request causes the URL of a resource to be added as an + internal member URL of a depth-infinity locked collection, then the + new resource MUST be automatically protected by the lock. For + example, if the collection /a/b/ is write locked and the resource /c + is moved to /a/b/c, then resource /a/b/c will be added to the write + lock. + + + + + + + + + +Dusseault Standards Track [Page 27] + +RFC 4918 WebDAV June 2007 + + +7.5. Write Locks and the If Request Header + + A user agent has to demonstrate knowledge of a lock when requesting + an operation on a locked resource. Otherwise, the following scenario + might occur. In the scenario, program A, run by User A, takes out a + write lock on a resource. Program B, also run by User A, has no + knowledge of the lock taken out by program A, yet performs a PUT to + the locked resource. In this scenario, the PUT succeeds because + locks are associated with a principal, not a program, and thus + program B, because it is acting with principal A's credential, is + allowed to perform the PUT. However, had program B known about the + lock, it would not have overwritten the resource, preferring instead + to present a dialog box describing the conflict to the user. Due to + this scenario, a mechanism is needed to prevent different programs + from accidentally ignoring locks taken out by other programs with the + same authorization. + + In order to prevent these collisions, a lock token MUST be submitted + by an authorized principal for all locked resources that a method may + change or the method MUST fail. A lock token is submitted when it + appears in an If header. For example, if a resource is to be moved + and both the source and destination are locked, then two lock tokens + must be submitted in the If header, one for the source and the other + for the destination. + +7.5.1. Example - Write Lock and COPY + + >>Request + + COPY /~fielding/index.html HTTP/1.1 + Host: www.example.com + Destination: http://www.example.com/users/f/fielding/index.html + If: + () + + >>Response + + HTTP/1.1 204 No Content + + In this example, even though both the source and destination are + locked, only one lock token must be submitted (the one for the lock + on the destination). This is because the source resource is not + modified by a COPY, and hence unaffected by the write lock. In this + example, user agent authentication has previously occurred via a + mechanism outside the scope of the HTTP protocol, in the underlying + transport layer. + + + + + +Dusseault Standards Track [Page 28] + +RFC 4918 WebDAV June 2007 + + +7.5.2. Example - Deleting a Member of a Locked Collection + + Consider a collection "/locked" with an exclusive, depth-infinity + write lock, and an attempt to delete an internal member "/locked/ + member": + + >>Request + + DELETE /locked/member HTTP/1.1 + Host: example.com + + >>Response + + HTTP/1.1 423 Locked + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + /locked/ + + + + Thus, the client would need to submit the lock token with the request + to make it succeed. To do that, various forms of the If header (see + Section 10.4) could be used. + + "No-Tag-List" format: + + If: () + + "Tagged-List" format, for "http://example.com/locked/": + + If: + () + + "Tagged-List" format, for "http://example.com/locked/member": + + If: + () + + Note that, for the purpose of submitting the lock token, the actual + form doesn't matter; what's relevant is that the lock token appears + in the If header, and that the If header itself evaluates to true. + + + + + + +Dusseault Standards Track [Page 29] + +RFC 4918 WebDAV June 2007 + + +7.6. Write Locks and COPY/MOVE + + A COPY method invocation MUST NOT duplicate any write locks active on + the source. However, as previously noted, if the COPY copies the + resource into a collection that is locked with a depth-infinity lock, + then the resource will be added to the lock. + + A successful MOVE request on a write locked resource MUST NOT move + the write lock with the resource. However, if there is an existing + lock at the destination, the server MUST add the moved resource to + the destination lock scope. For example, if the MOVE makes the + resource a child of a collection that has a depth-infinity lock, then + the resource will be added to that collection's lock. Additionally, + if a resource with a depth-infinity lock is moved to a destination + that is within the scope of the same lock (e.g., within the URL + namespace tree covered by the lock), the moved resource will again be + added to the lock. In both these examples, as specified in + Section 7.5, an If header must be submitted containing a lock token + for both the source and destination. + +7.7. Refreshing Write Locks + + A client MUST NOT submit the same write lock request twice. Note + that a client is always aware it is resubmitting the same lock + request because it must include the lock token in the If header in + order to make the request for a resource that is already locked. + + However, a client may submit a LOCK request with an If header but + without a body. A server receiving a LOCK request with no body MUST + NOT create a new lock -- this form of the LOCK request is only to be + used to "refresh" an existing lock (meaning, at minimum, that any + timers associated with the lock MUST be reset). + + Clients may submit Timeout headers of arbitrary value with their lock + refresh requests. Servers, as always, may ignore Timeout headers + submitted by the client, and a server MAY refresh a lock with a + timeout period that is different than the previous timeout period + used for the lock, provided it advertises the new value in the LOCK + refresh response. + + If an error is received in response to a refresh LOCK request, the + client MUST NOT assume that the lock was refreshed. + + + + + + + + + +Dusseault Standards Track [Page 30] + +RFC 4918 WebDAV June 2007 + + +8. General Request and Response Handling + +8.1. Precedence in Error Handling + + Servers MUST return authorization errors in preference to other + errors. This avoids leaking information about protected resources + (e.g., a client that finds that a hidden resource exists by seeing a + 423 Locked response to an anonymous request to the resource). + +8.2. Use of XML + + In HTTP/1.1, method parameter information was exclusively encoded in + HTTP headers. Unlike HTTP/1.1, WebDAV encodes method parameter + information either in an XML ([REC-XML]) request entity body, or in + an HTTP header. The use of XML to encode method parameters was + motivated by the ability to add extra XML elements to existing + structures, providing extensibility; and by XML's ability to encode + information in ISO 10646 character sets, providing + internationalization support. + + In addition to encoding method parameters, XML is used in WebDAV to + encode the responses from methods, providing the extensibility and + internationalization advantages of XML for method output, as well as + input. + + When XML is used for a request or response body, the Content-Type + type SHOULD be application/xml. Implementations MUST accept both + text/xml and application/xml in request and response bodies. Use of + text/xml is deprecated. + + All DAV-compliant clients and resources MUST use XML parsers that are + compliant with [REC-XML] and [REC-XML-NAMES]. All XML used in either + requests or responses MUST be, at minimum, well formed and use + namespaces correctly. If a server receives XML that is not well- + formed, then the server MUST reject the entire request with a 400 + (Bad Request). If a client receives XML that is not well-formed in a + response, then the client MUST NOT assume anything about the outcome + of the executed method and SHOULD treat the server as malfunctioning. + + Note that processing XML submitted by an untrusted source may cause + risks connected to privacy, security, and service quality (see + Section 20). Servers MAY reject questionable requests (even though + they consist of well-formed XML), for instance, with a 400 (Bad + Request) status code and an optional response body explaining the + problem. + + + + + + +Dusseault Standards Track [Page 31] + +RFC 4918 WebDAV June 2007 + + +8.3. URL Handling + + URLs appear in many places in requests and responses. + Interoperability experience with [RFC2518] showed that many clients + parsing Multi-Status responses did not fully implement the full + Reference Resolution defined in Section 5 of [RFC3986]. Thus, + servers in particular need to be careful in handling URLs in + responses, to ensure that clients have enough context to be able to + interpret all the URLs. The rules in this section apply not only to + resource URLs in the 'href' element in Multi-Status responses, but + also to the Destination and If header resource URLs. + + The sender has a choice between two approaches: using a relative + reference, which is resolved against the Request-URI, or a full URI. + A server MUST ensure that every 'href' value within a Multi-Status + response uses the same format. + + WebDAV only uses one form of relative reference in its extensions, + the absolute path. + + Simple-ref = absolute-URI | ( path-absolute [ "?" query ] ) + + The absolute-URI, path-absolute and query productions are defined in + Sections 4.3, 3.3, and 3.4 of [RFC3986]. + + Within Simple-ref productions, senders MUST NOT: + + o use dot-segments ("." or ".."), or + + o have prefixes that do not match the Request-URI (using the + comparison rules defined in Section 3.2.3 of [RFC2616]). + + Identifiers for collections SHOULD end in a '/' character. + +8.3.1. Example - Correct URL Handling + + Consider the collection http://example.com/sample/ with the internal + member URL http://example.com/sample/a%20test and the PROPFIND + request below: + + >>Request: + + PROPFIND /sample/ HTTP/1.1 + Host: example.com + Depth: 1 + + + + + + +Dusseault Standards Track [Page 32] + +RFC 4918 WebDAV June 2007 + + + In this case, the server should return two 'href' elements containing + either + + o 'http://example.com/sample/' and + 'http://example.com/sample/a%20test', or + + o '/sample/' and '/sample/a%20test' + + Note that even though the server may be storing the member resource + internally as 'a test', it has to be percent-encoded when used inside + a URI reference (see Section 2.1 of [RFC3986]). Also note that a + legal URI may still contain characters that need to be escaped within + XML character data, such as the ampersand character. + +8.4. Required Bodies in Requests + + Some of these new methods do not define bodies. Servers MUST examine + all requests for a body, even when a body was not expected. In cases + where a request body is present but would be ignored by a server, the + server MUST reject the request with 415 (Unsupported Media Type). + This informs the client (which may have been attempting to use an + extension) that the body could not be processed as the client + intended. + +8.5. HTTP Headers for Use in WebDAV + + HTTP defines many headers that can be used in WebDAV requests and + responses. Not all of these are appropriate in all situations and + some interactions may be undefined. Note that HTTP 1.1 requires the + Date header in all responses if possible (see Section 14.18, + [RFC2616]). + + The server MUST do authorization checks before checking any HTTP + conditional header. + +8.6. ETag + + HTTP 1.1 recommends the use of ETags rather than modification dates, + for cache control, and there are even stronger reasons to prefer + ETags for authoring. Correct use of ETags is even more important in + a distributed authoring environment, because ETags are necessary + along with locks to avoid the lost-update problem. A client might + fail to renew a lock, for example, when the lock times out and the + client is accidentally offline or in the middle of a long upload. + When a client fails to renew the lock, it's quite possible the + resource can still be relocked and the user can go on editing, as + long as no changes were made in the meantime. ETags are required for + the client to be able to distinguish this case. Otherwise, the + + + +Dusseault Standards Track [Page 33] + +RFC 4918 WebDAV June 2007 + + + client is forced to ask the user whether to overwrite the resource on + the server without even being able to tell the user if it has + changed. Timestamps do not solve this problem nearly as well as + ETags. + + Strong ETags are much more useful for authoring use cases than weak + ETags (see Section 13.3.3 of [RFC2616]). Semantic equivalence can be + a useful concept but that depends on the document type and the + application type, and interoperability might require some agreement + or standard outside the scope of this specification and HTTP. Note + also that weak ETags have certain restrictions in HTTP, e.g., these + cannot be used in If-Match headers. + + Note that the meaning of an ETag in a PUT response is not clearly + defined either in this document or in RFC 2616 (i.e., whether the + ETag means that the resource is octet-for-octet equivalent to the + body of the PUT request, or whether the server could have made minor + changes in the formatting or content of the document upon storage). + This is an HTTP issue, not purely a WebDAV issue. + + Because clients may be forced to prompt users or throw away changed + content if the ETag changes, a WebDAV server SHOULD NOT change the + ETag (or the Last-Modified time) for a resource that has an unchanged + body and location. The ETag represents the state of the body or + contents of the resource. There is no similar way to tell if + properties have changed. + +8.7. Including Error Response Bodies + + HTTP and WebDAV did not use the bodies of most error responses for + machine-parsable information until the specification for Versioning + Extensions to WebDAV introduced a mechanism to include more specific + information in the body of an error response (Section 1.6 of + [RFC3253]). The error body mechanism is appropriate to use with any + error response that may take a body but does not already have a body + defined. The mechanism is particularly appropriate when a status + code can mean many things (for example, 400 Bad Request can mean + required headers are missing, headers are incorrectly formatted, or + much more). This error body mechanism is covered in Section 16. + +8.8. Impact of Namespace Operations on Cache Validators + + Note that the HTTP response headers "Etag" and "Last-Modified" (see + [RFC2616], Sections 14.19 and 14.29) are defined per URL (not per + resource), and are used by clients for caching. Therefore servers + must ensure that executing any operation that affects the URL + namespace (such as COPY, MOVE, DELETE, PUT, or MKCOL) does preserve + their semantics, in particular: + + + +Dusseault Standards Track [Page 34] + +RFC 4918 WebDAV June 2007 + + + o For any given URL, the "Last-Modified" value MUST increment every + time the representation returned upon GET changes (within the + limits of timestamp resolution). + + o For any given URL, an "ETag" value MUST NOT be reused for + different representations returned by GET. + + In practice this means that servers + + o might have to increment "Last-Modified" timestamps for every + resource inside the destination namespace of a namespace operation + unless it can do so more selectively, and + + o similarly, might have to re-assign "ETag" values for these + resources (unless the server allocates entity tags in a way so + that they are unique across the whole URL namespace managed by the + server). + + Note that these considerations also apply to specific use cases, such + as using PUT to create a new resource at a URL that has been mapped + before, but has been deleted since then. + + Finally, WebDAV properties (such as DAV:getetag and DAV: + getlastmodified) that inherit their semantics from HTTP headers must + behave accordingly. + +9. HTTP Methods for Distributed Authoring + +9.1. PROPFIND Method + + The PROPFIND method retrieves properties defined on the resource + identified by the Request-URI, if the resource does not have any + internal members, or on the resource identified by the Request-URI + and potentially its member resources, if the resource is a collection + that has internal member URLs. All DAV-compliant resources MUST + support the PROPFIND method and the propfind XML element + (Section 14.20) along with all XML elements defined for use with that + element. + + A client MUST submit a Depth header with a value of "0", "1", or + "infinity" with a PROPFIND request. Servers MUST support "0" and "1" + depth requests on WebDAV-compliant resources and SHOULD support + "infinity" requests. In practice, support for infinite-depth + requests MAY be disabled, due to the performance and security + concerns associated with this behavior. Servers SHOULD treat a + request without a Depth header as if a "Depth: infinity" header was + included. + + + + +Dusseault Standards Track [Page 35] + +RFC 4918 WebDAV June 2007 + + + A client may submit a 'propfind' XML element in the body of the + request method describing what information is being requested. It is + possible to: + + o Request particular property values, by naming the properties + desired within the 'prop' element (the ordering of properties in + here MAY be ignored by the server), + + o Request property values for those properties defined in this + specification (at a minimum) plus dead properties, by using the + 'allprop' element (the 'include' element can be used with + 'allprop' to instruct the server to also include additional live + properties that may not have been returned otherwise), + + o Request a list of names of all the properties defined on the + resource, by using the 'propname' element. + + A client may choose not to submit a request body. An empty PROPFIND + request body MUST be treated as if it were an 'allprop' request. + + Note that 'allprop' does not return values for all live properties. + WebDAV servers increasingly have expensively-calculated or lengthy + properties (see [RFC3253] and [RFC3744]) and do not return all + properties already. Instead, WebDAV clients can use propname + requests to discover what live properties exist, and request named + properties when retrieving values. For a live property defined + elsewhere, that definition can specify whether or not that live + property would be returned in 'allprop' requests. + + All servers MUST support returning a response of content type text/ + xml or application/xml that contains a multistatus XML element that + describes the results of the attempts to retrieve the various + properties. + + If there is an error retrieving a property, then a proper error + result MUST be included in the response. A request to retrieve the + value of a property that does not exist is an error and MUST be noted + with a 'response' XML element that contains a 404 (Not Found) status + value. + + Consequently, the 'multistatus' XML element for a collection resource + MUST include a 'response' XML element for each member URL of the + collection, to whatever depth was requested. It SHOULD NOT include + any 'response' elements for resources that are not WebDAV-compliant. + Each 'response' element MUST contain an 'href' element that contains + the URL of the resource on which the properties in the prop XML + element are defined. Results for a PROPFIND on a collection resource + are returned as a flat list whose order of entries is not + + + +Dusseault Standards Track [Page 36] + +RFC 4918 WebDAV June 2007 + + + significant. Note that a resource may have only one value for a + property of a given name, so the property may only show up once in + PROPFIND responses. + + Properties may be subject to access control. In the case of + 'allprop' and 'propname' requests, if a principal does not have the + right to know whether a particular property exists, then the property + MAY be silently excluded from the response. + + Some PROPFIND results MAY be cached, with care, as there is no cache + validation mechanism for most properties. This method is both safe + and idempotent (see Section 9.1 of [RFC2616]). + +9.1.1. PROPFIND Status Codes + + This section, as with similar sections for other methods, provides + some guidance on error codes and preconditions or postconditions + (defined in Section 16) that might be particularly useful with + PROPFIND. + + 403 Forbidden - A server MAY reject PROPFIND requests on collections + with depth header of "Infinity", in which case it SHOULD use this + error with the precondition code 'propfind-finite-depth' inside the + error body. + +9.1.2. Status Codes for Use in 'propstat' Element + + In PROPFIND responses, information about individual properties is + returned inside 'propstat' elements (see Section 14.22), each + containing an individual 'status' element containing information + about the properties appearing in it. The list below summarizes the + most common status codes used inside 'propstat'; however, clients + should be prepared to handle other 2/3/4/5xx series status codes as + well. + + 200 OK - A property exists and/or its value is successfully returned. + + 401 Unauthorized - The property cannot be viewed without appropriate + authorization. + + 403 Forbidden - The property cannot be viewed regardless of + authentication. + + 404 Not Found - The property does not exist. + + + + + + + +Dusseault Standards Track [Page 37] + +RFC 4918 WebDAV June 2007 + + +9.1.3. Example - Retrieving Named Properties + + >>Request + + PROPFIND /file HTTP/1.1 + Host: www.example.com + Content-type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/file + + + + Box type A + + + J.J. Johnson + + + HTTP/1.1 200 OK + + + + HTTP/1.1 403 Forbidden + The user does not have access to the + DingALing property. + + + + + +Dusseault Standards Track [Page 38] + +RFC 4918 WebDAV June 2007 + + + + There has been an access violation error. + + + + + In this example, PROPFIND is executed on a non-collection resource + http://www.example.com/file. The propfind XML element specifies the + name of four properties whose values are being requested. In this + case, only two properties were returned, since the principal issuing + the request did not have sufficient access rights to see the third + and fourth properties. + +9.1.4. Example - Using 'propname' to Retrieve All Property Names + + >>Request + + PROPFIND /container/ HTTP/1.1 + Host: www.example.com + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/container/ + + + + + + + + + + HTTP/1.1 200 OK + + + +Dusseault Standards Track [Page 39] + +RFC 4918 WebDAV June 2007 + + + + + + http://www.example.com/container/front.html + + + + + + + + + + + + + HTTP/1.1 200 OK + + + + + In this example, PROPFIND is invoked on the collection resource + http://www.example.com/container/, with a propfind XML element + containing the propname XML element, meaning the name of all + properties should be returned. Since no Depth header is present, it + assumes its default value of "infinity", meaning the name of the + properties on the collection and all its descendants should be + returned. + + Consistent with the previous example, resource + http://www.example.com/container/ has six properties defined on it: + bigbox and author in the "http://ns.example.com/boxschema/" + namespace, and creationdate, displayname, resourcetype, and + supportedlock in the "DAV:" namespace. + + The resource http://www.example.com/container/index.html, a member of + the "container" collection, has nine properties defined on it, bigbox + in the "http://ns.example.com/boxschema/" namespace and creationdate, + displayname, getcontentlength, getcontenttype, getetag, + getlastmodified, resourcetype, and supportedlock in the "DAV:" + namespace. + + This example also demonstrates the use of XML namespace scoping and + the default namespace. Since the "xmlns" attribute does not contain + a prefix, the namespace applies by default to all enclosed elements. + Hence, all elements that do not explicitly state the namespace to + which they belong are members of the "DAV:" namespace. + + + + +Dusseault Standards Track [Page 40] + +RFC 4918 WebDAV June 2007 + + +9.1.5. Example - Using So-called 'allprop' + + Note that 'allprop', despite its name, which remains for backward- + compatibility, does not return every property, but only dead + properties and the live properties defined in this specification. + + >>Request + + PROPFIND /container/ HTTP/1.1 + Host: www.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + /container/ + + + Box type A + Hadrian + 1997-12-01T17:42:21-08:00 + Example collection + + + + + + + + + + + + + + + +Dusseault Standards Track [Page 41] + +RFC 4918 WebDAV June 2007 + + + HTTP/1.1 200 OK + + + + /container/front.html + + + Box type B + + 1997-12-01T18:27:21-08:00 + Example HTML resource + 4525 + text/html + "zzyzx" + Mon, 12 Jan 1998 09:25:56 GMT + + + + + + + + + + + + + HTTP/1.1 200 OK + + + + + In this example, PROPFIND was invoked on the resource + http://www.example.com/container/ with a Depth header of 1, meaning + the request applies to the resource and its children, and a propfind + XML element containing the allprop XML element, meaning the request + should return the name and value of all the dead properties defined + on the resources, plus the name and value of all the properties + defined in this specification. This example illustrates the use of + relative references in the 'href' elements of the response. + + The resource http://www.example.com/container/ has six properties + defined on it: 'bigbox' and 'author in the + "http://ns.example.com/boxschema/" namespace, DAV:creationdate, DAV: + displayname, DAV:resourcetype, and DAV:supportedlock. + + + + + +Dusseault Standards Track [Page 42] + +RFC 4918 WebDAV June 2007 + + + The last four properties are WebDAV-specific, defined in Section 15. + Since GET is not supported on this resource, the get* properties + (e.g., DAV:getcontentlength) are not defined on this resource. The + WebDAV-specific properties assert that "container" was created on + December 1, 1997, at 5:42:21PM, in a time zone 8 hours west of GMT + (DAV:creationdate), has a name of "Example collection" (DAV: + displayname), a collection resource type (DAV:resourcetype), and + supports exclusive write and shared write locks (DAV:supportedlock). + + The resource http://www.example.com/container/front.html has nine + properties defined on it: + + 'bigbox' in the "http://ns.example.com/boxschema/" namespace (another + instance of the "bigbox" property type), DAV:creationdate, DAV: + displayname, DAV:getcontentlength, DAV:getcontenttype, DAV:getetag, + DAV:getlastmodified, DAV:resourcetype, and DAV:supportedlock. + + The DAV-specific properties assert that "front.html" was created on + December 1, 1997, at 6:27:21PM, in a time zone 8 hours west of GMT + (DAV:creationdate), has a name of "Example HTML resource" (DAV: + displayname), a content length of 4525 bytes (DAV:getcontentlength), + a MIME type of "text/html" (DAV:getcontenttype), an entity tag of + "zzyzx" (DAV:getetag), was last modified on Monday, January 12, 1998, + at 09:25:56 GMT (DAV:getlastmodified), has an empty resource type, + meaning that it is not a collection (DAV:resourcetype), and supports + both exclusive write and shared write locks (DAV:supportedlock). + +9.1.6. Example - Using 'allprop' with 'include' + + >>Request + + PROPFIND /mycol/ HTTP/1.1 + Host: www.example.com + Depth: 1 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + +Dusseault Standards Track [Page 43] + +RFC 4918 WebDAV June 2007 + + + In this example, PROPFIND is executed on the resource + http://www.example.com/mycol/ and its internal member resources. The + client requests the values of all live properties defined in this + specification, plus all dead properties, plus two more live + properties defined in [RFC3253]. The response is not shown. + +9.2. PROPPATCH Method + + The PROPPATCH method processes instructions specified in the request + body to set and/or remove properties defined on the resource + identified by the Request-URI. + + All DAV-compliant resources MUST support the PROPPATCH method and + MUST process instructions that are specified using the + propertyupdate, set, and remove XML elements. Execution of the + directives in this method is, of course, subject to access control + constraints. DAV-compliant resources SHOULD support the setting of + arbitrary dead properties. + + The request message body of a PROPPATCH method MUST contain the + propertyupdate XML element. + + Servers MUST process PROPPATCH instructions in document order (an + exception to the normal rule that ordering is irrelevant). + Instructions MUST either all be executed or none executed. Thus, if + any error occurs during processing, all executed instructions MUST be + undone and a proper error result returned. Instruction processing + details can be found in the definition of the set and remove + instructions in Sections 14.23 and 14.26. + + If a server attempts to make any of the property changes in a + PROPPATCH request (i.e., the request is not rejected for high-level + errors before processing the body), the response MUST be a Multi- + Status response as described in Section 9.2.1. + + This method is idempotent, but not safe (see Section 9.1 of + [RFC2616]). Responses to this method MUST NOT be cached. + +9.2.1. Status Codes for Use in 'propstat' Element + + In PROPPATCH responses, information about individual properties is + returned inside 'propstat' elements (see Section 14.22), each + containing an individual 'status' element containing information + about the properties appearing in it. The list below summarizes the + most common status codes used inside 'propstat'; however, clients + should be prepared to handle other 2/3/4/5xx series status codes as + well. + + + + +Dusseault Standards Track [Page 44] + +RFC 4918 WebDAV June 2007 + + + 200 (OK) - The property set or change succeeded. Note that if this + appears for one property, it appears for every property in the + response, due to the atomicity of PROPPATCH. + + 403 (Forbidden) - The client, for reasons the server chooses not to + specify, cannot alter one of the properties. + + 403 (Forbidden): The client has attempted to set a protected + property, such as DAV:getetag. If returning this error, the server + SHOULD use the precondition code 'cannot-modify-protected-property' + inside the response body. + + 409 (Conflict) - The client has provided a value whose semantics are + not appropriate for the property. + + 424 (Failed Dependency) - The property change could not be made + because of another property change that failed. + + 507 (Insufficient Storage) - The server did not have sufficient space + to record the property. + +9.2.2. Example - PROPPATCH + + >>Request + + PROPPATCH /bar.html HTTP/1.1 + Host: www.example.com + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + Jim Whitehead + Roy Fielding + + + + + + + + + + + + + +Dusseault Standards Track [Page 45] + +RFC 4918 WebDAV June 2007 + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/bar.html + + + HTTP/1.1 424 Failed Dependency + + + + HTTP/1.1 409 Conflict + + Copyright Owner cannot be deleted or + altered. + + + + In this example, the client requests the server to set the value of + the "Authors" property in the + "http://ns.example.com/standards/z39.50/" namespace, and to remove + the property "Copyright-Owner" in the same namespace. Since the + Copyright-Owner property could not be removed, no property + modifications occur. The 424 (Failed Dependency) status code for the + Authors property indicates this action would have succeeded if it + were not for the conflict with removing the Copyright-Owner property. + +9.3. MKCOL Method + + MKCOL creates a new collection resource at the location specified by + the Request-URI. If the Request-URI is already mapped to a resource, + then the MKCOL MUST fail. During MKCOL processing, a server MUST + make the Request-URI an internal member of its parent collection, + unless the Request-URI is "/". If no such ancestor exists, the + method MUST fail. When the MKCOL operation creates a new collection + resource, all ancestors MUST already exist, or the method MUST fail + with a 409 (Conflict) status code. For example, if a request to + create collection /a/b/c/d/ is made, and /a/b/c/ does not exist, the + request must fail. + + When MKCOL is invoked without a request body, the newly created + collection SHOULD have no members. + + + +Dusseault Standards Track [Page 46] + +RFC 4918 WebDAV June 2007 + + + A MKCOL request message may contain a message body. The precise + behavior of a MKCOL request when the body is present is undefined, + but limited to creating collections, members of a collection, bodies + of members, and properties on the collections or members. If the + server receives a MKCOL request entity type it does not support or + understand, it MUST respond with a 415 (Unsupported Media Type) + status code. If the server decides to reject the request based on + the presence of an entity or the type of an entity, it should use the + 415 (Unsupported Media Type) status code. + + This method is idempotent, but not safe (see Section 9.1 of + [RFC2616]). Responses to this method MUST NOT be cached. + +9.3.1. MKCOL Status Codes + + In addition to the general status codes possible, the following + status codes have specific applicability to MKCOL: + + 201 (Created) - The collection was created. + + 403 (Forbidden) - This indicates at least one of two conditions: 1) + the server does not allow the creation of collections at the given + location in its URL namespace, or 2) the parent collection of the + Request-URI exists but cannot accept members. + + 405 (Method Not Allowed) - MKCOL can only be executed on an unmapped + URL. + + 409 (Conflict) - A collection cannot be made at the Request-URI until + one or more intermediate collections have been created. The server + MUST NOT create those intermediate collections automatically. + + 415 (Unsupported Media Type) - The server does not support the + request body type (although bodies are legal on MKCOL requests, since + this specification doesn't define any, the server is likely not to + support any given body type). + + 507 (Insufficient Storage) - The resource does not have sufficient + space to record the state of the resource after the execution of this + method. + +9.3.2. Example - MKCOL + + This example creates a collection called /webdisc/xfiles/ on the + server www.example.com. + + + + + + +Dusseault Standards Track [Page 47] + +RFC 4918 WebDAV June 2007 + + + >>Request + + MKCOL /webdisc/xfiles/ HTTP/1.1 + Host: www.example.com + + + >>Response + + HTTP/1.1 201 Created + +9.4. GET, HEAD for Collections + + The semantics of GET are unchanged when applied to a collection, + since GET is defined as, "retrieve whatever information (in the form + of an entity) is identified by the Request-URI" [RFC2616]. GET, when + applied to a collection, may return the contents of an "index.html" + resource, a human-readable view of the contents of the collection, or + something else altogether. Hence, it is possible that the result of + a GET on a collection will bear no correlation to the membership of + the collection. + + Similarly, since the definition of HEAD is a GET without a response + message body, the semantics of HEAD are unmodified when applied to + collection resources. + +9.5. POST for Collections + + Since by definition the actual function performed by POST is + determined by the server and often depends on the particular + resource, the behavior of POST when applied to collections cannot be + meaningfully modified because it is largely undefined. Thus, the + semantics of POST are unmodified when applied to a collection. + +9.6. DELETE Requirements + + DELETE is defined in [RFC2616], Section 9.7, to "delete the resource + identified by the Request-URI". However, WebDAV changes some DELETE + handling requirements. + + A server processing a successful DELETE request: + + MUST destroy locks rooted on the deleted resource + + MUST remove the mapping from the Request-URI to any resource. + + Thus, after a successful DELETE operation (and in the absence of + other actions), a subsequent GET/HEAD/PROPFIND request to the target + Request-URI MUST return 404 (Not Found). + + + +Dusseault Standards Track [Page 48] + +RFC 4918 WebDAV June 2007 + + +9.6.1. DELETE for Collections + + The DELETE method on a collection MUST act as if a "Depth: infinity" + header was used on it. A client MUST NOT submit a Depth header with + a DELETE on a collection with any value but infinity. + + DELETE instructs that the collection specified in the Request-URI and + all resources identified by its internal member URLs are to be + deleted. + + If any resource identified by a member URL cannot be deleted, then + all of the member's ancestors MUST NOT be deleted, so as to maintain + URL namespace consistency. + + Any headers included with DELETE MUST be applied in processing every + resource to be deleted. + + When the DELETE method has completed processing, it MUST result in a + consistent URL namespace. + + 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). Multi-Status is used here to indicate which + internal resources could NOT be deleted, including an error code, + which should help the client understand which resources caused the + failure. For example, the Multi-Status body could include a response + with status 423 (Locked) if an internal resource was locked. + + The server MAY return a 4xx status response, rather than a 207, if + the request failed completely. + + 424 (Failed Dependency) status codes SHOULD NOT be in the 207 (Multi- + Status) response for DELETE. They can be safely left out because the + client will know that the ancestors of a resource could not be + deleted when the client receives an error for the ancestor's progeny. + Additionally, 204 (No Content) errors SHOULD NOT be returned in the + 207 (Multi-Status). The reason for this prohibition is that 204 (No + Content) is the default success code. + +9.6.2. Example - DELETE + + >>Request + + DELETE /container/ HTTP/1.1 + Host: www.example.com + + + + + + +Dusseault Standards Track [Page 49] + +RFC 4918 WebDAV June 2007 + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/container/resource3 + HTTP/1.1 423 Locked + + + + + In this example, the attempt to delete + http://www.example.com/container/resource3 failed because it is + locked, and no lock token was submitted with the request. + Consequently, the attempt to delete http://www.example.com/container/ + also failed. Thus, the client knows that the attempt to delete + http://www.example.com/container/ must have also failed since the + parent cannot be deleted unless its child has also been deleted. + Even though a Depth header has not been included, a depth of infinity + is assumed because the method is on a collection. + +9.7. PUT Requirements + +9.7.1. PUT for Non-Collection Resources + + A PUT performed on an existing resource replaces the GET response + entity of the resource. Properties defined on the resource may be + recomputed during PUT processing but are not otherwise affected. For + example, if a server recognizes the content type of the request body, + it may be able to automatically extract information that could be + profitably exposed as properties. + + A PUT that would result in the creation of a resource without an + appropriately scoped parent collection MUST fail with a 409 + (Conflict). + + A PUT request allows a client to indicate what media type an entity + body has, and whether it should change if overwritten. Thus, a + client SHOULD provide a Content-Type for a new resource if any is + known. If the client does not provide a Content-Type for a new + resource, the server MAY create a resource with no Content-Type + assigned, or it MAY attempt to assign a Content-Type. + + + + + +Dusseault Standards Track [Page 50] + +RFC 4918 WebDAV June 2007 + + + Note that although a recipient ought generally to treat metadata + supplied with an HTTP request as authoritative, in practice there's + no guarantee that a server will accept client-supplied metadata + (e.g., any request header beginning with "Content-"). Many servers + do not allow configuring the Content-Type on a per-resource basis in + the first place. Thus, clients can't always rely on the ability to + directly influence the content type by including a Content-Type + request header. + +9.7.2. PUT for Collections + + This specification does not define the behavior of the PUT method for + existing collections. A PUT request to an existing collection MAY be + treated as an error (405 Method Not Allowed). + + The MKCOL method is defined to create collections. + +9.8. COPY Method + + The COPY method creates a duplicate of the source resource identified + by the Request-URI, in the destination resource identified by the URI + in the Destination header. The Destination header MUST be present. + The exact behavior of the COPY method depends on the type of the + source resource. + + All WebDAV-compliant resources MUST support the COPY method. + However, support for the COPY method does not guarantee the ability + to copy a resource. For example, separate programs may control + resources on the same server. As a result, it may not be possible to + copy a resource to a location that appears to be on the same server. + + This method is idempotent, but not safe (see Section 9.1 of + [RFC2616]). Responses to this method MUST NOT be cached. + +9.8.1. COPY for Non-collection Resources + + When the source resource is not a collection, the result of the COPY + method is the creation of a new resource at the destination whose + state and behavior match that of the source resource as closely as + possible. Since the environment at the destination may be different + than at the source due to factors outside the scope of control of the + server, such as the absence of resources required for correct + operation, it may not be possible to completely duplicate the + behavior of the resource at the destination. Subsequent alterations + to the destination resource will not modify the source resource. + Subsequent alterations to the source resource will not modify the + destination resource. + + + + +Dusseault Standards Track [Page 51] + +RFC 4918 WebDAV June 2007 + + +9.8.2. COPY for Properties + + After a successful COPY invocation, all dead properties on the source + resource SHOULD be duplicated on the destination resource. Live + properties described in this document SHOULD be duplicated as + identically behaving live properties at the destination resource, but + not necessarily with the same values. Servers SHOULD NOT convert + live properties into dead properties on the destination resource, + because clients may then draw incorrect conclusions about the state + or functionality of a resource. Note that some live properties are + defined such that the absence of the property has a specific meaning + (e.g., a flag with one meaning if present, and the opposite if + absent), and in these cases, a successful COPY might result in the + property being reported as "Not Found" in subsequent requests. + + When the destination is an unmapped URL, a COPY operation creates a + new resource much like a PUT operation does. Live properties that + are related to resource creation (such as DAV:creationdate) should + have their values set accordingly. + +9.8.3. COPY for Collections + + The COPY method on a collection without a Depth header MUST act as if + a Depth header with value "infinity" was included. A client may + submit a Depth header on a COPY on a collection with a value of "0" + or "infinity". Servers MUST support the "0" and "infinity" Depth + header behaviors on WebDAV-compliant resources. + + An infinite-depth COPY instructs that the collection resource + identified by the Request-URI is to be copied to the location + identified by the URI in the Destination header, and all its internal + member resources are to be copied to a location relative to it, + recursively through all levels of the collection hierarchy. Note + that an infinite-depth COPY of /A/ into /A/B/ could lead to infinite + recursion if not handled correctly. + + A COPY of "Depth: 0" only instructs that the collection and its + properties, but not resources identified by its internal member URLs, + are to be copied. + + Any headers included with a COPY MUST be applied in processing every + resource to be copied with the exception of the Destination header. + + The Destination header only specifies the destination URI for the + Request-URI. When applied to members of the collection identified by + the Request-URI, the value of Destination is to be modified to + reflect the current location in the hierarchy. So, if the Request- + URI is /a/ with Host header value http://example.com/ and the + + + +Dusseault Standards Track [Page 52] + +RFC 4918 WebDAV June 2007 + + + Destination is http://example.com/b/, then when + http://example.com/a/c/d is processed, it must use a Destination of + http://example.com/b/c/d. + + When the COPY method has completed processing, it MUST have created a + consistent URL namespace at the destination (see Section 5.1 for the + definition of namespace consistency). However, if an error occurs + while copying an internal collection, the server MUST NOT copy any + resources identified by members of this collection (i.e., the server + must skip this subtree), as this would create an inconsistent + namespace. After detecting an error, the COPY operation SHOULD try + to finish as much of the original copy operation as possible (i.e., + the server should still attempt to copy other subtrees and their + members that are not descendants of an error-causing collection). + + So, for example, if an infinite-depth copy operation is performed on + collection /a/, which contains collections /a/b/ and /a/c/, and an + error occurs copying /a/b/, an attempt should still be made to copy + /a/c/. Similarly, after encountering an error copying a non- + collection resource as part of an infinite-depth copy, the server + SHOULD try to finish as much of the original copy operation as + possible. + + If an error in executing the COPY method occurs with a resource other + than the resource identified in the Request-URI, then the response + MUST be a 207 (Multi-Status), and the URL of the resource causing the + failure MUST appear with the specific error. + + The 424 (Failed Dependency) status code SHOULD NOT be returned in the + 207 (Multi-Status) response from a COPY method. These responses can + be safely omitted because the client will know that the progeny of a + resource could not be copied when the client receives an error for + the parent. Additionally, 201 (Created)/204 (No Content) status + codes SHOULD NOT be returned as values in 207 (Multi-Status) + responses from COPY methods. They, too, can be safely omitted + because they are the default success codes. + +9.8.4. COPY and Overwriting Destination Resources + + If a COPY request has an Overwrite header with a value of "F", and a + resource exists at the Destination URL, the server MUST fail the + request. + + When a server executes a COPY request and overwrites a destination + resource, the exact behavior MAY depend on many factors, including + WebDAV extension capabilities (see particularly [RFC3253]). For + + + + + +Dusseault Standards Track [Page 53] + +RFC 4918 WebDAV June 2007 + + + example, when an ordinary resource is overwritten, the server could + delete the target resource before doing the copy, or could do an in- + place overwrite to preserve live properties. + + When a collection is overwritten, the membership of the destination + collection after the successful COPY request MUST be the same + membership as the source collection immediately before the COPY. + Thus, merging the membership of the source and destination + collections together in the destination is not a compliant behavior. + + In general, if clients require the state of the destination URL to be + wiped out prior to a COPY (e.g., to force live properties to be + reset), then the client could send a DELETE to the destination before + the COPY request to ensure this reset. + +9.8.5. Status Codes + + In addition to the general status codes possible, the following + status codes have specific applicability to COPY: + + 201 (Created) - The source resource was successfully copied. The + COPY operation resulted in the creation of a new resource. + + 204 (No Content) - The source resource was successfully copied to a + preexisting destination resource. + + 207 (Multi-Status) - Multiple resources were to be affected by the + COPY, but errors on some of them prevented the operation from taking + place. Specific error messages, together with the most appropriate + of the source and destination URLs, appear in the body of the multi- + status response. For example, if a destination resource was locked + and could not be overwritten, then the destination resource URL + appears with the 423 (Locked) status. + + 403 (Forbidden) - The operation is forbidden. A special case for + COPY could be that the source and destination resources are the same + resource. + + 409 (Conflict) - A resource cannot be created at the destination + until one or more intermediate collections have been created. The + server MUST NOT create those intermediate collections automatically. + + 412 (Precondition Failed) - A precondition header check failed, e.g., + the Overwrite header is "F" and the destination URL is already mapped + to a resource. + + + + + + +Dusseault Standards Track [Page 54] + +RFC 4918 WebDAV June 2007 + + + 423 (Locked) - The destination resource, or resource within the + destination collection, was locked. This response SHOULD contain the + 'lock-token-submitted' precondition element. + + 502 (Bad Gateway) - This may occur when the destination is on another + server, repository, or URL namespace. Either the source namespace + does not support copying to the destination namespace, or the + destination namespace refuses to accept the resource. The client may + wish to try GET/PUT and PROPFIND/PROPPATCH instead. + + 507 (Insufficient Storage) - The destination resource does not have + sufficient space to record the state of the resource after the + execution of this method. + +9.8.6. Example - COPY with Overwrite + + This example shows resource + http://www.example.com/~fielding/index.html being copied to the + location http://www.example.com/users/f/fielding/index.html. The 204 + (No Content) status code indicates that the existing resource at the + destination was overwritten. + + >>Request + + COPY /~fielding/index.html HTTP/1.1 + Host: www.example.com + Destination: http://www.example.com/users/f/fielding/index.html + + >>Response + + HTTP/1.1 204 No Content + +9.8.7. Example - COPY with No Overwrite + + The following example shows the same copy operation being performed, + but with the Overwrite header set to "F." A response of 412 + (Precondition Failed) is returned because the destination URL is + already mapped to a resource. + + >>Request + + COPY /~fielding/index.html HTTP/1.1 + Host: www.example.com + Destination: http://www.example.com/users/f/fielding/index.html + Overwrite: F + + + + + + +Dusseault Standards Track [Page 55] + +RFC 4918 WebDAV June 2007 + + + >>Response + + HTTP/1.1 412 Precondition Failed + +9.8.8. Example - COPY of a Collection + + >>Request + + COPY /container/ HTTP/1.1 + Host: www.example.com + Destination: http://www.example.com/othercontainer/ + Depth: infinity + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + http://www.example.com/othercontainer/R2/ + HTTP/1.1 423 Locked + + + + + The Depth header is unnecessary as the default behavior of COPY on a + collection is to act as if a "Depth: infinity" header had been + submitted. In this example, most of the resources, along with the + collection, were copied successfully. However, the collection R2 + failed because the destination R2 is locked. Because there was an + error copying R2, none of R2's members were copied. However, no + errors were listed for those members due to the error minimization + rules. + +9.9. MOVE Method + + The MOVE operation on a non-collection resource is the logical + equivalent of a copy (COPY), followed by consistency maintenance + processing, followed by a delete of the source, where all three + actions are performed in a single operation. The consistency + maintenance step allows the server to perform updates caused by the + move, such as updating all URLs, other than the Request-URI that + identifies the source resource, to point to the new destination + resource. + + + +Dusseault Standards Track [Page 56] + +RFC 4918 WebDAV June 2007 + + + The Destination header MUST be present on all MOVE methods and MUST + follow all COPY requirements for the COPY part of the MOVE method. + All WebDAV-compliant resources MUST support the MOVE method. + + Support for the MOVE method does not guarantee the ability to move a + resource to a particular destination. For example, separate programs + may actually control different sets of resources on the same server. + Therefore, it may not be possible to move a resource within a + namespace that appears to belong to the same server. + + If a resource exists at the destination, the destination resource + will be deleted as a side-effect of the MOVE operation, subject to + the restrictions of the Overwrite header. + + This method is idempotent, but not safe (see Section 9.1 of + [RFC2616]). Responses to this method MUST NOT be cached. + +9.9.1. MOVE for Properties + + Live properties described in this document SHOULD be moved along with + the resource, such that the resource has identically behaving live + properties at the destination resource, but not necessarily with the + same values. Note that some live properties are defined such that + the absence of the property has a specific meaning (e.g., a flag with + one meaning if present, and the opposite if absent), and in these + cases, a successful MOVE might result in the property being reported + as "Not Found" in subsequent requests. If the live properties will + not work the same way at the destination, the server MAY fail the + request. + + MOVE is frequently used by clients to rename a file without changing + its parent collection, so it's not appropriate to reset all live + properties that are set at resource creation. For example, the DAV: + creationdate property value SHOULD remain the same after a MOVE. + + Dead properties MUST be moved along with the resource. + +9.9.2. MOVE for Collections + + A MOVE with "Depth: infinity" instructs that the collection + identified by the Request-URI be moved to the address specified in + the Destination header, and all resources identified by its internal + member URLs are to be moved to locations relative to it, recursively + through all levels of the collection hierarchy. + + The MOVE method on a collection MUST act as if a "Depth: infinity" + header was used on it. A client MUST NOT submit a Depth header on a + MOVE on a collection with any value but "infinity". + + + +Dusseault Standards Track [Page 57] + +RFC 4918 WebDAV June 2007 + + + Any headers included with MOVE MUST be applied in processing every + resource to be moved with the exception of the Destination header. + The behavior of the Destination header is the same as given for COPY + on collections. + + When the MOVE method has completed processing, it MUST have created a + consistent URL namespace at both the source and destination (see + Section 5.1 for the definition of namespace consistency). However, + if an error occurs while moving an internal collection, the server + MUST NOT move any resources identified by members of the failed + collection (i.e., the server must skip the error-causing subtree), as + this would create an inconsistent namespace. In this case, after + detecting the error, the move operation SHOULD try to finish as much + of the original move as possible (i.e., the server should still + attempt to move other subtrees and the resources identified by their + members that are not descendants of an error-causing collection). + So, for example, if an infinite-depth move is performed on collection + /a/, which contains collections /a/b/ and /a/c/, and an error occurs + moving /a/b/, an attempt should still be made to try moving /a/c/. + Similarly, after encountering an error moving a non-collection + resource as part of an infinite-depth move, the server SHOULD try to + finish as much of the original move operation as possible. + + If an error occurs with a resource other than the resource identified + in the Request-URI, then the response MUST be a 207 (Multi-Status), + and the errored resource's URL MUST appear with the specific error. + + The 424 (Failed Dependency) status code SHOULD NOT be returned in the + 207 (Multi-Status) response from a MOVE method. These errors can be + safely omitted because the client will know that the progeny of a + resource could not be moved when the client receives an error for the + parent. Additionally, 201 (Created)/204 (No Content) responses + SHOULD NOT be returned as values in 207 (Multi-Status) responses from + a MOVE. These responses can be safely omitted because they are the + default success codes. + +9.9.3. MOVE and the Overwrite Header + + If a resource exists at the destination and the Overwrite header is + "T", then prior to performing the move, the server MUST perform a + DELETE with "Depth: infinity" on the destination resource. If the + Overwrite header is set to "F", then the operation will fail. + + + + + + + + + +Dusseault Standards Track [Page 58] + +RFC 4918 WebDAV June 2007 + + +9.9.4. Status Codes + + In addition to the general status codes possible, the following + status codes have specific applicability to MOVE: + + 201 (Created) - The source resource was successfully moved, and a new + URL mapping was created at the destination. + + 204 (No Content) - The source resource was successfully moved to a + URL that was already mapped. + + 207 (Multi-Status) - Multiple resources were to be affected by the + MOVE, but errors on some of them prevented the operation from taking + place. Specific error messages, together with the most appropriate + of the source and destination URLs, appear in the body of the multi- + status response. For example, if a source resource was locked and + could not be moved, then the source resource URL appears with the 423 + (Locked) status. + + 403 (Forbidden) - Among many possible reasons for forbidding a MOVE + operation, this status code is recommended for use when the source + and destination resources are the same. + + 409 (Conflict) - A resource cannot be created at the destination + until one or more intermediate collections have been created. The + server MUST NOT create those intermediate collections automatically. + Or, the server was unable to preserve the behavior of the live + properties and still move the resource to the destination (see + 'preserved-live-properties' postcondition). + + 412 (Precondition Failed) - A condition header failed. Specific to + MOVE, this could mean that the Overwrite header is "F" and the + destination URL is already mapped to a resource. + + 423 (Locked) - The source or the destination resource, the source or + destination resource parent, or some resource within the source or + destination collection, was locked. This response SHOULD contain the + 'lock-token-submitted' precondition element. + + 502 (Bad Gateway) - This may occur when the destination is on another + server and the destination server refuses to accept the resource. + This could also occur when the destination is on another sub-section + of the same server namespace. + + + + + + + + +Dusseault Standards Track [Page 59] + +RFC 4918 WebDAV June 2007 + + +9.9.5. Example - MOVE of a Non-Collection + + This example shows resource + http://www.example.com/~fielding/index.html being moved to the + location http://www.example.com/users/f/fielding/index.html. The + contents of the destination resource would have been overwritten if + the destination URL was already mapped to a resource. In this case, + since there was nothing at the destination resource, the response + code is 201 (Created). + + >>Request + + MOVE /~fielding/index.html HTTP/1.1 + Host: www.example.com + Destination: http://www.example/users/f/fielding/index.html + + >>Response + + HTTP/1.1 201 Created + Location: http://www.example.com/users/f/fielding/index.html + +9.9.6. Example - MOVE of a Collection + + >>Request + + MOVE /container/ HTTP/1.1 + Host: www.example.com + Destination: http://www.example.com/othercontainer/ + Overwrite: F + If: () + () + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/othercontainer/C2/ + HTTP/1.1 423 Locked + + + + + + + + +Dusseault Standards Track [Page 60] + +RFC 4918 WebDAV June 2007 + + + In this example, the client has submitted a number of lock tokens + with the request. A lock token will need to be submitted for every + resource, both source and destination, anywhere in the scope of the + method, that is locked. In this case, the proper lock token was not + submitted for the destination + http://www.example.com/othercontainer/C2/. This means that the + resource /container/C2/ could not be moved. Because there was an + error moving /container/C2/, none of /container/C2's members were + moved. However, no errors were listed for those members due to the + error minimization rules. User agent authentication has previously + occurred via a mechanism outside the scope of the HTTP protocol, in + an underlying transport layer. + +9.10. LOCK Method + + The following sections describe the LOCK method, which is used to + take out a lock of any access type and to refresh an existing lock. + These sections on the LOCK method describe only those semantics that + are specific to the LOCK method and are independent of the access + type of the lock being requested. + + Any resource that supports the LOCK method MUST, at minimum, support + the XML request and response formats defined herein. + + This method is neither idempotent nor safe (see Section 9.1 of + [RFC2616]). Responses to this method MUST NOT be cached. + +9.10.1. Creating a Lock on an Existing Resource + + A LOCK request to an existing resource will create a lock on the + resource identified by the Request-URI, provided the resource is not + already locked with a conflicting lock. The resource identified in + the Request-URI becomes the root of the lock. LOCK method requests + to create a new lock MUST have an XML request body. The server MUST + preserve the information provided by the client in the 'owner' + element in the LOCK request. The LOCK request MAY have a Timeout + header. + + When a new lock is created, the LOCK response: + + o MUST contain a body with the value of the DAV:lockdiscovery + property in a prop XML element. This MUST contain the full + information about the lock just granted, while information about + other (shared) locks is OPTIONAL. + + o MUST include the Lock-Token response header with the token + associated with the new lock. + + + + +Dusseault Standards Track [Page 61] + +RFC 4918 WebDAV June 2007 + + +9.10.2. Refreshing Locks + + A lock is refreshed by sending a LOCK request to the URL of a + resource within the scope of the lock. This request MUST NOT have a + body and it MUST specify which lock to refresh by using the 'If' + header with a single lock token (only one lock may be refreshed at a + time). The request MAY contain a Timeout header, which a server MAY + accept to change the duration remaining on the lock to the new value. + A server MUST ignore the Depth header on a LOCK refresh. + + If the resource has other (shared) locks, those locks are unaffected + by a lock refresh. Additionally, those locks do not prevent the + named lock from being refreshed. + + The Lock-Token header is not returned in the response for a + successful refresh LOCK request, but the LOCK response body MUST + contain the new value for the DAV:lockdiscovery property. + +9.10.3. Depth and Locking + + The Depth header may be used with the LOCK method. Values other than + 0 or infinity MUST NOT be used with the Depth header on a LOCK + method. All resources that support the LOCK method MUST support the + Depth header. + + A Depth header of value 0 means to just lock the resource specified + by the Request-URI. + + If the Depth header is set to infinity, then the resource specified + in the Request-URI along with all its members, all the way down the + hierarchy, are to be locked. A successful result MUST return a + single lock token. Similarly, if an UNLOCK is successfully executed + on this token, all associated resources are unlocked. Hence, partial + success is not an option for LOCK or UNLOCK. Either the entire + hierarchy is locked or no resources are locked. + + If the lock cannot be granted to all resources, the server MUST + return a Multi-Status response with a 'response' element for at least + one resource that prevented the lock from being granted, along with a + suitable status code for that failure (e.g., 403 (Forbidden) or 423 + (Locked)). Additionally, if the resource causing the failure was not + the resource requested, then the server SHOULD include a 'response' + element for the Request-URI as well, with a 'status' element + containing 424 Failed Dependency. + + If no Depth header is submitted on a LOCK request, then the request + MUST act as if a "Depth:infinity" had been submitted. + + + + +Dusseault Standards Track [Page 62] + +RFC 4918 WebDAV June 2007 + + +9.10.4. Locking Unmapped URLs + + A successful LOCK method MUST result in the creation of an empty + resource that is locked (and that is not a collection) when a + resource did not previously exist at that URL. Later on, the lock + may go away but the empty resource remains. Empty resources MUST + then appear in PROPFIND responses including that URL in the response + scope. A server MUST respond successfully to a GET request to an + empty resource, either by using a 204 No Content response, or by + using 200 OK with a Content-Length header indicating zero length + +9.10.5. Lock Compatibility Table + + The table below describes the behavior that occurs when a lock + request is made on a resource. + + +--------------------------+----------------+-------------------+ + | Current State | Shared Lock OK | Exclusive Lock OK | + +--------------------------+----------------+-------------------+ + | None | True | True | + | Shared Lock | True | False | + | Exclusive Lock | False | False* | + +--------------------------+----------------+-------------------+ + + Legend: True = lock may be granted. False = lock MUST NOT be + granted. *=It is illegal for a principal to request the same lock + twice. + + The current lock state of a resource is given in the leftmost column, + and lock requests are listed in the first row. The intersection of a + row and column gives the result of a lock request. For example, if a + shared lock is held on a resource, and an exclusive lock is + requested, the table entry is "false", indicating that the lock must + not be granted. + +9.10.6. LOCK Responses + + In addition to the general status codes possible, the following + status codes have specific applicability to LOCK: + + 200 (OK) - The LOCK request succeeded and the value of the DAV: + lockdiscovery property is included in the response body. + + 201 (Created) - The LOCK request was to an unmapped URL, the request + succeeded and resulted in the creation of a new resource, and the + value of the DAV:lockdiscovery property is included in the response + body. + + + + +Dusseault Standards Track [Page 63] + +RFC 4918 WebDAV June 2007 + + + 409 (Conflict) - A resource cannot be created at the destination + until one or more intermediate collections have been created. The + server MUST NOT create those intermediate collections automatically. + + 423 (Locked), potentially with 'no-conflicting-lock' precondition + code - There is already a lock on the resource that is not compatible + with the requested lock (see lock compatibility table above). + + 412 (Precondition Failed), with 'lock-token-matches-request-uri' + precondition code - The LOCK request was made with an If header, + indicating that the client wishes to refresh the given lock. + However, the Request-URI did not fall within the scope of the lock + identified by the token. The lock may have a scope that does not + include the Request-URI, or the lock could have disappeared, or the + token may be invalid. + +9.10.7. Example - Simple Lock Request + + >>Request + + LOCK /workspace/webdav/proposal.doc HTTP/1.1 + Host: example.com + Timeout: Infinite, Second-4100000000 + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="ejw", + realm="ejw@example.com", nonce="...", + uri="/workspace/webdav/proposal.doc", + response="...", opaque="..." + + + + + + + http://example.org/~ejw/contact.html + + + + >>Response + + HTTP/1.1 200 OK + Lock-Token: + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + +Dusseault Standards Track [Page 64] + +RFC 4918 WebDAV June 2007 + + + + + + + infinity + + http://example.org/~ejw/contact.html + + Second-604800 + + urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4 + + + http://example.com/workspace/webdav/proposal.doc + + + + + + + This example shows the successful creation of an exclusive write lock + on resource http://example.com/workspace/webdav/proposal.doc. The + resource http://example.org/~ejw/contact.html contains contact + information for the creator of the lock. The server has an activity- + based timeout policy in place on this resource, which causes the lock + to automatically be removed after 1 week (604800 seconds). Note that + the nonce, response, and opaque fields have not been calculated in + the Authorization request header. + +9.10.8. Example - Refreshing a Write Lock + + >>Request + + LOCK /workspace/webdav/proposal.doc HTTP/1.1 + Host: example.com + Timeout: Infinite, Second-4100000000 + If: () + Authorization: Digest username="ejw", + realm="ejw@example.com", nonce="...", + uri="/workspace/webdav/proposal.doc", + response="...", opaque="..." + + + + + + + + +Dusseault Standards Track [Page 65] + +RFC 4918 WebDAV June 2007 + + + >>Response + + HTTP/1.1 200 OK + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + infinity + + http://example.org/~ejw/contact.html + + Second-604800 + + urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4 + + + http://example.com/workspace/webdav/proposal.doc + + + + + + + This request would refresh the lock, attempting to reset the timeout + to the new value specified in the timeout header. Notice that the + client asked for an infinite time out but the server choose to ignore + the request. In this example, the nonce, response, and opaque fields + have not been calculated in the Authorization request header. + +9.10.9. Example - Multi-Resource Lock Request + + >>Request + + LOCK /webdav/ HTTP/1.1 + Host: example.com + Timeout: Infinite, Second-4100000000 + Depth: infinity + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + Authorization: Digest username="ejw", + realm="ejw@example.com", nonce="...", + + + +Dusseault Standards Track [Page 66] + +RFC 4918 WebDAV June 2007 + + + uri="/workspace/webdav/proposal.doc", + response="...", opaque="..." + + + + + + + http://example.org/~ejw/contact.html + + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://example.com/webdav/secret + HTTP/1.1 403 Forbidden + + + http://example.com/webdav/ + HTTP/1.1 424 Failed Dependency + + + + + This example shows a request for an exclusive write lock on a + collection and all its children. In this request, the client has + specified that it desires an infinite-length lock, if available, + otherwise a timeout of 4.1 billion seconds, if available. The + request entity body contains the contact information for the + principal taking out the lock -- in this case, a Web page URL. + + The error is a 403 (Forbidden) response on the resource + http://example.com/webdav/secret. Because this resource could not be + locked, none of the resources were locked. Note also that the a + 'response' element for the Request-URI itself has been included as + required. + + In this example, the nonce, response, and opaque fields have not been + calculated in the Authorization request header. + + + + + +Dusseault Standards Track [Page 67] + +RFC 4918 WebDAV June 2007 + + +9.11. UNLOCK Method + + The UNLOCK method removes the lock identified by the lock token in + the Lock-Token request header. The Request-URI MUST identify a + resource within the scope of the lock. + + Note that use of the Lock-Token header to provide the lock token is + not consistent with other state-changing methods, which all require + an If header with the lock token. Thus, the If header is not needed + to provide the lock token. Naturally, when the If header is present, + it has its normal meaning as a conditional header. + + For a successful response to this method, the server MUST delete the + lock entirely. + + If all resources that have been locked under the submitted lock token + cannot be unlocked, then the UNLOCK request MUST fail. + + A successful response to an UNLOCK method does not mean that the + resource is necessarily unlocked. It means that the specific lock + corresponding to the specified token no longer exists. + + Any DAV-compliant resource that supports the LOCK method MUST support + the UNLOCK method. + + This method is idempotent, but not safe (see Section 9.1 of + [RFC2616]). Responses to this method MUST NOT be cached. + +9.11.1. Status Codes + + In addition to the general status codes possible, the following + status codes have specific applicability to UNLOCK: + + 204 (No Content) - Normal success response (rather than 200 OK, since + 200 OK would imply a response body, and an UNLOCK success response + does not normally contain a body). + + 400 (Bad Request) - No lock token was provided. + + 403 (Forbidden) - The currently authenticated principal does not have + permission to remove the lock. + + 409 (Conflict), with 'lock-token-matches-request-uri' precondition - + The resource was not locked, or the request was made to a Request-URI + that was not within the scope of the lock. + + + + + + +Dusseault Standards Track [Page 68] + +RFC 4918 WebDAV June 2007 + + +9.11.2. Example - UNLOCK + + >>Request + + UNLOCK /workspace/webdav/info.doc HTTP/1.1 + Host: example.com + Lock-Token: + Authorization: Digest username="ejw" + realm="ejw@example.com", nonce="...", + uri="/workspace/webdav/proposal.doc", + response="...", opaque="..." + + >>Response + + HTTP/1.1 204 No Content + + In this example, the lock identified by the lock token + "urn:uuid:a515cfa4-5da4-22e1-f5b5-00a0451e6bf7" is successfully + removed from the resource + http://example.com/workspace/webdav/info.doc. If this lock included + more than just one resource, the lock is removed from all resources + included in the lock. + + In this example, the nonce, response, and opaque fields have not been + calculated in the Authorization request header. + +10. HTTP Headers for Distributed Authoring + + All DAV headers follow the same basic formatting rules as HTTP + headers. This includes rules like line continuation and how to + combine (or separate) multiple instances of the same header using + commas. + + WebDAV adds two new conditional headers to the set defined in HTTP: + the If and Overwrite headers. + +10.1. DAV Header + + DAV = "DAV" ":" #( compliance-class ) + compliance-class = ( "1" | "2" | "3" | extend ) + extend = Coded-URL | token + ; token is defined in RFC 2616, Section 2.2 + Coded-URL = "<" absolute-URI ">" + ; No linear whitespace (LWS) allowed in Coded-URL + ; absolute-URI defined in RFC 3986, Section 4.3 + + + + + + +Dusseault Standards Track [Page 69] + +RFC 4918 WebDAV June 2007 + + + This general-header appearing in the response indicates that the + resource supports the DAV schema and protocol as specified. All DAV- + compliant resources MUST return the DAV header with compliance-class + "1" on all OPTIONS responses. In cases where WebDAV is only + supported in part of the server namespace, an OPTIONS request to non- + WebDAV resources (including "/") SHOULD NOT advertise WebDAV support. + + The value is a comma-separated list of all compliance class + identifiers that the resource supports. Class identifiers may be + Coded-URLs or tokens (as defined by [RFC2616]). Identifiers can + appear in any order. Identifiers that are standardized through the + IETF RFC process are tokens, but other identifiers SHOULD be Coded- + URLs to encourage uniqueness. + + A resource must show class 1 compliance if it shows class 2 or 3 + compliance. In general, support for one compliance class does not + entail support for any other, and in particular, support for + compliance class 3 does not require support for compliance class 2. + Please refer to Section 18 for more details on compliance classes + defined in this specification. + + Note that many WebDAV servers do not advertise WebDAV support in + response to "OPTIONS *". + + As a request header, this header allows the client to advertise + compliance with named features when the server needs that + information. Clients SHOULD NOT send this header unless a standards + track specification requires it. Any extension that makes use of + this as a request header will need to carefully consider caching + implications. + +10.2. Depth Header + + Depth = "Depth" ":" ("0" | "1" | "infinity") + + The Depth request header is used with methods executed on resources + that could potentially have internal members to indicate whether the + method is to be applied only to the resource ("Depth: 0"), to the + resource and its internal members only ("Depth: 1"), or the resource + and all its members ("Depth: infinity"). + + The Depth header is only supported if a method's definition + explicitly provides for such support. + + The following rules are the default behavior for any method that + supports the Depth header. A method may override these defaults by + defining different behavior in its definition. + + + + +Dusseault Standards Track [Page 70] + +RFC 4918 WebDAV June 2007 + + + Methods that support the Depth header may choose not to support all + of the header's values and may define, on a case-by-case basis, the + behavior of the method if a Depth header is not present. For + example, the MOVE method only supports "Depth: infinity", and if a + Depth header is not present, it will act as if a "Depth: infinity" + header had been applied. + + Clients MUST NOT rely upon methods executing on members of their + hierarchies in any particular order or on the execution being atomic + unless the particular method explicitly provides such guarantees. + + Upon execution, a method with a Depth header will perform as much of + its assigned task as possible and then return a response specifying + what it was able to accomplish and what it failed to do. + + So, for example, an attempt to COPY a hierarchy may result in some of + the members being copied and some not. + + By default, the Depth header does not interact with other headers. + That is, each header on a request with a Depth header MUST be applied + only to the Request-URI if it applies to any resource, unless + specific Depth behavior is defined for that header. + + If a source or destination resource within the scope of the Depth + header is locked in such a way as to prevent the successful execution + of the method, then the lock token for that resource MUST be + submitted with the request in the If request header. + + The Depth header only specifies the behavior of the method with + regards to internal members. If a resource does not have internal + members, then the Depth header MUST be ignored. + +10.3. Destination Header + + The Destination request header specifies the URI that identifies a + destination resource for methods such as COPY and MOVE, which take + two URIs as parameters. + + Destination = "Destination" ":" Simple-ref + + + If the Destination value is an absolute-URI (Section 4.3 of + [RFC3986]), it may name a different server (or different port or + scheme). If the source server cannot attempt a copy to the remote + server, it MUST fail the request. Note that copying and moving + resources to remote servers is not fully defined in this + specification (e.g., specific error conditions). + + + + +Dusseault Standards Track [Page 71] + +RFC 4918 WebDAV June 2007 + + + If the Destination value is too long or otherwise unacceptable, the + server SHOULD return 400 (Bad Request), ideally with helpful + information in an error body. + +10.4. If Header + + The If request header is intended to have similar functionality to + the If-Match header defined in Section 14.24 of [RFC2616]. However, + the If header handles any state token as well as ETags. A typical + example of a state token is a lock token, and lock tokens are the + only state tokens defined in this specification. + +10.4.1. Purpose + + The If header has two distinct purposes: + + o The first purpose is to make a request conditional by supplying a + series of state lists with conditions that match tokens and ETags + to a specific resource. If this header is evaluated and all state + lists fail, then the request MUST fail with a 412 (Precondition + Failed) status. On the other hand, the request can succeed only + if one of the described state lists succeeds. The success + criteria for state lists and matching functions are defined in + Sections 10.4.3 and 10.4.4. + + o Additionally, the mere fact that a state token appears in an If + header means that it has been "submitted" with the request. In + general, this is used to indicate that the client has knowledge of + that state token. The semantics for submitting a state token + depend on its type (for lock tokens, please refer to Section 6). + + Note that these two purposes need to be treated distinctly: a state + token counts as being submitted independently of whether the server + actually has evaluated the state list it appears in, and also + independently of whether or not the condition it expressed was found + to be true. + +10.4.2. Syntax + + If = "If" ":" ( 1*No-tag-list | 1*Tagged-list ) + + No-tag-list = List + Tagged-list = Resource-Tag 1*List + + List = "(" 1*Condition ")" + Condition = ["Not"] (State-token | "[" entity-tag "]") + ; entity-tag: see Section 3.11 of [RFC2616] + ; No LWS allowed between "[", entity-tag and "]" + + + +Dusseault Standards Track [Page 72] + +RFC 4918 WebDAV June 2007 + + + State-token = Coded-URL + + Resource-Tag = "<" Simple-ref ">" + ; Simple-ref: see Section 8.3 + ; No LWS allowed in Resource-Tag + + The syntax distinguishes between untagged lists ("No-tag-list") and + tagged lists ("Tagged-list"). Untagged lists apply to the resource + identified by the Request-URI, while tagged lists apply to the + resource identified by the preceding Resource-Tag. + + A Resource-Tag applies to all subsequent Lists, up to the next + Resource-Tag. + + Note that the two list types cannot be mixed within an If header. + This is not a functional restriction because the No-tag-list syntax + is just a shorthand notation for a Tagged-list production with a + Resource-Tag referring to the Request-URI. + + Each List consists of one or more Conditions. Each Condition is + defined in terms of an entity-tag or state-token, potentially negated + by the prefix "Not". + + Note that the If header syntax does not allow multiple instances of + If headers in a single request. However, the HTTP header syntax + allows extending single header values across multiple lines, by + inserting a line break followed by whitespace (see [RFC2616], Section + 4.2). + +10.4.3. List Evaluation + + A Condition that consists of a single entity-tag or state-token + evaluates to true if the resource matches the described state (where + the individual matching functions are defined below in + Section 10.4.4). Prefixing it with "Not" reverses the result of the + evaluation (thus, the "Not" applies only to the subsequent entity-tag + or state-token). + + Each List production describes a series of conditions. The whole + list evaluates to true if and only if each condition evaluates to + true (that is, the list represents a logical conjunction of + Conditions). + + Each No-tag-list and Tagged-list production may contain one or more + Lists. They evaluate to true if and only if any of the contained + lists evaluates to true (that is, if there's more than one List, that + List sequence represents a logical disjunction of the Lists). + + + + +Dusseault Standards Track [Page 73] + +RFC 4918 WebDAV June 2007 + + + Finally, the whole If header evaluates to true if and only if at + least one of the No-tag-list or Tagged-list productions evaluates to + true. If the header evaluates to false, the server MUST reject the + request with a 412 (Precondition Failed) status. Otherwise, + execution of the request can proceed as if the header wasn't present. + +10.4.4. Matching State Tokens and ETags + + When performing If header processing, the definition of a matching + state token or entity tag is as follows: + + Identifying a resource: The resource is identified by the URI along + with the token, in tagged list production, or by the Request-URI in + untagged list production. + + Matching entity tag: Where the entity tag matches an entity tag + associated with the identified resource. Servers MUST use either the + weak or the strong comparison function defined in Section 13.3.3 of + [RFC2616]. + + Matching state token: Where there is an exact match between the state + token in the If header and any state token on the identified + resource. A lock state token is considered to match if the resource + is anywhere in the scope of the lock. + + Handling unmapped URLs: For both ETags and state tokens, treat as if + the URL identified a resource that exists but does not have the + specified state. + +10.4.5. If Header and Non-DAV-Aware Proxies + + Non-DAV-aware proxies will not honor the If header, since they will + not understand the If header, and HTTP requires non-understood + headers to be ignored. When communicating with HTTP/1.1 proxies, the + client MUST use the "Cache-Control: no-cache" request header so as to + prevent the proxy from improperly trying to service the request from + its cache. When dealing with HTTP/1.0 proxies, the "Pragma: no- + cache" request header MUST be used for the same reason. + + Because in general clients may not be able to reliably detect non- + DAV-aware intermediates, they are advised to always prevent caching + using the request directives mentioned above. + + + + + + + + + +Dusseault Standards Track [Page 74] + +RFC 4918 WebDAV June 2007 + + +10.4.6. Example - No-tag Production + + If: ( + ["I am an ETag"]) + (["I am another ETag"]) + + The previous header would require that the resource identified in the + Request-URI be locked with the specified lock token and be in the + state identified by the "I am an ETag" ETag or in the state + identified by the second ETag "I am another ETag". + + To put the matter more plainly one can think of the previous If + header as expressing the condition below: + + ( + is-locked-with(urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2) AND + matches-etag("I am an ETag") + ) + OR + ( + matches-etag("I am another ETag") + ) + +10.4.7. Example - Using "Not" with No-tag Production + + If: (Not + ) + + This If header requires that the resource must not be locked with a + lock having the lock token + urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2 and must be locked by a + lock with the lock token + urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092. + +10.4.8. Example - Causing a Condition to Always Evaluate to True + + There may be cases where a client wishes to submit state tokens, but + doesn't want the request to fail just because the state token isn't + current anymore. One simple way to do this is to include a Condition + that is known to always evaluate to true, such as in: + + If: () + (Not ) + + "DAV:no-lock" is known to never represent a current lock token. Lock + tokens are assigned by the server, following the uniqueness + requirements described in Section 6.5, therefore cannot use the + "DAV:" scheme. Thus, by applying "Not" to a state token that is + + + +Dusseault Standards Track [Page 75] + +RFC 4918 WebDAV June 2007 + + + known not to be current, the Condition always evaluates to true. + Consequently, the whole If header will always evaluate to true, and + the lock token urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2 will be + submitted in any case. + +10.4.9. Example - Tagged List If Header in COPY + + >>Request + + COPY /resource1 HTTP/1.1 + Host: www.example.com + Destination: /resource2 + If: + ( + [W/"A weak ETag"]) (["strong ETag"]) + + In this example, http://www.example.com/resource1 is being copied to + http://www.example.com/resource2. When the method is first applied + to http://www.example.com/resource1, resource1 must be in the state + specified by "( [W/"A + weak ETag"]) (["strong ETag"])". That is, either it must be locked + with a lock token of "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2" + and have a weak entity tag W/"A weak ETag" or it must have a strong + entity tag "strong ETag". + +10.4.10. Example - Matching Lock Tokens with Collection Locks + + DELETE /specs/rfc2518.txt HTTP/1.1 + Host: www.example.com + If: + () + + For this example, the lock token must be compared to the identified + resource, which is the 'specs' collection identified by the URL in + the tagged list production. If the 'specs' collection is not locked + by a lock with the specified lock token, the request MUST fail. + Otherwise, this request could succeed, because the If header + evaluates to true, and because the lock token for the lock affecting + the affected resource has been submitted. + +10.4.11. Example - Matching ETags on Unmapped URLs + + Consider a collection "/specs" that does not contain the member + "/specs/rfc2518.doc". In this case, the If header + + If: (["4217"]) + + + + + +Dusseault Standards Track [Page 76] + +RFC 4918 WebDAV June 2007 + + + will evaluate to false (the URI isn't mapped, thus the resource + identified by the URI doesn't have an entity matching the ETag + "4217"). + + On the other hand, an If header of + + If: (Not ["4217"]) + + will consequently evaluate to true. + + Note that, as defined above in Section 10.4.4, the same + considerations apply to matching state tokens. + +10.5. Lock-Token Header + + Lock-Token = "Lock-Token" ":" Coded-URL + + The Lock-Token request header is used with the UNLOCK method to + identify the lock to be removed. The lock token in the Lock-Token + request header MUST identify a lock that contains the resource + identified by Request-URI as a member. + + The Lock-Token response header is used with the LOCK method to + indicate the lock token created as a result of a successful LOCK + request to create a new lock. + +10.6. Overwrite Header + + Overwrite = "Overwrite" ":" ("T" | "F") + + The Overwrite request header specifies whether the server should + overwrite a resource mapped to the destination URL during a COPY or + MOVE. A value of "F" states that the server must not perform the + COPY or MOVE operation if the destination URL does map to a resource. + If the overwrite header is not included in a COPY or MOVE request, + then the resource MUST treat the request as if it has an overwrite + header of value "T". While the Overwrite header appears to duplicate + the functionality of using an "If-Match: *" header (see [RFC2616]), + If-Match applies only to the Request-URI, and not to the Destination + of a COPY or MOVE. + + If a COPY or MOVE is not performed due to the value of the Overwrite + header, the method MUST fail with a 412 (Precondition Failed) status + code. The server MUST do authorization checks before checking this + or any conditional header. + + All DAV-compliant resources MUST support the Overwrite header. + + + + +Dusseault Standards Track [Page 77] + +RFC 4918 WebDAV June 2007 + + +10.7. Timeout Request Header + + TimeOut = "Timeout" ":" 1#TimeType + TimeType = ("Second-" DAVTimeOutVal | "Infinite") + ; No LWS allowed within TimeType + DAVTimeOutVal = 1*DIGIT + + Clients MAY include Timeout request headers in their LOCK requests. + However, the server is not required to honor or even consider these + requests. Clients MUST NOT submit a Timeout request header with any + method other than a LOCK method. + + The "Second" TimeType specifies the number of seconds that will + elapse between granting of the lock at the server, and the automatic + removal of the lock. The timeout value for TimeType "Second" MUST + NOT be greater than 2^32-1. + + See Section 6.6 for a description of lock timeout behavior. + +11. Status Code Extensions to HTTP/1.1 + + The following status codes are added to those defined in HTTP/1.1 + [RFC2616]. + +11.1. 207 Multi-Status + + The 207 (Multi-Status) status code provides status for multiple + independent operations (see Section 13 for more information). + +11.2. 422 Unprocessable Entity + + The 422 (Unprocessable Entity) status code means the server + understands the content type of the request entity (hence a + 415(Unsupported Media Type) status code is inappropriate), and the + syntax of the request entity is correct (thus a 400 (Bad Request) + status code is inappropriate) but was unable to process the contained + instructions. For example, this error condition may occur if an XML + request body contains well-formed (i.e., syntactically correct), but + semantically erroneous, XML instructions. + +11.3. 423 Locked + + The 423 (Locked) status code means the source or destination resource + of a method is locked. This response SHOULD contain an appropriate + precondition or postcondition code, such as 'lock-token-submitted' or + 'no-conflicting-lock'. + + + + + +Dusseault Standards Track [Page 78] + +RFC 4918 WebDAV June 2007 + + +11.4. 424 Failed Dependency + + The 424 (Failed Dependency) status code means that the method could + not be performed on the resource because the requested action + depended on another action and that action failed. For example, if a + command in a PROPPATCH method fails, then, at minimum, the rest of + the commands will also fail with 424 (Failed Dependency). + +11.5. 507 Insufficient Storage + + The 507 (Insufficient Storage) status code means the method could not + be performed on the resource because the server is unable to store + the representation needed to successfully complete the request. This + condition is considered to be temporary. If the request that + received this status code was the result of a user action, the + request MUST NOT be repeated until it is requested by a separate user + action. + +12. Use of HTTP Status Codes + + These HTTP codes are not redefined, but their use is somewhat + extended by WebDAV methods and requirements. In general, many HTTP + status codes can be used in response to any request, not just in + cases described in this document. Note also that WebDAV servers are + known to use 300-level redirect responses (and early interoperability + tests found clients unprepared to see those responses). A 300-level + response MUST NOT be used when the server has created a new resource + in response to the request. + +12.1. 412 Precondition Failed + + Any request can contain a conditional header defined in HTTP (If- + Match, If-Modified-Since, etc.) or the "If" or "Overwrite" + conditional headers defined in this specification. If the server + evaluates a conditional header, and if that condition fails to hold, + then this error code MUST be returned. On the other hand, if the + client did not include a conditional header in the request, then the + server MUST NOT use this status code. + +12.2. 414 Request-URI Too Long + + This status code is used in HTTP 1.1 only for Request-URIs, not URIs + in other locations. + + + + + + + + +Dusseault Standards Track [Page 79] + +RFC 4918 WebDAV June 2007 + + +13. Multi-Status Response + + A Multi-Status response conveys information about multiple resources + in situations where multiple status codes might be appropriate. The + default Multi-Status response body is a text/xml or application/xml + HTTP entity with a 'multistatus' root element. Further elements + contain 200, 300, 400, and 500 series status codes generated during + the method invocation. 100 series status codes SHOULD NOT be recorded + in a 'response' XML element. + + Although '207' is used as the overall response status code, the + recipient needs to consult the contents of the multistatus response + body for further information about the success or failure of the + method execution. The response MAY be used in success, partial + success and also in failure situations. + + The 'multistatus' root element holds zero or more 'response' elements + in any order, each with information about an individual resource. + Each 'response' element MUST have an 'href' element to identify the + resource. + + A Multi-Status response uses one out of two distinct formats for + representing the status: + + 1. A 'status' element as child of the 'response' element indicates + the status of the message execution for the identified resource + as a whole (for instance, see Section 9.6.2). Some method + definitions provide information about specific status codes + clients should be prepared to see in a response. However, + clients MUST be able to handle other status codes, using the + generic rules defined in Section 10 of [RFC2616]. + + 2. For PROPFIND and PROPPATCH, the format has been extended using + the 'propstat' element instead of 'status', providing information + about individual properties of a resource. This format is + specific to PROPFIND and PROPPATCH, and is described in detail in + Sections 9.1 and 9.2. + +13.1. Response Headers + + HTTP defines the Location header to indicate a preferred URL for the + resource that was addressed in the Request-URI (e.g., in response to + successful PUT requests or in redirect responses). However, use of + this header creates ambiguity when there are URLs in the body of the + response, as with Multi-Status. Thus, use of the Location header + with the Multi-Status response is intentionally undefined. + + + + + +Dusseault Standards Track [Page 80] + +RFC 4918 WebDAV June 2007 + + +13.2. Handling Redirected Child Resources + + Redirect responses (300-303, 305, and 307) defined in HTTP 1.1 + normally take a Location header to indicate the new URI for the + single resource redirected from the Request-URI. Multi-Status + responses contain many resource addresses, but the original + definition in [RFC2518] did not have any place for the server to + provide the new URI for redirected resources. This specification + does define a 'location' element for this information (see + Section 14.9). Servers MUST use this new element with redirect + responses in Multi-Status. + + Clients encountering redirected resources in Multi-Status MUST NOT + rely on the 'location' element being present with a new URI. If the + element is not present, the client MAY reissue the request to the + individual redirected resource, because the response to that request + can be redirected with a Location header containing the new URI. + +13.3. Internal Status Codes + + Sections 9.2.1, 9.1.2, 9.6.1, 9.8.3, and 9.9.2 define various status + codes used in Multi-Status responses. This specification does not + define the meaning of other status codes that could appear in these + responses. + +14. XML Element Definitions + + In this section, the final line of each section gives the element + type declaration using the format defined in [REC-XML]. The "Value" + field, where present, specifies further restrictions on the allowable + contents of the XML element using BNF (i.e., to further restrict the + values of a PCDATA element). Note that all of the elements defined + here may be extended according to the rules defined in Section 17. + All elements defined here are in the "DAV:" namespace. + +14.1. activelock XML Element + + Name: activelock + + Purpose: Describes a lock on a resource. + + + + + + + + + + +Dusseault Standards Track [Page 81] + +RFC 4918 WebDAV June 2007 + + +14.2. allprop XML Element + + Name: allprop + + Purpose: Specifies that all names and values of dead properties and + the live properties defined by this document existing on the + resource are to be returned. + + + +14.3. collection XML Element + + Name: collection + + Purpose: Identifies the associated resource as a collection. The + DAV:resourcetype property of a collection resource MUST contain + this element. It is normally empty but extensions may add sub- + elements. + + + +14.4. depth XML Element + + Name: depth + + Purpose: Used for representing depth values in XML content (e.g., + in lock information). + + Value: "0" | "1" | "infinity" + + + +14.5. error XML Element + + Name: error + + Purpose: Error responses, particularly 403 Forbidden and 409 + Conflict, sometimes need more information to indicate what went + wrong. In these cases, servers MAY return an XML response body + with a document element of 'error', containing child elements + identifying particular condition codes. + + Description: Contains at least one XML element, and MUST NOT + contain text or mixed content. Any element that is a child of the + 'error' element is considered to be a precondition or + postcondition code. Unrecognized elements MUST be ignored. + + + + + +Dusseault Standards Track [Page 82] + +RFC 4918 WebDAV June 2007 + + +14.6. exclusive XML Element + + Name: exclusive + + Purpose: Specifies an exclusive lock. + + + + + +14.7. href XML Element + + Name: href + + Purpose: MUST contain a URI or a relative reference. + + Description: There may be limits on the value of 'href' depending + on the context of its use. Refer to the specification text where + 'href' is used to see what limitations apply in each case. + + Value: Simple-ref + + + + +14.8. include XML Element + + Name: include + + Purpose: Any child element represents the name of a property to be + included in the PROPFIND response. All elements inside an + 'include' XML element MUST define properties related to the + resource, although possible property names are in no way limited + to those property names defined in this document or other + standards. This element MUST NOT contain text or mixed content. + + + +14.9. location XML Element + + Name: location + + Purpose: HTTP defines the "Location" header (see [RFC2616], Section + 14.30) for use with some status codes (such as 201 and the 300 + series codes). When these codes are used inside a 'multistatus' + element, the 'location' element can be used to provide the + accompanying Location header value. + + + + +Dusseault Standards Track [Page 83] + +RFC 4918 WebDAV June 2007 + + + Description: Contains a single href element with the same value + that would be used in a Location header. + + + + +14.10. lockentry XML Element + + Name: lockentry + + Purpose: Defines the types of locks that can be used with the + resource. + + + +14.11. lockinfo XML Element + + Name: lockinfo + + Purpose: The 'lockinfo' XML element is used with a LOCK method to + specify the type of lock the client wishes to have created. + + + + +14.12. lockroot XML Element + + Name: lockroot + + Purpose: Contains the root URL of the lock, which is the URL + through which the resource was addressed in the LOCK request. + + Description: The href element contains the root of the lock. The + server SHOULD include this in all DAV:lockdiscovery property + values and the response to LOCK requests. + + + +14.13. lockscope XML Element + + Name: lockscope + + Purpose: Specifies whether a lock is an exclusive lock, or a shared + lock. + + + + + + + +Dusseault Standards Track [Page 84] + +RFC 4918 WebDAV June 2007 + + +14.14. locktoken XML Element + + Name: locktoken + + Purpose: The lock token associated with a lock. + + Description: The href contains a single lock token URI, which + refers to the lock. + + + +14.15. locktype XML Element + + Name: locktype + + Purpose: Specifies the access type of a lock. At present, this + specification only defines one lock type, the write lock. + + + + + +14.16. multistatus XML Element + + Name: multistatus + + Purpose: Contains multiple response messages. + + Description: The 'responsedescription' element at the top level is + used to provide a general message describing the overarching + nature of the response. If this value is available, an + application may use it instead of presenting the individual + response descriptions contained within the responses. + + + + + +14.17. owner XML Element + + Name: owner + + Purpose: Holds client-supplied information about the creator of a + lock. + + Description: Allows a client to provide information sufficient for + either directly contacting a principal (such as a telephone number + or Email URI), or for discovering the principal (such as the URL + + + +Dusseault Standards Track [Page 85] + +RFC 4918 WebDAV June 2007 + + + of a homepage) who created a lock. The value provided MUST be + treated as a dead property in terms of XML Information Item + preservation. The server MUST NOT alter the value unless the + owner value provided by the client is empty. For a certain amount + of interoperability between different client implementations, if + clients have URI-formatted contact information for the lock + creator suitable for user display, then clients SHOULD put those + URIs in 'href' child elements of the 'owner' element. + + Extensibility: MAY be extended with child elements, mixed content, + text content or attributes. + + + +14.18. prop XML Element + + Name: prop + + Purpose: Contains properties related to a resource. + + Description: A generic container for properties defined on + resources. All elements inside a 'prop' XML element MUST define + properties related to the resource, although possible property + names are in no way limited to those property names defined in + this document or other standards. This element MUST NOT contain + text or mixed content. + + + +14.19. propertyupdate XML Element + + Name: propertyupdate + + Purpose: Contains a request to alter the properties on a resource. + + Description: This XML element is a container for the information + required to modify the properties on the resource. + + + +14.20. propfind XML Element + + Name: propfind + + + + + + + + +Dusseault Standards Track [Page 86] + +RFC 4918 WebDAV June 2007 + + + Purpose: Specifies the properties to be returned from a PROPFIND + method. Four special elements are specified for use with + 'propfind': 'prop', 'allprop', 'include', and 'propname'. If + 'prop' is used inside 'propfind', it MUST NOT contain property + values. + + + +14.21. propname XML Element + + Name: propname + + Purpose: Specifies that only a list of property names on the + resource is to be returned. + + + +14.22. propstat XML Element + + Name: propstat + + Purpose: Groups together a prop and status element that is + associated with a particular 'href' element. + + Description: The propstat XML element MUST contain one prop XML + element and one status XML element. The contents of the prop XML + element MUST only list the names of properties to which the result + in the status element applies. The optional precondition/ + postcondition element and 'responsedescription' text also apply to + the properties named in 'prop'. + + + +14.23. remove XML Element + + Name: remove + + Purpose: Lists the properties to be removed from a resource. + + Description: Remove instructs that the properties specified in prop + should be removed. Specifying the removal of a property that does + not exist is not an error. All the XML elements in a 'prop' XML + element inside of a 'remove' XML element MUST be empty, as only + the names of properties to be removed are required. + + + + + + + +Dusseault Standards Track [Page 87] + +RFC 4918 WebDAV June 2007 + + +14.24. response XML Element + + Name: response + + Purpose: Holds a single response describing the effect of a method + on resource and/or its properties. + + Description: The 'href' element contains an HTTP URL pointing to a + WebDAV resource when used in the 'response' container. A + particular 'href' value MUST NOT appear more than once as the + child of a 'response' XML element under a 'multistatus' XML + element. This requirement is necessary in order to keep + processing costs for a response to linear time. Essentially, this + prevents having to search in order to group together all the + responses by 'href'. There are, however, no requirements + regarding ordering based on 'href' values. The optional + precondition/postcondition element and 'responsedescription' text + can provide additional information about this resource relative to + the request or result. + + + + +14.25. responsedescription XML Element + + Name: responsedescription + + Purpose: Contains information about a status response within a + Multi-Status. + + Description: Provides information suitable to be presented to a + user. + + + +14.26. set XML Element + + Name: set + + Purpose: Lists the property values to be set for a resource. + + Description: The 'set' element MUST contain only a 'prop' element. + The elements contained by the 'prop' element inside the 'set' + element MUST specify the name and value of properties that are set + on the resource identified by Request-URI. If a property already + exists, then its value is replaced. Language tagging information + appearing in the scope of the 'prop' element (in the "xml:lang" + + + +Dusseault Standards Track [Page 88] + +RFC 4918 WebDAV June 2007 + + + attribute, if present) MUST be persistently stored along with the + property, and MUST be subsequently retrievable using PROPFIND. + + + +14.27. shared XML Element + + Name: shared + + Purpose: Specifies a shared lock. + + + + + +14.28. status XML Element + + Name: status + + Purpose: Holds a single HTTP status-line. + + Value: status-line (defined in Section 6.1 of [RFC2616]) + + + +14.29. timeout XML Element + + Name: timeout + + Purpose: The number of seconds remaining before a lock expires. + + Value: TimeType (defined in Section 10.7) + + + + +14.30. write XML Element + + Name: write + + Purpose: Specifies a write lock. + + + + + + + + + + +Dusseault Standards Track [Page 89] + +RFC 4918 WebDAV June 2007 + + +15. DAV Properties + + For DAV properties, the name of the property is also the same as the + name of the XML element that contains its value. In the section + below, the final line of each section gives the element type + declaration using the format defined in [REC-XML]. The "Value" + field, where present, specifies further restrictions on the allowable + contents of the XML element using BNF (i.e., to further restrict the + values of a PCDATA element). + + A protected property is one that cannot be changed with a PROPPATCH + request. There may be other requests that would result in a change + to a protected property (as when a LOCK request affects the value of + DAV:lockdiscovery). Note that a given property could be protected on + one type of resource, but not protected on another type of resource. + + A computed property is one with a value defined in terms of a + computation (based on the content and other properties of that + resource, or even of some other resource). A computed property is + always a protected property. + + COPY and MOVE behavior refers to local COPY and MOVE operations. + + For properties defined based on HTTP GET response headers (DAV:get*), + the header value could include LWS as defined in [RFC2616], Section + 4.2. Server implementors SHOULD strip LWS from these values before + using as WebDAV property values. + +15.1. creationdate Property + + Name: creationdate + + Purpose: Records the time and date the resource was created. + + Value: date-time (defined in [RFC3339], see the ABNF in Section + 5.6.) + + Protected: MAY be protected. Some servers allow DAV:creationdate + to be changed to reflect the time the document was created if that + is more meaningful to the user (rather than the time it was + uploaded). Thus, clients SHOULD NOT use this property in + synchronization logic (use DAV:getetag instead). + + COPY/MOVE behavior: This property value SHOULD be kept during a + MOVE operation, but is normally re-initialized when a resource is + created with a COPY. It should not be set in a COPY. + + + + + +Dusseault Standards Track [Page 90] + +RFC 4918 WebDAV June 2007 + + + Description: The DAV:creationdate property SHOULD be defined on all + DAV compliant resources. If present, it contains a timestamp of + the moment when the resource was created. Servers that are + incapable of persistently recording the creation date SHOULD + instead leave it undefined (i.e. report "Not Found"). + + + +15.2. displayname Property + + Name: displayname + + Purpose: Provides a name for the resource that is suitable for + presentation to a user. + + Value: Any text. + + Protected: SHOULD NOT be protected. Note that servers implementing + [RFC2518] might have made this a protected property as this is a + new requirement. + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: Contains a description of the resource that is + suitable for presentation to a user. This property is defined on + the resource, and hence SHOULD have the same value independent of + the Request-URI used to retrieve it (thus, computing this property + based on the Request-URI is deprecated). While generic clients + might display the property value to end users, client UI designers + must understand that the method for identifying resources is still + the URL. Changes to DAV:displayname do not issue moves or copies + to the server, but simply change a piece of meta-data on the + individual resource. Two resources can have the same DAV: + displayname value even within the same collection. + + + +15.3. getcontentlanguage Property + + Name: getcontentlanguage + + Purpose: Contains the Content-Language header value (from Section + 14.12 of [RFC2616]) as it would be returned by a GET without + accept headers. + + Value: language-tag (language-tag is defined in Section 3.10 of + [RFC2616]) + + + +Dusseault Standards Track [Page 91] + +RFC 4918 WebDAV June 2007 + + + Protected: SHOULD NOT be protected, so that clients can reset the + language. Note that servers implementing [RFC2518] might have + made this a protected property as this is a new requirement. + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: The DAV:getcontentlanguage property MUST be defined on + any DAV-compliant resource that returns the Content-Language + header on a GET. + + + +15.4. getcontentlength Property + + Name: getcontentlength + + Purpose: Contains the Content-Length header returned by a GET + without accept headers. + + Value: See Section 14.13 of [RFC2616]. + + Protected: This property is computed, therefore protected. + + Description: The DAV:getcontentlength property MUST be defined on + any DAV-compliant resource that returns the Content-Length header + in response to a GET. + + COPY/MOVE behavior: This property value is dependent on the size of + the destination resource, not the value of the property on the + source resource. + + + +15.5. getcontenttype Property + + Name: getcontenttype + + Purpose: Contains the Content-Type header value (from Section 14.17 + of [RFC2616]) as it would be returned by a GET without accept + headers. + + Value: media-type (defined in Section 3.7 of [RFC2616]) + + Protected: Potentially protected if the server prefers to assign + content types on its own (see also discussion in Section 9.7.1). + + + + + +Dusseault Standards Track [Page 92] + +RFC 4918 WebDAV June 2007 + + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: This property MUST be defined on any DAV-compliant + resource that returns the Content-Type header in response to a + GET. + + + +15.6. getetag Property + + Name: getetag + + Purpose: Contains the ETag header value (from Section 14.19 of + [RFC2616]) as it would be returned by a GET without accept + headers. + + Value: entity-tag (defined in Section 3.11 of [RFC2616]) + + Protected: MUST be protected because this value is created and + controlled by the server. + + COPY/MOVE behavior: This property value is dependent on the final + state of the destination resource, not the value of the property + on the source resource. Also note the considerations in + Section 8.8. + + Description: The getetag property MUST be defined on any DAV- + compliant resource that returns the Etag header. Refer to Section + 3.11 of RFC 2616 for a complete definition of the semantics of an + ETag, and to Section 8.6 for a discussion of ETags in WebDAV. + + + +15.7. getlastmodified Property + + Name: getlastmodified + + Purpose: Contains the Last-Modified header value (from Section + 14.29 of [RFC2616]) as it would be returned by a GET method + without accept headers. + + Value: rfc1123-date (defined in Section 3.3.1 of [RFC2616]) + + Protected: SHOULD be protected because some clients may rely on the + value for appropriate caching behavior, or on the value of the + Last-Modified header to which this property is linked. + + + + +Dusseault Standards Track [Page 93] + +RFC 4918 WebDAV June 2007 + + + COPY/MOVE behavior: This property value is dependent on the last + modified date of the destination resource, not the value of the + property on the source resource. Note that some server + implementations use the file system date modified value for the + DAV:getlastmodified value, and this can be preserved in a MOVE + even when the HTTP Last-Modified value SHOULD change. Note that + since [RFC2616] requires clients to use ETags where provided, a + server implementing ETags can count on clients using a much better + mechanism than modification dates for offline synchronization or + cache control. Also note the considerations in Section 8.8. + + Description: The last-modified date on a resource SHOULD only + reflect changes in the body (the GET responses) of the resource. + A change in a property only SHOULD NOT cause the last-modified + date to change, because clients MAY rely on the last-modified date + to know when to overwrite the existing body. The DAV: + getlastmodified property MUST be defined on any DAV-compliant + resource that returns the Last-Modified header in response to a + GET. + + + +15.8. lockdiscovery Property + + Name: lockdiscovery + + Purpose: Describes the active locks on a resource + + Protected: MUST be protected. Clients change the list of locks + through LOCK and UNLOCK, not through PROPPATCH. + + COPY/MOVE behavior: The value of this property depends on the lock + state of the destination, not on the locks of the source resource. + Recall that locks are not moved in a MOVE operation. + + Description: Returns a listing of who has a lock, what type of lock + he has, the timeout type and the time remaining on the timeout, + and the associated lock token. Owner information MAY be omitted + if it is considered sensitive. If there are no locks, but the + server supports locks, the property will be present but contain + zero 'activelock' elements. If there are one or more locks, an + 'activelock' element appears for each lock on the resource. This + property is NOT lockable with respect to write locks (Section 7). + + + + + + + + +Dusseault Standards Track [Page 94] + +RFC 4918 WebDAV June 2007 + + +15.8.1. Example - Retrieving DAV:lockdiscovery + + >>Request + + PROPFIND /container/ HTTP/1.1 + Host: www.example.com + Content-Length: xxxx + Content-Type: application/xml; charset="utf-8" + + + + + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/container/ + + + + + + + 0 + Jane Smith + Infinite + + urn:uuid:f81de2ad-7f3d-a1b2-4f3c-00a0c91a9d76 + + + http://www.example.com/container/ + + + + + HTTP/1.1 200 OK + + + + + + + +Dusseault Standards Track [Page 95] + +RFC 4918 WebDAV June 2007 + + + This resource has a single exclusive write lock on it, with an + infinite timeout. + +15.9. resourcetype Property + + Name: resourcetype + + Purpose: Specifies the nature of the resource. + + Protected: SHOULD be protected. Resource type is generally decided + through the operation creating the resource (MKCOL vs PUT), not by + PROPPATCH. + + COPY/MOVE behavior: Generally a COPY/MOVE of a resource results in + the same type of resource at the destination. + + Description: MUST be defined on all DAV-compliant resources. Each + child element identifies a specific type the resource belongs to, + such as 'collection', which is the only resource type defined by + this specification (see Section 14.3). If the element contains + the 'collection' child element plus additional unrecognized + elements, it should generally be treated as a collection. If the + element contains no recognized child elements, it should be + treated as a non-collection resource. The default value is empty. + This element MUST NOT contain text or mixed content. Any custom + child element is considered to be an identifier for a resource + type. + + Example: (fictional example to show extensibility) + + + + + + +15.10. supportedlock Property + + Name: supportedlock + + Purpose: To provide a listing of the lock capabilities supported by + the resource. + + Protected: MUST be protected. Servers, not clients, determine what + lock mechanisms are supported. + + + + + + + +Dusseault Standards Track [Page 96] + +RFC 4918 WebDAV June 2007 + + + COPY/MOVE behavior: This property value is dependent on the kind of + locks supported at the destination, not on the value of the + property at the source resource. Servers attempting to COPY to a + destination should not attempt to set this property at the + destination. + + Description: Returns a listing of the combinations of scope and + access types that may be specified in a lock request on the + resource. Note that the actual contents are themselves controlled + by access controls, so a server is not required to provide + information the client is not authorized to see. This property is + NOT lockable with respect to write locks (Section 7). + + + +15.10.1. Example - Retrieving DAV:supportedlock + + >>Request + + PROPFIND /container/ HTTP/1.1 + Host: www.example.com + Content-Length: xxxx + Content-Type: application/xml; charset="utf-8" + + + + + + + >>Response + + HTTP/1.1 207 Multi-Status + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + http://www.example.com/container/ + + + + + + + + + + + + +Dusseault Standards Track [Page 97] + +RFC 4918 WebDAV June 2007 + + + + + + + HTTP/1.1 200 OK + + + + +16. Precondition/Postcondition XML Elements + + As introduced in Section 8.7, extra information on error conditions + can be included in the body of many status responses. This section + makes requirements on the use of the error body mechanism and + introduces a number of precondition and postcondition codes. + + A "precondition" of a method describes the state of the server that + must be true for that method to be performed. A "postcondition" of a + method describes the state of the server that must be true after that + method has been completed. + + Each precondition and postcondition has a unique XML element + associated with it. In a 207 Multi-Status response, the XML element + MUST appear inside an 'error' element in the appropriate 'propstat or + 'response' element depending on whether the condition applies to one + or more properties or to the resource as a whole. In all other error + responses where this specification's 'error' body is used, the + precondition/postcondition XML element MUST be returned as the child + of a top-level 'error' element in the response body, unless otherwise + negotiated by the request, along with an appropriate response status. + The most common response status codes are 403 (Forbidden) if the + request should not be repeated because it will always fail, and 409 + (Conflict) if it is expected that the user might be able to resolve + the conflict and resubmit the request. The 'error' element MAY + contain child elements with specific error information and MAY be + extended with any custom child elements. + + This mechanism does not take the place of using a correct numeric + status code as defined here or in HTTP, because the client must + always be able to take a reasonable course of action based only on + the numeric code. However, it does remove the need to define new + numeric codes. The new machine-readable codes used for this purpose + are XML elements classified as preconditions and postconditions, so + naturally, any group defining a new condition code can use their own + namespace. As always, the "DAV:" namespace is reserved for use by + IETF-chartered WebDAV working groups. + + + + + +Dusseault Standards Track [Page 98] + +RFC 4918 WebDAV June 2007 + + + A server supporting this specification SHOULD use the XML error + whenever a precondition or postcondition defined in this document is + violated. For error conditions not specified in this document, the + server MAY simply choose an appropriate numeric status and leave the + response body blank. However, a server MAY instead use a custom + condition code and other supporting text, because even when clients + do not automatically recognize condition codes, they can be quite + useful in interoperability testing and debugging. + + Example - Response with precondition code + + >>Response + + HTTP/1.1 423 Locked + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + /workspace/webdav/ + + + + In this example, a client unaware of a depth-infinity lock on the + parent collection "/workspace/webdav/" attempted to modify the + collection member "/workspace/webdav/proposal.doc". + + Some other useful preconditions and postconditions have been defined + in other specifications extending WebDAV, such as [RFC3744] (see + particularly Section 7.1.1), [RFC3253], and [RFC3648]. + + All these elements are in the "DAV:" namespace. If not specified + otherwise, the content for each condition's XML element is defined to + be empty. + + + Name: lock-token-matches-request-uri + + Use with: 409 Conflict + + Purpose: (precondition) -- A request may include a Lock-Token header + to identify a lock for the UNLOCK method. However, if the + Request-URI does not fall within the scope of the lock identified + by the token, the server SHOULD use this error. The lock may have + a scope that does not include the Request-URI, or the lock could + have disappeared, or the token may be invalid. + + + + +Dusseault Standards Track [Page 99] + +RFC 4918 WebDAV June 2007 + + + Name: lock-token-submitted (precondition) + + Use with: 423 Locked + + Purpose: The request could not succeed because a lock token should + have been submitted. This element, if present, MUST contain at + least one URL of a locked resource that prevented the request. In + cases of MOVE, COPY, and DELETE where collection locks are + involved, it can be difficult for the client to find out which + locked resource made the request fail -- but the server is only + responsible for returning one such locked resource. The server + MAY return every locked resource that prevented the request from + succeeding if it knows them all. + + + + + Name: no-conflicting-lock (precondition) + + Use with: Typically 423 Locked + + Purpose: A LOCK request failed due the presence of an already + existing conflicting lock. Note that a lock can be in conflict + although the resource to which the request was directed is only + indirectly locked. In this case, the precondition code can be + used to inform the client about the resource that is the root of + the conflicting lock, avoiding a separate lookup of the + "lockdiscovery" property. + + + + + Name: no-external-entities + + Use with: 403 Forbidden + + Purpose: (precondition) -- If the server rejects a client request + because the request body contains an external entity, the server + SHOULD use this error. + + + Name: preserved-live-properties + + Use with: 409 Conflict + + Purpose: (postcondition) -- The server received an otherwise-valid + MOVE or COPY request, but cannot maintain the live properties with + the same behavior at the destination. It may be that the server + + + +Dusseault Standards Track [Page 100] + +RFC 4918 WebDAV June 2007 + + + only supports some live properties in some parts of the + repository, or simply has an internal error. + + + Name: propfind-finite-depth + + Use with: 403 Forbidden + + Purpose: (precondition) -- This server does not allow infinite-depth + PROPFIND requests on collections. + + + Name: cannot-modify-protected-property + + Use with: 403 Forbidden + + Purpose: (precondition) -- The client attempted to set a protected + property in a PROPPATCH (such as DAV:getetag). See also + [RFC3253], Section 3.12. + +17. XML Extensibility in DAV + + The XML namespace extension ([REC-XML-NAMES]) is used in this + specification in order to allow for new XML elements to be added + without fear of colliding with other element names. Although WebDAV + request and response bodies can be extended by arbitrary XML + elements, which can be ignored by the message recipient, an XML + element in the "DAV:" namespace SHOULD NOT be used in the request or + response body unless that XML element is explicitly defined in an + IETF RFC reviewed by a WebDAV working group. + + For WebDAV to be both extensible and backwards-compatible, both + clients and servers need to know how to behave when unexpected or + unrecognized command extensions are received. For XML processing, + this means that clients and servers MUST process received XML + documents as if unexpected elements and attributes (and all children + of unrecognized elements) were not there. An unexpected element or + attribute includes one that may be used in another context but is not + expected here. Ignoring such items for purposes of processing can of + course be consistent with logging all information or presenting for + debugging. + + This restriction also applies to the processing, by clients, of DAV + property values where unexpected XML elements SHOULD be ignored + unless the property's schema declares otherwise. + + This restriction does not apply to setting dead DAV properties on the + server where the server MUST record all XML elements. + + + +Dusseault Standards Track [Page 101] + +RFC 4918 WebDAV June 2007 + + + Additionally, this restriction does not apply to the use of XML where + XML happens to be the content type of the entity body, for example, + when used as the body of a PUT. + + Processing instructions in XML SHOULD be ignored by recipients. + Thus, specifications extending WebDAV SHOULD NOT use processing + instructions to define normative behavior. + + XML DTD fragments are included for all the XML elements defined in + this specification. However, correct XML will not be valid according + to any DTD due to namespace usage and extension rules. In + particular: + + o Elements (from this specification) are in the "DAV:" namespace, + + o Element ordering is irrelevant unless otherwise stated, + + o Extension attributes MAY be added, + + o For element type definitions of "ANY", the normative text + definition for that element defines what can be in it and what + that means. + + o For element type definitions of "#PCDATA", extension elements MUST + NOT be added. + + o For other element type definitions, including "EMPTY", extension + elements MAY be added. + + Note that this means that elements containing elements cannot be + extended to contain text, and vice versa. + + With DTD validation relaxed by the rules above, the constraints + described by the DTD fragments are normative (see for example + Appendix A). A recipient of a WebDAV message with an XML body MUST + NOT validate the XML document according to any hard-coded or + dynamically-declared DTD. + + Note that this section describes backwards-compatible extensibility + rules. There might also be times when an extension is designed not + to be backwards-compatible, for example, defining an extension that + reuses an XML element defined in this document but omitting one of + the child elements required by the DTDs in this specification. + + + + + + + + +Dusseault Standards Track [Page 102] + +RFC 4918 WebDAV June 2007 + + +18. DAV Compliance Classes + + A DAV-compliant resource can advertise several classes of compliance. + A client can discover the compliance classes of a resource by + executing OPTIONS on the resource and examining the "DAV" header + which is returned. Note particularly that resources, rather than + servers, are spoken of as being compliant. That is because + theoretically some resources on a server could support different + feature sets. For example, a server could have a sub-repository + where an advanced feature like versioning was supported, even if that + feature was not supported on all sub-repositories. + + Since this document describes extensions to the HTTP/1.1 protocol, + minimally all DAV-compliant resources, clients, and proxies MUST be + compliant with [RFC2616]. + + A resource that is class 2 or class 3 compliant must also be class 1 + compliant. + +18.1. Class 1 + + A class 1 compliant resource MUST meet all "MUST" requirements in all + sections of this document. + + Class 1 compliant resources MUST return, at minimum, the value "1" in + the DAV header on all responses to the OPTIONS method. + +18.2. Class 2 + + A class 2 compliant resource MUST meet all class 1 requirements and + support the LOCK method, the DAV:supportedlock property, the DAV: + lockdiscovery property, the Time-Out response header and the Lock- + Token request header. A class 2 compliant resource SHOULD also + support the Timeout request header and the 'owner' XML element. + + Class 2 compliant resources MUST return, at minimum, the values "1" + and "2" in the DAV header on all responses to the OPTIONS method. + +18.3. Class 3 + + A resource can explicitly advertise its support for the revisions to + [RFC2518] made in this document. Class 1 MUST be supported as well. + Class 2 MAY be supported. Advertising class 3 support in addition to + class 1 and 2 means that the server supports all the requirements in + this specification. Advertising class 3 and class 1 support, but not + class 2, means that the server supports all the requirements in this + specification except possibly those that involve locking support. + + + + +Dusseault Standards Track [Page 103] + +RFC 4918 WebDAV June 2007 + + + Example: + + DAV: 1, 3 + +19. Internationalization Considerations + + In the realm of internationalization, this specification complies + with the IETF Character Set Policy [RFC2277]. In this specification, + human-readable fields can be found either in the value of a property, + or in an error message returned in a response entity body. In both + cases, the human-readable content is encoded using XML, which has + explicit provisions for character set tagging and encoding, and + requires that XML processors read XML elements encoded, at minimum, + using the UTF-8 [RFC3629] and UTF-16 [RFC2781] encodings of the ISO + 10646 multilingual plane. XML examples in this specification + demonstrate use of the charset parameter of the Content-Type header + (defined in [RFC3023]), as well as XML charset declarations. + + XML also provides a language tagging capability for specifying the + language of the contents of a particular XML element. The "xml:lang" + attribute appears on an XML element to identify the language of its + content and attributes. See [REC-XML] for definitions of values and + scoping. + + WebDAV applications MUST support the character set tagging, character + set encoding, and the language tagging functionality of the XML + specification. Implementors of WebDAV applications are strongly + encouraged to read "XML Media Types" [RFC3023] for instruction on + which MIME media type to use for XML transport, and on use of the + charset parameter of the Content-Type header. + + Names used within this specification fall into four categories: names + of protocol elements such as methods and headers, names of XML + elements, names of properties, and names of conditions. Naming of + protocol elements follows the precedent of HTTP, using English names + encoded in US-ASCII for methods and headers. Since these protocol + elements are not visible to users, and are simply long token + identifiers, they do not need to support multiple languages. + Similarly, the names of XML elements used in this specification are + not visible to the user and hence do not need to support multiple + languages. + + WebDAV property names are qualified XML names (pairs of XML namespace + name and local name). Although some applications (e.g., a generic + property viewer) will display property names directly to their users, + it is expected that the typical application will use a fixed set of + properties, and will provide a mapping from the property name and + namespace to a human-readable field when displaying the property name + + + +Dusseault Standards Track [Page 104] + +RFC 4918 WebDAV June 2007 + + + to a user. It is only in the case where the set of properties is not + known ahead of time that an application need display a property name + to a user. We recommend that applications provide human-readable + property names wherever feasible. + + For error reporting, we follow the convention of HTTP/1.1 status + codes, including with each status code a short, English description + of the code (e.g., 423 (Locked)). While the possibility exists that + a poorly crafted user agent would display this message to a user, + internationalized applications will ignore this message, and display + an appropriate message in the user's language and character set. + + Since interoperation of clients and servers does not require locale + information, this specification does not specify any mechanism for + transmission of this information. + +20. Security Considerations + + This section is provided to detail issues concerning security + implications of which WebDAV applications need to be aware. + + All of the security considerations of HTTP/1.1 (discussed in + [RFC2616]) and XML (discussed in [RFC3023]) also apply to WebDAV. In + addition, the security risks inherent in remote authoring require + stronger authentication technology, introduce several new privacy + concerns, and may increase the hazards from poor server design. + These issues are detailed below. + +20.1. Authentication of Clients + + Due to their emphasis on authoring, WebDAV servers need to use + authentication technology to protect not just access to a network + resource, but the integrity of the resource as well. Furthermore, + the introduction of locking functionality requires support for + authentication. + + A password sent in the clear over an insecure channel is an + inadequate means for protecting the accessibility and integrity of a + resource as the password may be intercepted. Since Basic + authentication for HTTP/1.1 performs essentially clear text + transmission of a password, Basic authentication MUST NOT be used to + authenticate a WebDAV client to a server unless the connection is + secure. Furthermore, a WebDAV server MUST NOT send a Basic + authentication challenge in a WWW-Authenticate header unless the + connection is secure. An example of a secure connection would be a + Transport Layer Security (TLS) connection employing a strong cipher + suite and server authentication. + + + + +Dusseault Standards Track [Page 105] + +RFC 4918 WebDAV June 2007 + + + WebDAV applications MUST support the Digest authentication scheme + [RFC2617]. Since Digest authentication verifies that both parties to + a communication know a shared secret, a password, without having to + send that secret in the clear, Digest authentication avoids the + security problems inherent in Basic authentication while providing a + level of authentication that is useful in a wide range of scenarios. + +20.2. Denial of Service + + Denial-of-service attacks are of special concern to WebDAV servers. + WebDAV plus HTTP enables denial-of-service attacks on every part of a + system's resources. + + o The underlying storage can be attacked by PUTting extremely large + files. + + o Asking for recursive operations on large collections can attack + processing time. + + o Making multiple pipelined requests on multiple connections can + attack network connections. + + WebDAV servers need to be aware of the possibility of a denial-of- + service attack at all levels. The proper response to such an attack + MAY be to simply drop the connection. Or, if the server is able to + make a response, the server MAY use a 400-level status request such + as 400 (Bad Request) and indicate why the request was refused (a 500- + level status response would indicate that the problem is with the + server, whereas unintentional DoS attacks are something the client is + capable of remedying). + +20.3. Security through Obscurity + + WebDAV provides, through the PROPFIND method, a mechanism for listing + the member resources of a collection. This greatly diminishes the + effectiveness of security or privacy techniques that rely only on the + difficulty of discovering the names of network resources. Users of + WebDAV servers are encouraged to use access control techniques to + prevent unwanted access to resources, rather than depending on the + relative obscurity of their resource names. + +20.4. Privacy Issues Connected to Locks + + When submitting a lock request, a user agent may also submit an + 'owner' XML field giving contact information for the person taking + out the lock (for those cases where a person, rather than a robot, is + taking out the lock). This contact information is stored in a DAV: + lockdiscovery property on the resource, and can be used by other + + + +Dusseault Standards Track [Page 106] + +RFC 4918 WebDAV June 2007 + + + collaborators to begin negotiation over access to the resource. + However, in many cases, this contact information can be very private, + and should not be widely disseminated. Servers SHOULD limit read + access to the DAV:lockdiscovery property as appropriate. + Furthermore, user agents SHOULD provide control over whether contact + information is sent at all, and if contact information is sent, + control over exactly what information is sent. + +20.5. Privacy Issues Connected to Properties + + Since property values are typically used to hold information such as + the author of a document, there is the possibility that privacy + concerns could arise stemming from widespread access to a resource's + property data. To reduce the risk of inadvertent release of private + information via properties, servers are encouraged to develop access + control mechanisms that separate read access to the resource body and + read access to the resource's properties. This allows a user to + control the dissemination of their property data without overly + restricting access to the resource's contents. + +20.6. Implications of XML Entities + + XML supports a facility known as "external entities", defined in + Section 4.2.2 of [REC-XML], which instructs an XML processor to + retrieve and include additional XML. An external XML entity can be + used to append or modify the document type declaration (DTD) + associated with an XML document. An external XML entity can also be + used to include XML within the content of an XML document. For non- + validating XML, such as the XML used in this specification, including + an external XML entity is not required by XML. However, XML does + state that an XML processor may, at its discretion, include the + external XML entity. + + External XML entities have no inherent trustworthiness and are + subject to all the attacks that are endemic to any HTTP GET request. + Furthermore, it is possible for an external XML entity to modify the + DTD, and hence affect the final form of an XML document, in the worst + case, significantly modifying its semantics or exposing the XML + processor to the security risks discussed in [RFC3023]. Therefore, + implementers must be aware that external XML entities should be + treated as untrustworthy. If a server chooses not to handle external + XML entities, it SHOULD respond to requests containing external + entities with the 'no-external-entities' condition code. + + There is also the scalability risk that would accompany a widely + deployed application that made use of external XML entities. In this + situation, it is possible that there would be significant numbers of + requests for one external XML entity, potentially overloading any + + + +Dusseault Standards Track [Page 107] + +RFC 4918 WebDAV June 2007 + + + server that fields requests for the resource containing the external + XML entity. + + Furthermore, there's also a risk based on the evaluation of "internal + entities" as defined in Section 4.2.2 of [REC-XML]. A small, + carefully crafted request using nested internal entities may require + enormous amounts of memory and/or processing time to process. Server + implementers should be aware of this risk and configure their XML + parsers so that requests like these can be detected and rejected as + early as possible. + +20.7. Risks Connected with Lock Tokens + + This specification encourages the use of "A Universally Unique + Identifier (UUID) URN Namespace" ([RFC4122]) for lock tokens + (Section 6.5), in order to guarantee their uniqueness across space + and time. Version 1 UUIDs (defined in Section 4) MAY contain a + "node" field that "consists of an IEEE 802 MAC address, usually the + host address. For systems with multiple IEEE addresses, any + available one can be used". Since a WebDAV server will issue many + locks over its lifetime, the implication is that it may also be + publicly exposing its IEEE 802 address. + + There are several risks associated with exposure of IEEE 802 + addresses. Using the IEEE 802 address: + + o It is possible to track the movement of hardware from subnet to + subnet. + + o It may be possible to identify the manufacturer of the hardware + running a WebDAV server. + + o It may be possible to determine the number of each type of + computer running WebDAV. + + This risk only applies to host-address-based UUID versions. Section + 4 of [RFC4122] describes several other mechanisms for generating + UUIDs that do not involve the host address and therefore do not + suffer from this risk. + +20.8. Hosting Malicious Content + + HTTP has the ability to host programs that are executed on client + machines. These programs can take many forms including Web scripts, + executables, plug-in modules, and macros in documents. WebDAV does + not change any of the security concerns around these programs, yet + often WebDAV is used in contexts where a wide range of users can + publish documents on a server. The server might not have a close + + + +Dusseault Standards Track [Page 108] + +RFC 4918 WebDAV June 2007 + + + trust relationship with the author that is publishing the document. + Servers that allow clients to publish arbitrary content can usefully + implement precautions to check that content published to the server + is not harmful to other clients. Servers could do this by techniques + such as restricting the types of content that is allowed to be + published and running virus and malware detection software on + published content. Servers can also mitigate the risk by having + appropriate access restriction and authentication of users that are + allowed to publish content to the server. + +21. IANA Considerations + +21.1. New URI Schemes + + This specification defines two URI schemes: + + 1. the "opaquelocktoken" scheme defined in Appendix C, and + + 2. the "DAV" URI scheme, which historically was used in [RFC2518] to + disambiguate WebDAV property and XML element names and which + continues to be used for that purpose in this specification and + others extending WebDAV. Creation of identifiers in the "DAV:" + namespace is controlled by the IETF. + + Note that defining new URI schemes for XML namespaces is now + discouraged. "DAV:" was defined before standard best practices + emerged. + +21.2. XML Namespaces + + XML namespaces disambiguate WebDAV property names and XML elements. + Any WebDAV user or application can define a new namespace in order to + create custom properties or extend WebDAV XML syntax. IANA does not + need to manage such namespaces, property names, or element names. + +21.3. Message Header Fields + + The message header fields below should be added to the permanent + registry (see [RFC3864]). + +21.3.1. DAV + + Header field name: DAV + + Applicable protocol: http + + Status: standard + + + + +Dusseault Standards Track [Page 109] + +RFC 4918 WebDAV June 2007 + + + Author/Change controller: IETF + + Specification document: this specification (Section 10.1) + +21.3.2. Depth + + Header field name: Depth + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document: this specification (Section 10.2) + +21.3.3. Destination + + Header field name: Destination + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document: this specification (Section 10.3) + +21.3.4. If + + Header field name: If + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document: this specification (Section 10.4) + +21.3.5. Lock-Token + + Header field name: Lock-Token + + Applicable protocol: http + + Status: standard + + + + +Dusseault Standards Track [Page 110] + +RFC 4918 WebDAV June 2007 + + + Author/Change controller: IETF + + Specification document: this specification (Section 10.5) + +21.3.6. Overwrite + + Header field name: Overwrite + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document: this specification (Section 10.6) + +21.3.7. Timeout + + Header field name: Timeout + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document: this specification (Section 10.7) + +21.4. HTTP Status Codes + + This specification defines the HTTP status codes + + o 207 Multi-Status (Section 11.1) + + o 422 Unprocessable Entity (Section 11.2), + + o 423 Locked (Section 11.3), + + o 424 Failed Dependency (Section 11.4) and + + o 507 Insufficient Storage (Section 11.5), + + to be updated in the registry at + . + + Note: the HTTP status code 102 (Processing) has been removed in this + specification; its IANA registration should continue to reference RFC + 2518. + + + +Dusseault Standards Track [Page 111] + +RFC 4918 WebDAV June 2007 + + +22. Acknowledgements + + A specification such as this thrives on piercing critical review and + withers from apathetic neglect. The authors gratefully acknowledge + the contributions of the following people, whose insights were so + valuable at every stage of our work. + + Contributors to RFC 2518 + + Terry Allen, Harald Alvestrand, Jim Amsden, Becky Anderson, Alan + Babich, Sanford Barr, Dylan Barrell, Bernard Chester, Tim Berners- + Lee, Dan Connolly, Jim Cunningham, Ron Daniel, Jr., Jim Davis, Keith + Dawson, Mark Day, Brian Deen, Martin Duerst, David Durand, Lee + Farrell, Chuck Fay, Wesley Felter, Roy Fielding, Mark Fisher, Alan + Freier, George Florentine, Jim Gettys, Phill Hallam-Baker, Dennis + Hamilton, Steve Henning, Mead Himelstein, Alex Hopmann, Andre van der + Hoek, Ben Laurie, Paul Leach, Ora Lassila, Karen MacArthur, Steven + Martin, Larry Masinter, Michael Mealling, Keith Moore, Thomas Narten, + Henrik Nielsen, Kenji Ota, Bob Parker, Glenn Peterson, Jon Radoff, + Saveen Reddy, Henry Sanders, Christopher Seiwald, Judith Slein, Mike + Spreitzer, Einar Stefferud, Greg Stein, Ralph Swick, Kenji Takahashi, + Richard N. Taylor, Robert Thau, John Turner, Sankar Virdhagriswaran, + Fabio Vitali, Gregory Woodhouse, and Lauren Wood. + + Two from this list deserve special mention. The contributions by + Larry Masinter have been invaluable; he both helped the formation of + the working group and patiently coached the authors along the way. + In so many ways he has set high standards that we have toiled to + meet. The contributions of Judith Slein were also invaluable; by + clarifying the requirements and in patiently reviewing version after + version, she both improved this specification and expanded our minds + on document management. + + We would also like to thank John Turner for developing the XML DTD. + + The authors of RFC 2518 were Yaron Goland, Jim Whitehead, A. Faizi, + Steve Carter, and D. Jensen. Although their names had to be removed + due to IETF author count restrictions, they can take credit for the + majority of the design of WebDAV. + + Additional Acknowledgements for This Specification + + Significant contributors of text for this specification are listed as + contributors in the section below. We must also gratefully + acknowledge Geoff Clemm, Joel Soderberg, and Dan Brotsky for hashing + out specific text on the list or in meetings. Joe Hildebrand and + Cullen Jennings helped close many issues. Barry Lind described an + additional security consideration and Cullen Jennings provided text + + + +Dusseault Standards Track [Page 112] + +RFC 4918 WebDAV June 2007 + + + for that consideration. Jason Crawford tracked issue status for this + document for a period of years, followed by Elias Sinderson. + +23. Contributors to This Specification + + Julian Reschke + bytes GmbH + Hafenweg 16, 48155 Muenster, Germany + EMail: julian.reschke@greenbytes.de + + + Elias Sinderson + University of California, Santa Cruz + 1156 High Street, Santa Cruz, CA 95064 + EMail: elias@cse.ucsc.edu + + + Jim Whitehead + University of California, Santa Cruz + 1156 High Street, Santa Cruz, CA 95064 + EMail: ejw@soe.ucsc.edu + +24. Authors of RFC 2518 + + Y. Y. Goland + Microsoft Corporation + One Microsoft Way + Redmond, WA 98052-6399 + EMail: yarong@microsoft.com + + + E. J. Whitehead, Jr. + Dept. Of Information and Computer Science + University of California, Irvine + Irvine, CA 92697-3425 + EMail: ejw@ics.uci.edu + + + A. Faizi + Netscape + 685 East Middlefield Road + Mountain View, CA 94043 + EMail: asad@netscape.com + + + + + + + + +Dusseault Standards Track [Page 113] + +RFC 4918 WebDAV June 2007 + + + S. R. Carter + Novell + 1555 N. Technology Way + M/S ORM F111 + Orem, UT 84097-2399 + EMail: srcarter@novell.com + + + D. Jensen + Novell + 1555 N. Technology Way + M/S ORM F111 + Orem, UT 84097-2399 + EMail: dcjensen@novell.com + +25. References + +25.1. Normative References + + [REC-XML] Bray, T., Paoli, J., Sperberg-McQueen, C., Maler, + E., and F. Yergeau, "Extensible Markup Language + (XML) 1.0 (Fourth Edition)", W3C REC-xml-20060816, + August 2006, + . + + [REC-XML-INFOSET] Cowan, J. and R. Tobin, "XML Information Set + (Second Edition)", W3C REC-xml-infoset-20040204, + February 2004, . + + [REC-XML-NAMES] Bray, T., Hollander, D., Layman, A., and R. Tobin, + "Namespaces in XML 1.0 (Second Edition)", W3C REC- + xml-names-20060816, August 2006, . + + [RFC2119] Bradner, S., "Key words for use in RFCs to + Indicate Requirement Levels", BCP 14, RFC 2119, + March 1997. + + [RFC2277] Alvestrand, H., "IETF Policy on Character Sets and + Languages", BCP 18, RFC 2277, January 1998. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., + Masinter, L., Leach, P., and T. Berners-Lee, + "Hypertext Transfer Protocol -- HTTP/1.1", + RFC 2616, June 1999. + + + + + +Dusseault Standards Track [Page 114] + +RFC 4918 WebDAV June 2007 + + + [RFC2617] Franks, J., Hallam-Baker, P., Hostetler, J., + Lawrence, S., Leach, P., Luotonen, A., and L. + Stewart, "HTTP Authentication: Basic and Digest + Access Authentication", RFC 2617, June 1999. + + [RFC3339] Klyne, G., Ed. and C. Newman, "Date and Time on + the Internet: Timestamps", RFC 3339, July 2002. + + [RFC3629] Yergeau, F., "UTF-8, a transformation format of + ISO 10646", STD 63, RFC 3629, November 2003. + + [RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, + "Uniform Resource Identifier (URI): Generic + Syntax", STD 66, RFC 3986, January 2005. + + [RFC4122] Leach, P., Mealling, M., and R. Salz, "A + Universally Unique IDentifier (UUID) URN + Namespace", RFC 4122, July 2005. + +25.2. Informative References + + [RFC2291] Slein, J., Vitali, F., Whitehead, E., and D. + Durand, "Requirements for a Distributed Authoring + and Versioning Protocol for the World Wide Web", + RFC 2291, February 1998. + + [RFC2518] Goland, Y., Whitehead, E., Faizi, A., Carter, S., + and D. Jensen, "HTTP Extensions for Distributed + Authoring -- WEBDAV", RFC 2518, February 1999. + + [RFC2781] Hoffman, P. and F. Yergeau, "UTF-16, an encoding + of ISO 10646", RFC 2781, February 2000. + + [RFC3023] Murata, M., St. Laurent, S., and D. Kohn, "XML + Media Types", RFC 3023, January 2001. + + [RFC3253] Clemm, G., Amsden, J., Ellison, T., Kaler, C., and + J. Whitehead, "Versioning Extensions to WebDAV + (Web Distributed Authoring and Versioning)", + RFC 3253, March 2002. + + [RFC3648] Whitehead, J. and J. Reschke, Ed., "Web + Distributed Authoring and Versioning (WebDAV) + Ordered Collections Protocol", RFC 3648, + December 2003. + + + + + + +Dusseault Standards Track [Page 115] + +RFC 4918 WebDAV June 2007 + + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. + Whitehead, "Web Distributed Authoring and + Versioning (WebDAV) Access Control Protocol", + RFC 3744, May 2004. + + [RFC3864] Klyne, G., Nottingham, M., and J. Mogul, + "Registration Procedures for Message Header + Fields", BCP 90, RFC 3864, September 2004. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Dusseault Standards Track [Page 116] + +RFC 4918 WebDAV June 2007 + + +Appendix A. Notes on Processing XML Elements + +A.1. Notes on Empty XML Elements + + XML supports two mechanisms for indicating that an XML element does + not have any content. The first is to declare an XML element of the + form . The second is to declare an XML element of the form + . The two XML elements are semantically identical. + +A.2. Notes on Illegal XML Processing + + XML is a flexible data format that makes it easy to submit data that + appears legal but in fact is not. The philosophy of "Be flexible in + what you accept and strict in what you send" still applies, but it + must not be applied inappropriately. XML is extremely flexible in + dealing with issues of whitespace, element ordering, inserting new + elements, etc. This flexibility does not require extension, + especially not in the area of the meaning of elements. + + There is no kindness in accepting illegal combinations of XML + elements. At best, it will cause an unwanted result and at worst it + can cause real damage. + +A.3. Example - XML Syntax Error + + The following request body for a PROPFIND method is illegal. + + + + + + + + The definition of the propfind element only allows for the allprop or + the propname element, not both. Thus, the above is an error and must + be responded to with a 400 (Bad Request). + + Imagine, however, that a server wanted to be "kind" and decided to + pick the allprop element as the true element and respond to it. A + client running over a bandwidth limited line who intended to execute + a propname would be in for a big surprise if the server treated the + command as an allprop. + + Additionally, if a server were lenient and decided to reply to this + request, the results would vary randomly from server to server, with + some servers executing the allprop directive, and others executing + the propname directive. This reduces interoperability rather than + increasing it. + + + +Dusseault Standards Track [Page 117] + +RFC 4918 WebDAV June 2007 + + +A.4. Example - Unexpected XML Element + + The previous example was illegal because it contained two elements + that were explicitly banned from appearing together in the propfind + element. However, XML is an extensible language, so one can imagine + new elements being defined for use with propfind. Below is the + request body of a PROPFIND and, like the previous example, must be + rejected with a 400 (Bad Request) by a server that does not + understand the expired-props element. + + + + + + + To understand why a 400 (Bad Request) is returned, let us look at the + request body as the server unfamiliar with expired-props sees it. + + + + + + As the server does not understand the 'expired-props' element, + according to the WebDAV-specific XML processing rules specified in + Section 17, it must process the request as if the element were not + there. Thus, the server sees an empty propfind, which by the + definition of the propfind element is illegal. + + Please note that had the extension been additive, it would not + necessarily have resulted in a 400 (Bad Request). For example, + imagine the following request body for a PROPFIND: + + + + + + *boss* + + + The previous example contains the fictitious element leave-out. Its + purpose is to prevent the return of any property whose name matches + the submitted pattern. If the previous example were submitted to a + server unfamiliar with 'leave-out', the only result would be that the + 'leave-out' element would be ignored and a propname would be + executed. + + + +Dusseault Standards Track [Page 118] + +RFC 4918 WebDAV June 2007 + + +Appendix B. Notes on HTTP Client Compatibility + + WebDAV was designed to be, and has been found to be, backward- + compatible with HTTP 1.1. The PUT and DELETE methods are defined in + HTTP and thus may be used by HTTP clients as well as WebDAV-aware + clients, but the responses to PUT and DELETE have been extended in + this specification in ways that only a WebDAV client would be + entirely prepared for. Some theoretical concerns were raised about + whether those responses would cause interoperability problems with + HTTP-only clients, and this section addresses those concerns. + + Since any HTTP client ought to handle unrecognized 400-level and 500- + level status codes as errors, the following new status codes should + not present any issues: 422, 423, and 507 (424 is also a new status + code but it appears only in the body of a Multistatus response.) So, + for example, if an HTTP client attempted to PUT or DELETE a locked + resource, the 423 Locked response ought to result in a generic error + presented to the user. + + The 207 Multistatus response is interesting because an HTTP client + issuing a DELETE request to a collection might interpret a 207 + response as a success, even though it does not realize the resource + is a collection and cannot understand that the DELETE operation might + have been a complete or partial failure. That interpretation isn't + entirely justified, because a 200-level response indicates that the + server "received, understood, and accepted" the request, not that the + request resulted in complete success. + + One option is that a server could treat a DELETE of a collection as + an atomic operation, and use either 204 No Content in case of + success, or some appropriate error response (400 or 500 level) for an + error. This approach would indeed maximize backward compatibility. + However, since interoperability tests and working group discussions + have not turned up any instances of HTTP clients issuing a DELETE + request against a WebDAV collection, this concern is more theoretical + than practical. Thus, servers are likely to be completely successful + at interoperating with HTTP clients even if they treat any collection + DELETE request as a WebDAV request and send a 207 Multi-Status + response. + + In general, server implementations are encouraged to use the detailed + responses and other mechanisms defined in this document rather than + make changes for theoretical interoperability concerns. + + + + + + + + +Dusseault Standards Track [Page 119] + +RFC 4918 WebDAV June 2007 + + +Appendix C. The 'opaquelocktoken' Scheme and URIs + + The 'opaquelocktoken' URI scheme was defined in [RFC2518] (and + registered by IANA) in order to create syntactically correct and + easy-to-generate URIs out of UUIDs, intended to be used as lock + tokens and to be unique across all resources for all time. + + An opaquelocktoken URI is constructed by concatenating the + 'opaquelocktoken' scheme with a UUID, along with an optional + extension. Servers can create new UUIDs for each new lock token. If + a server wishes to reuse UUIDs, the server MUST add an extension, and + the algorithm generating the extension MUST guarantee that the same + extension will never be used twice with the associated UUID. + + OpaqueLockToken-URI = "opaquelocktoken:" UUID [Extension] + ; UUID is defined in Section 3 of [RFC4122]. Note that LWS + ; is not allowed between elements of + ; this production. + + Extension = path + ; path is defined in Section 3.3 of [RFC3986] + + +Appendix D. Lock-null Resources + + The original WebDAV model for locking unmapped URLs created "lock- + null resources". This model was over-complicated and some + interoperability and implementation problems were discovered. The + new WebDAV model for locking unmapped URLs (see Section 7.3) creates + "locked empty resources". Lock-null resources are deprecated. This + section discusses the original model briefly because clients MUST be + able to handle either model. + + In the original "lock-null resource" model, which is no longer + recommended for implementation: + + o A lock-null resource sometimes appeared as "Not Found". The + server responds with a 404 or 405 to any method except for PUT, + MKCOL, OPTIONS, PROPFIND, LOCK, UNLOCK. + + o A lock-null resource does however show up as a member of its + parent collection. + + o The server removes the lock-null resource entirely (its URI + becomes unmapped) if its lock goes away before it is converted to + a regular resource. Recall that locks go away not only when they + expire or are unlocked, but are also removed if a resource is + renamed or moved, or if any parent collection is renamed or moved. + + + +Dusseault Standards Track [Page 120] + +RFC 4918 WebDAV June 2007 + + + o The server converts the lock-null resource into a regular resource + if a PUT request to the URL is successful. + + o The server converts the lock-null resource into a collection if a + MKCOL request to the URL is successful (though interoperability + experience showed that not all servers followed this requirement). + + o Property values were defined for DAV:lockdiscovery and DAV: + supportedlock properties but not necessarily for other properties + like DAV:getcontenttype. + + Clients can easily interoperate both with servers that support the + old model "lock-null resources" and the recommended model of "locked + empty resources" by only attempting PUT after a LOCK to an unmapped + URL, not MKCOL or GET. + +D.1. Guidance for Clients Using LOCK to Create Resources + + A WebDAV client implemented to this specification might find servers + that create lock-null resources (implemented before this + specification using [RFC2518]) as well as servers that create locked + empty resources. The response to the LOCK request will not indicate + what kind of resource was created. There are a few techniques that + help the client deal with either type. + + If the client wishes to avoid accidentally creating either lock- + null or empty locked resources, an "If-Match: *" header can be + included with LOCK requests to prevent the server from creating a + new resource. + + If a LOCK request creates a resource and the client subsequently + wants to overwrite that resource using a COPY or MOVE request, the + client should include an "Overwrite: T" header. + + If a LOCK request creates a resource and the client then decides + to get rid of that resource, a DELETE request is supposed to fail + on a lock-null resource and UNLOCK should be used instead. But + with a locked empty resource, UNLOCK doesn't make the resource + disappear. Therefore, the client might have to try both requests + and ignore an error in one of the two requests. + +Appendix E. Guidance for Clients Desiring to Authenticate + + Many WebDAV clients that have already been implemented have account + settings (similar to the way email clients store IMAP account + settings). Thus, the WebDAV client would be able to authenticate + with its first couple requests to the server, provided it had a way + to get the authentication challenge from the server with realm name, + + + +Dusseault Standards Track [Page 121] + +RFC 4918 WebDAV June 2007 + + + nonce, and other challenge information. Note that the results of + some requests might vary according to whether or not the client is + authenticated -- a PROPFIND might return more visible resources if + the client is authenticated, yet not fail if the client is anonymous. + + There are a number of ways the client might be able to trigger the + server to provide an authentication challenge. This appendix + describes a couple approaches that seem particularly likely to work. + + The first approach is to perform a request that ought to require + authentication. However, it's possible that a server might handle + any request even without authentication, so to be entirely safe, the + client could add a conditional header to ensure that even if the + request passes permissions checks, it's not actually handled by the + server. An example of following this approach would be to use a PUT + request with an "If-Match" header with a made-up ETag value. This + approach might fail to result in an authentication challenge if the + server does not test authorization before testing conditionals as is + required (see Section 8.5), or if the server does not need to test + authorization. + + Example - forcing auth challenge with write request + + >>Request + + PUT /forceauth.txt HTTP/1.1 + Host: www.example.com + If-Match: "xxx" + Content-Type: text/plain + Content-Length: 0 + + + The second approach is to use an Authorization header (defined in + [RFC2617]), which is likely to be rejected by the server but which + will then prompt a proper authentication challenge. For example, the + client could start with a PROPFIND request containing an + Authorization header containing a made-up Basic userid:password + string or with actual plausible credentials. This approach relies on + the server responding with a "401 Unauthorized" along with a + challenge if it receives an Authorization header with an unrecognized + username, invalid password, or if it doesn't even handle Basic + authentication. This seems likely to work because of the + requirements of RFC 2617: + + + + + + + + +Dusseault Standards Track [Page 122] + +RFC 4918 WebDAV June 2007 + + + "If the origin server does not wish to accept the credentials sent + with a request, it SHOULD return a 401 (Unauthorized) response. The + response MUST include a WWW-Authenticate header field containing at + least one (possibly new) challenge applicable to the requested + resource." + + There's a slight problem with implementing that recommendation in + some cases, because some servers do not even have challenge + information for certain resources. Thus, when there's no way to + authenticate to a resource or the resource is entirely publicly + available over all accepted methods, the server MAY ignore the + Authorization header, and the client will presumably try again later. + + Example - forcing auth challenge with Authorization header + + >>Request + + PROPFIND /docs/ HTTP/1.1 + Host: www.example.com + Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + Content-type: application/xml; charset="utf-8" + Content-Length: xxxx + + [body omitted] + + +Appendix F. Summary of Changes from RFC 2518 + + This section lists major changes between this document and RFC 2518, + starting with those that are likely to result in implementation + changes. Servers will advertise support for all changes in this + specification by returning the compliance class "3" in the DAV + response header (see Sections 10.1 and 18.3). + +F.1. Changes for Both Client and Server Implementations + + Collections and Namespace Operations + + o The semantics of PROPFIND 'allprop' (Section 9.1) have been + relaxed so that servers return results including, at a minimum, + the live properties defined in this specification, but not + necessarily return other live properties. The 'allprop' directive + therefore means something more like "return all properties that + are supposed to be returned when 'allprop' is requested" -- a set + of properties that may include custom properties and properties + defined in other specifications if those other specifications so + require. Related to this, 'allprop' requests can now be extended + with the 'include' syntax to include specific named properties, + + + +Dusseault Standards Track [Page 123] + +RFC 4918 WebDAV June 2007 + + + thereby avoiding additional requests due to changed 'allprop' + semantics. + + o Servers are now allowed to reject PROPFIND requests with Depth: + Infinity. Clients that used this will need to be able to do a + series of Depth:1 requests instead. + + o Multi-Status response bodies now can transport the value of HTTP's + Location response header in the new 'location' element. Clients + may use this to avoid additional roundtrips to the server when + there is a 'response' element with a 3xx status (see + Section 14.24). + + o The definition of COPY has been relaxed so that it doesn't require + servers to first delete the target resources anymore (this was a + known incompatibility with [RFC3253]). See Section 9.8. + + Headers and Marshalling + + o The Destination and If request headers now allow absolute paths in + addition to full URIs (see Section 8.3). This may be useful for + clients operating through a reverse proxy that does rewrite the + Host request header, but not WebDAV-specific headers. + + o This specification adopts the error marshalling extensions and the + "precondition/postcondition" terminology defined in [RFC3253] (see + Section 16). Related to that, it adds the "error" XML element + inside multistatus response bodies (see Section 14.5, however note + that it uses a format different from the one recommended in RFC + 3253). + + o Senders and recipients are now required to support the UTF-16 + character encoding in XML message bodies (see Section 19). + + o Clients are now required to send the Depth header on PROPFIND + requests, although servers are still encouraged to support clients + that don't. + + Locking + + o RFC 2518's concept of "lock-null resources" (LNRs) has been + replaced by a simplified approach, the "locked empty resources" + (see Section 7.3). There are some aspects of lock-null resources + clients cannot rely on anymore, namely, the ability to use them to + create a locked collection or the fact that they disappear upon + UNLOCK when no PUT or MKCOL request was issued. Note that servers + are still allowed to implement LNRs as per RFC 2518. + + + + +Dusseault Standards Track [Page 124] + +RFC 4918 WebDAV June 2007 + + + o There is no implicit refresh of locks anymore. Locks are only + refreshed upon explicit request (see Section 9.10.2). + + o Clarified that the DAV:owner value supplied in the LOCK request + must be preserved by the server just like a dead property + (Section 14.17). Also added the DAV:lockroot element + (Section 14.12), which allows clients to discover the root of + lock. + +F.2. Changes for Server Implementations + + Collections and Namespace Operations + + o Due to interoperability problems, allowable formats for contents + of 'href' elements in multistatus responses have been limited (see + Section 8.3). + + o Due to lack of implementation, support for the 'propertybehavior' + request body for COPY and MOVE has been removed. Instead, + requirements for property preservation have been clarified (see + Sections 9.8 and 9.9). + + Properties + + o Strengthened server requirements for storage of property values, + in particular persistence of language information (xml:lang), + whitespace, and XML namespace information (see Section 4.3). + + o Clarified requirements on which properties should be writable by + the client; in particular, setting "DAV:displayname" should be + supported by servers (see Section 15). + + o Only 'rfc1123-date' productions are legal as values for DAV: + getlastmodified (see Section 15.7). + + Headers and Marshalling + + o Servers are now required to do authorization checks before + processing conditional headers (see Section 8.5). + + Locking + + o Strengthened requirement to check identity of lock creator when + accessing locked resources (see Section 6.4). Clients should be + aware that lock tokens returned to other principals can only be + used to break a lock, if at all. + + + + + +Dusseault Standards Track [Page 125] + +RFC 4918 WebDAV June 2007 + + + o Section 8.10.4 of [RFC2518] incorrectly required servers to return + a 409 status where a 207 status was really appropriate. This has + been corrected (Section 9.10). + +F.3. Other Changes + + The definition of collection state has been fixed so it doesn't vary + anymore depending on the Request-URI (see Section 5.2). + + The DAV:source property introduced in Section 4.6 of [RFC2518] was + removed due to lack of implementation experience. + + The DAV header now allows non-IETF extensions through URIs in + addition to compliance class tokens. It also can now be used in + requests, although this specification does not define any associated + semantics for the compliance classes defined in here (see + Section 10.1). + + In RFC 2518, the definition of the Depth header (Section 9.2) + required that, by default, request headers would be applied to each + resource in scope. Based on implementation experience, the default + has now been reversed (see Section 10.2). + + The definitions of HTTP status code 102 ([RFC2518], Section 10.1) and + the Status-URI response header (Section 9.7) have been removed due to + lack of implementation. + + The TimeType format used in the Timeout request header and the + "timeout" XML element used to be extensible. Now, only the two + formats defined by this specification are allowed (see Section 10.7). + +Author's Address + + Lisa Dusseault (editor) + CommerceNet + 2064 Edgewood Dr. + Palo Alto, CA 94303 + US + + EMail: ldusseault@commerce.net + + + + + + + + + + + +Dusseault Standards Track [Page 126] + +RFC 4918 WebDAV June 2007 + + +Full Copyright Statement + + Copyright (C) The IETF Trust (2007). + + This document is subject to the rights, licenses and restrictions + contained in BCP 78, and except as set forth therein, the authors + retain all their rights. + + This document and the information contained herein are provided on an + "AS IS" basis and THE CONTRIBUTOR, THE ORGANIZATION HE/SHE REPRESENTS + OR IS SPONSORED BY (IF ANY), THE INTERNET SOCIETY, THE IETF TRUST AND + THE INTERNET ENGINEERING TASK FORCE DISCLAIM ALL WARRANTIES, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF + THE INFORMATION HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED + WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +Intellectual Property + + The IETF takes no position regarding the validity or scope of any + Intellectual Property Rights or other rights that might be claimed to + pertain to the implementation or use of the technology described in + this document or the extent to which any license under such rights + might or might not be available; nor does it represent that it has + made any independent effort to identify any such rights. Information + on the procedures with respect to rights in RFC documents can be + found in BCP 78 and BCP 79. + + Copies of IPR disclosures made to the IETF Secretariat and any + assurances of licenses to be made available, or the result of an + attempt made to obtain a general license or permission for the use of + such proprietary rights by implementers or users of this + specification can be obtained from the IETF on-line IPR repository at + http://www.ietf.org/ipr. + + The IETF invites any interested party to bring to its attention any + copyrights, patents or patent applications, or other proprietary + rights that may cover technology that may be required to implement + this standard. Please address the information to the IETF at + ietf-ipr@ietf.org. + +Acknowledgement + + Funding for the RFC Editor function is currently provided by the + Internet Society. + + + + + + + +Dusseault Standards Track [Page 127] + diff --git a/doc/rfc5397-webdav-current-principal-extension.txt b/doc/rfc5397-webdav-current-principal-extension.txt new file mode 100644 index 0000000..616055e --- /dev/null +++ b/doc/rfc5397-webdav-current-principal-extension.txt @@ -0,0 +1,281 @@ + + + +Network Working Group W. Sanchez +Request for Comments: 5397 C. Daboo +Category: Standards Track Apple Inc. + December 2008 + + + WebDAV Current Principal Extension + +Status of This Memo + + This document specifies an Internet standards track protocol for the + Internet community, and requests discussion and suggestions for + improvements. Please refer to the current edition of the "Internet + Official Protocol Standards" (STD 1) for the standardization state + and status of this protocol. Distribution of this memo is unlimited. + +Copyright Notice + + Copyright (c) 2008 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (http://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. + +Abstract + + This specification defines a new WebDAV property that allows clients + to quickly determine the principal corresponding to the current + authenticated user. + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2 + 2. Conventions Used in This Document . . . . . . . . . . . . . . . 2 + 3. DAV:current-user-principal . . . . . . . . . . . . . . . . . . 3 + 4. Security Considerations . . . . . . . . . . . . . . . . . . . . 4 + 5. Acknowledgments . . . . . . . . . . . . . . . . . . . . . . . . 4 + 6. Normative References . . . . . . . . . . . . . . . . . . . . . 4 + + + + + + + + + +Sanchez & Daboo Standards Track [Page 1] + +RFC 5397 WebDAV Current Principal December 2008 + + +1. Introduction + + WebDAV [RFC4918] is an extension to HTTP [RFC2616] to support + improved document authoring capabilities. The WebDAV Access Control + Protocol ("WebDAV ACL") [RFC3744] extension adds access control + capabilities to WebDAV. It introduces the concept of a "principal" + resource, which is used to represent information about authenticated + entities on the system. + + Some clients have a need to determine which [RFC3744] principal a + server is associating with the currently authenticated HTTP user. + While [RFC3744] defines a DAV:current-user-privilege-set property for + retrieving the privileges granted to that principal, there is no + recommended way to identify the principal in question, which is + necessary to perform other useful operations. For example, a client + may wish to determine which groups the current user is a member of, + or modify a property of the principal resource associated with the + current user. + + The DAV:principal-match REPORT provides some useful functionality, + but there are common situations where the results from that query can + be ambiguous. For example, not only is an individual user principal + returned, but also every group principal that the user is a member + of, and there is no clear way to distinguish which is which. + + This specification proposes an extension to WebDAV ACL that adds a + DAV:current-user-principal property to resources under access control + on the server. This property provides a URL to a principal resource + corresponding to the currently authenticated user. This allows a + client to "bootstrap" itself by performing additional queries on the + principal resource to obtain additional information from that + resource, which is the purpose of this extension. Note that while it + is possible for multiple URLs to refer to the same principal + resource, or for multiple principal resources to correspond to a + single principal, this specification only allows for a single http(s) + URL in the DAV:current-user-principal property. If a client wishes + to obtain alternate URLs for the principal, it can query the + principal resource for this information; it is not the purpose of + this extension to provide a complete list of such URLs, but simply to + provide a means to locate a resource which contains that (and other) + information. + +2. Conventions Used in This Document + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + + + +Sanchez & Daboo Standards Track [Page 2] + +RFC 5397 WebDAV Current Principal December 2008 + + + When XML element types in the namespace "DAV:" are referenced in this + document outside of the context of an XML fragment, the string "DAV:" + will be prefixed to the element type names. + + Processing of XML by clients and servers MUST follow the rules + defined in Section 17 of WebDAV [RFC4918]. + + Some of the declarations refer to XML elements defined by WebDAV + [RFC4918]. + +3. DAV:current-user-principal + + Name: current-user-principal + + Namespace: DAV: + + Purpose: Indicates a URL for the currently authenticated user's + principal resource on the server. + + Value: A single DAV:href or DAV:unauthenticated element. + + Protected: This property is computed on a per-request basis, and + therefore is protected. + + Description: The DAV:current-user-principal property contains either + a DAV:href or DAV:unauthenticated XML element. The DAV:href + element contains a URL to a principal resource corresponding to + the currently authenticated user. That URL MUST be one of the + URLs in the DAV:principal-URL or DAV:alternate-URI-set properties + defined on the principal resource and MUST be an http(s) scheme + URL. When authentication has not been done or has failed, this + property MUST contain the DAV:unauthenticated pseudo-principal. + + In some cases, there may be multiple principal resources + corresponding to the same authenticated principal. In that case, + the server is free to choose any one of the principal resource + URIs for the value of the DAV:current-user-principal property. + However, servers SHOULD be consistent and use the same principal + resource URI for each authenticated principal. + + COPY/MOVE behavior: This property is computed on a per-request + basis, and is thus never copied or moved. + + Definition: + + + + + + + +Sanchez & Daboo Standards Track [Page 3] + +RFC 5397 WebDAV Current Principal December 2008 + + + Example: + + + /principals/users/cdaboo + + +4. Security Considerations + + This specification does not introduce any additional security issues + beyond those defined for HTTP [RFC2616], WebDAV [RFC4918], and WebDAV + ACL [RFC3744]. + +5. Acknowledgments + + This specification is based on discussions that took place within the + Calendaring and Scheduling Consortium's CalDAV Technical Committee. + The authors thank the participants of that group for their input. + + The authors thank Julian Reschke for his valuable input via the + WebDAV working group mailing list. + +6. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., + Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext + Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999. + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web + Distributed Authoring and Versioning (WebDAV) + Access Control Protocol", RFC 3744, May 2004. + + [RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed + Authoring and Versioning (WebDAV)", RFC 4918, June 2007. + +Authors' Addresses + + Wilfredo Sanchez + Apple Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + EMail: wsanchez@wsanchez.net + URI: http://www.apple.com/ + + + + +Sanchez & Daboo Standards Track [Page 4] + +RFC 5397 WebDAV Current Principal December 2008 + + + Cyrus Daboo + Apple Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + EMail: cyrus@daboo.name + URI: http://www.apple.com/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Sanchez & Daboo Standards Track [Page 5] + + diff --git a/doc/rfc5785-well-known-uris.txt b/doc/rfc5785-well-known-uris.txt new file mode 100644 index 0000000..c28ccf6 --- /dev/null +++ b/doc/rfc5785-well-known-uris.txt @@ -0,0 +1,451 @@ + + + + + + +Internet Engineering Task Force (IETF) M. Nottingham +Request for Comments: 5785 E. Hammer-Lahav +Updates: 2616, 2818 April 2010 +Category: Standards Track +ISSN: 2070-1721 + + + Defining Well-Known Uniform Resource Identifiers (URIs) + +Abstract + + This memo defines a path prefix for "well-known locations", + "/.well-known/", in selected Uniform Resource Identifier (URI) + schemes. + +Status of This Memo + + This is an Internet Standards Track document. + + This document is a product of the Internet Engineering Task Force + (IETF). It represents the consensus of the IETF community. It has + received public review and has been approved for publication by the + Internet Engineering Steering Group (IESG). Further information on + Internet Standards is available in Section 2 of RFC 5741. + + Information about the current status of this document, any errata, + and how to provide feedback on it may be obtained at + http://www.rfc-editor.org/info/rfc5785. + +Copyright Notice + + Copyright (c) 2010 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (http://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. Code Components extracted from this document must + include Simplified BSD License text as described in Section 4.e of + the Trust Legal Provisions and are provided without warranty as + described in the Simplified BSD License. + + + + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 1] + +RFC 5785 Defining Well-Known URIs April 2010 + + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2 + 1.1. Appropriate Use of Well-Known URIs . . . . . . . . . . . . 3 + 2. Notational Conventions . . . . . . . . . . . . . . . . . . . . 3 + 3. Well-Known URIs . . . . . . . . . . . . . . . . . . . . . . . . 3 + 4. Security Considerations . . . . . . . . . . . . . . . . . . . . 4 + 5. IANA Considerations . . . . . . . . . . . . . . . . . . . . . . 4 + 5.1. The Well-Known URI Registry . . . . . . . . . . . . . . . . 4 + 5.1.1. Registration Template . . . . . . . . . . . . . . . . . 5 + 6. References . . . . . . . . . . . . . . . . . . . . . . . . . . 5 + 6.1. Normative References . . . . . . . . . . . . . . . . . . . 5 + 6.2. Informative References . . . . . . . . . . . . . . . . . . 5 + Appendix A. Acknowledgements . . . . . . . . . . . . . . . . . . . 7 + Appendix B. Frequently Asked Questions . . . . . . . . . . . . . . 7 + +1. Introduction + + It is increasingly common for Web-based protocols to require the + discovery of policy or other information about a host ("site-wide + metadata") before making a request. For example, the Robots + Exclusion Protocol specifies a way for + automated processes to obtain permission to access resources; + likewise, the Platform for Privacy Preferences [W3C.REC-P3P-20020416] + tells user-agents how to discover privacy policy beforehand. + + While there are several ways to access per-resource metadata (e.g., + HTTP headers, WebDAV's PROPFIND [RFC4918]), the perceived overhead + (either in terms of client-perceived latency and/or deployment + difficulties) associated with them often precludes their use in these + scenarios. + + When this happens, it is common to designate a "well-known location" + for such data, so that it can be easily located. However, this + approach has the drawback of risking collisions, both with other such + designated "well-known locations" and with pre-existing resources. + + To address this, this memo defines a path prefix in HTTP(S) URIs for + these "well-known locations", "/.well-known/". Future specifications + that need to define a resource for such site-wide metadata can + register their use to avoid collisions and minimise impingement upon + sites' URI space. + + + + + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 2] + +RFC 5785 Defining Well-Known URIs April 2010 + + +1.1. Appropriate Use of Well-Known URIs + + There are a number of possible ways that applications could use Well- + known URIs. However, in keeping with the Architecture of the World- + Wide Web [W3C.REC-webarch-20041215], well-known URIs are not intended + for general information retrieval or establishment of large URI + namespaces on the Web. Rather, they are designed to facilitate + discovery of information on a site when it isn't practical to use + other mechanisms; for example, when discovering policy that needs to + be evaluated before a resource is accessed, or when using multiple + round-trips is judged detrimental to performance. + + As such, the well-known URI space was created with the expectation + that it will be used to make site-wide policy information and other + metadata available directly (if sufficiently concise), or provide + references to other URIs that provide such metadata. + +2. Notational Conventions + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in RFC 2119 [RFC2119]. + +3. Well-Known URIs + + A well-known URI is a URI [RFC3986] whose path component begins with + the characters "/.well-known/", and whose scheme is "HTTP", "HTTPS", + or another scheme that has explicitly been specified to use well- + known URIs. + + Applications that wish to mint new well-known URIs MUST register + them, following the procedures in Section 5.1. + + For example, if an application registers the name 'example', the + corresponding well-known URI on 'http://www.example.com/' would be + 'http://www.example.com/.well-known/example'. + + Registered names MUST conform to the segment-nz production in + [RFC3986]. + + Note that this specification defines neither how to determine the + authority to use for a particular context, nor the scope of the + metadata discovered by dereferencing the well-known URI; both should + be defined by the application itself. + + Typically, a registration will reference a specification that defines + the format and associated media type to be obtained by dereferencing + the well-known URI. + + + +Nottingham & Hammer-Lahav Standards Track [Page 3] + +RFC 5785 Defining Well-Known URIs April 2010 + + + It MAY also contain additional information, such as the syntax of + additional path components, query strings and/or fragment identifiers + to be appended to the well-known URI, or protocol-specific details + (e.g., HTTP [RFC2616] method handling). + + Note that this specification does not define a format or media-type + for the resource located at "/.well-known/" and clients should not + expect a resource to exist at that location. + +4. Security Considerations + + This memo does not specify the scope of applicability of metadata or + policy obtained from a well-known URI, and does not specify how to + discover a well-known URI for a particular application. Individual + applications using this mechanism must define both aspects. + + Applications minting new well-known URIs, as well as administrators + deploying them, will need to consider several security-related + issues, including (but not limited to) exposure of sensitive data, + denial-of-service attacks (in addition to normal load issues), server + and client authentication, vulnerability to DNS rebinding attacks, + and attacks where limited access to a server grants the ability to + affect how well-known URIs are served. + +5. IANA Considerations + +5.1. The Well-Known URI Registry + + This document establishes the well-known URI registry. + + Well-known URIs are registered on the advice of one or more + Designated Experts (appointed by the IESG or their delegate), with a + Specification Required (using terminology from [RFC5226]). However, + to allow for the allocation of values prior to publication, the + Designated Expert(s) may approve registration once they are satisfied + that such a specification will be published. + + Registration requests should be sent to the + wellknown-uri-review@ietf.org mailing list for review and comment, + with an appropriate subject (e.g., "Request for well-known URI: + example"). + + Before a period of 14 days has passed, the Designated Expert(s) will + either approve or deny the registration request, communicating this + decision both to the review list and to IANA. Denials should include + an explanation and, if applicable, suggestions as to how to make the + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 4] + +RFC 5785 Defining Well-Known URIs April 2010 + + + request successful. Registration requests that are undetermined for + a period longer than 21 days can be brought to the IESG's attention + (using the iesg@iesg.org mailing list) for resolution. + +5.1.1. Registration Template + + URI suffix: The name requested for the well-known URI, relative to + "/.well-known/"; e.g., "example". + + Change controller: For Standards-Track RFCs, state "IETF". For + others, give the name of the responsible party. Other details + (e.g., postal address, e-mail address, home page URI) may also be + included. + + Specification document(s): Reference to the document that specifies + the field, preferably including a URI that can be used to retrieve + a copy of the document. An indication of the relevant sections + may also be included, but is not required. + + Related information: Optionally, citations to additional documents + containing further relevant information. + +6. References + +6.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform + Resource Identifier (URI): Generic Syntax", STD 66, + RFC 3986, January 2005. + + [RFC5226] Narten, T. and H. Alvestrand, "Guidelines for Writing an + IANA Considerations Section in RFCs", BCP 26, RFC 5226, + May 2008. + +6.2. Informative References + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., Masinter, + L., Leach, P., and T. Berners-Lee, "Hypertext Transfer + Protocol -- HTTP/1.1", RFC 2616, June 1999. + + [RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed + Authoring and Versioning (WebDAV)", RFC 4918, June 2007. + + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 5] + +RFC 5785 Defining Well-Known URIs April 2010 + + + [W3C.REC-P3P-20020416] + Marchiori, M., "The Platform for Privacy Preferences 1.0 + (P3P1.0) Specification", World Wide Web Consortium + Recommendation REC-P3P-20020416, April 2002, + . + + [W3C.REC-webarch-20041215] + Jacobs, I. and N. Walsh, "Architecture of the World Wide + Web, Volume One", World Wide Web Consortium + Recommendation REC- webarch-20041215, December 2004, + . + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 6] + +RFC 5785 Defining Well-Known URIs April 2010 + + +Appendix A. Acknowledgements + + We would like to acknowledge the contributions of everyone who + provided feedback and use cases for this document; in particular, + Phil Archer, Dirk Balfanz, Adam Barth, Tim Bray, Brian Eaton, Brad + Fitzpatrick, Joe Gregorio, Paul Hoffman, Barry Leiba, Ashok Malhotra, + Breno de Medeiros, John Panzer, and Drummond Reed. However, they are + not responsible for errors and omissions. + +Appendix B. Frequently Asked Questions + + 1. Aren't well-known locations bad for the Web? + + They are, but for various reasons -- both technical and social -- + they are commonly used and their use is increasing. This memo + defines a "sandbox" for them, to reduce the risks of collision and + to minimise the impact upon pre-existing URIs on sites. + + 2. Why /.well-known? + + It's short, descriptive, and according to search indices, not + widely used. + + 3. What impact does this have on existing mechanisms, such as P3P and + robots.txt? + + None, until they choose to use this mechanism. + + 4. Why aren't per-directory well-known locations defined? + + Allowing every URI path segment to have a well-known location + (e.g., "/images/.well-known/") would increase the risks of + colliding with a pre-existing URI on a site, and generally these + solutions are found not to scale well, because they're too + "chatty". + + + + + + + + + + + + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 7] + +RFC 5785 Defining Well-Known URIs April 2010 + + +Authors' Addresses + + Mark Nottingham + + EMail: mnot@mnot.net + URI: http://www.mnot.net/ + + + Eran Hammer-Lahav + + EMail: eran@hueniverse.com + URI: http://hueniverse.com/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Nottingham & Hammer-Lahav Standards Track [Page 8] + diff --git a/doc/rfc6352-carddav.txt b/doc/rfc6352-carddav.txt new file mode 100644 index 0000000..cb03747 --- /dev/null +++ b/doc/rfc6352-carddav.txt @@ -0,0 +1,2691 @@ + + + + + + +Internet Engineering Task Force (IETF) C. Daboo +Request for Comments: 6352 Apple +Category: Standards Track August 2011 +ISSN: 2070-1721 + + + CardDAV: vCard Extensions to + Web Distributed Authoring and Versioning (WebDAV) + +Abstract + + This document defines extensions to the Web Distributed Authoring and + Versioning (WebDAV) protocol to specify a standard way of accessing, + managing, and sharing contact information based on the vCard format. + +Status of This Memo + + This is an Internet Standards Track document. + + This document is a product of the Internet Engineering Task Force + (IETF). It represents the consensus of the IETF community. It has + received public review and has been approved for publication by the + Internet Engineering Steering Group (IESG). Further information on + Internet Standards is available in Section 2 of RFC 5741. + + Information about the current status of this document, any errata, + and how to provide feedback on it may be obtained at + http://www.rfc-editor.org/info/rfc6352. + +Copyright Notice + + Copyright (c) 2011 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (http://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. Code Components extracted from this document must + include Simplified BSD License text as described in Section 4.e of + the Trust Legal Provisions and are provided without warranty as + described in the Simplified BSD License. + + This document may contain material from IETF Documents or IETF + Contributions published or made publicly available before November + 10, 2008. The person(s) controlling the copyright in some of this + material may not have granted the IETF Trust the right to allow + + + +Daboo Standards Track [Page 1] + +RFC 6352 CardDAV August 2011 + + + modifications of such material outside the IETF Standards Process. + Without obtaining an adequate license from the person(s) controlling + the copyright in such materials, this document may not be modified + outside the IETF Standards Process, and derivative works of it may + not be created outside the IETF Standards Process, except to format + it for publication as an RFC or to translate it into languages other + than English. + +Table of Contents + + 1. Introduction and Overview . . . . . . . . . . . . . . . . . . 4 + 2. Conventions . . . . . . . . . . . . . . . . . . . . . . . . . 5 + 3. Requirements Overview . . . . . . . . . . . . . . . . . . . . 6 + 4. Address Book Data Model . . . . . . . . . . . . . . . . . . . 7 + 4.1. Address Book Server . . . . . . . . . . . . . . . . . . . 7 + 5. Address Book Resources . . . . . . . . . . . . . . . . . . . . 7 + 5.1. Address Object Resources . . . . . . . . . . . . . . . . . 7 + 5.1.1. Data Type Conversion . . . . . . . . . . . . . . . . . 8 + 5.1.1.1. Additional Precondition for GET . . . . . . . . . 8 + 5.2. Address Book Collections . . . . . . . . . . . . . . . . . 9 + 6. Address Book Feature . . . . . . . . . . . . . . . . . . . . . 10 + 6.1. Address Book Support . . . . . . . . . . . . . . . . . . . 10 + 6.1.1. Example: Using OPTIONS for the Discovery of + Support for CardDAV . . . . . . . . . . . . . . . . . 10 + 6.2. Address Book Properties . . . . . . . . . . . . . . . . . 10 + 6.2.1. CARDDAV:addressbook-description Property . . . . . . . 10 + 6.2.2. CARDDAV:supported-address-data Property . . . . . . . 11 + 6.2.3. CARDDAV:max-resource-size Property . . . . . . . . . . 12 + 6.3. Creating Resources . . . . . . . . . . . . . . . . . . . . 13 + 6.3.1. Extended MKCOL Method . . . . . . . . . . . . . . . . 13 + 6.3.1.1. Example - Successful MKCOL Request . . . . . . . . 14 + 6.3.2. Creating Address Object Resources . . . . . . . . . . 15 + 6.3.2.1. Additional Preconditions for PUT, COPY, and + MOVE . . . . . . . . . . . . . . . . . . . . . . . 16 + 6.3.2.2. Non-Standard vCard Properties and Parameters . . . 17 + 6.3.2.3. Address Object Resource Entity Tag . . . . . . . . 18 + 7. Address Book Access Control . . . . . . . . . . . . . . . . . 18 + 7.1. Additional Principal Properties . . . . . . . . . . . . . 18 + 7.1.1. CARDDAV:addressbook-home-set Property . . . . . . . . 19 + 7.1.2. CARDDAV:principal-address Property . . . . . . . . . . 19 + 8. Address Book Reports . . . . . . . . . . . . . . . . . . . . . 20 + 8.1. REPORT Method . . . . . . . . . . . . . . . . . . . . . . 20 + 8.2. Ordinary Collections . . . . . . . . . . . . . . . . . . . 21 + 8.3. Searching Text: Collations . . . . . . . . . . . . . . . . 21 + 8.3.1. CARDDAV:supported-collation-set Property . . . . . . . 22 + 8.4. Partial Retrieval . . . . . . . . . . . . . . . . . . . . 23 + 8.5. Non-Standard Properties and Parameters . . . . . . . . . . 23 + + + + +Daboo Standards Track [Page 2] + +RFC 6352 CardDAV August 2011 + + + 8.6. CARDDAV:addressbook-query Report . . . . . . . . . . . . . 23 + 8.6.1. Limiting Results . . . . . . . . . . . . . . . . . . . 25 + 8.6.2. Truncation of Results . . . . . . . . . . . . . . . . 25 + 8.6.3. Example: Partial Retrieval of vCards Matching + NICKNAME . . . . . . . . . . . . . . . . . . . . . . . 26 + 8.6.4. Example: Partial Retrieval of vCards Matching a + Full Name or Email Address . . . . . . . . . . . . . . 27 + 8.6.5. Example: Truncated Results . . . . . . . . . . . . . . 29 + 8.7. CARDDAV:addressbook-multiget Report . . . . . . . . . . . 31 + 8.7.1. Example: CARDDAV:addressbook-multiget Report . . . . . 32 + 8.7.2. Example: CARDDAV:addressbook-multiget Report . . . . . 33 + 9. Client Guidelines . . . . . . . . . . . . . . . . . . . . . . 34 + 9.1. Restrict the Properties Returned . . . . . . . . . . . . . 34 + 9.2. Avoiding Lost Updates . . . . . . . . . . . . . . . . . . 35 + 9.3. Client Configuration . . . . . . . . . . . . . . . . . . . 35 + 9.4. Finding Other Users' Address Books . . . . . . . . . . . . 35 + 10. XML Element Definitions . . . . . . . . . . . . . . . . . . . 36 + 10.1. CARDDAV:addressbook XML Element . . . . . . . . . . . . . 36 + 10.2. CARDDAV:supported-collation XML Element . . . . . . . . . 36 + 10.3. CARDDAV:addressbook-query XML Element . . . . . . . . . . 37 + 10.4. CARDDAV:address-data XML Element . . . . . . . . . . . . . 37 + 10.4.1. CARDDAV:allprop XML Element . . . . . . . . . . . . . 39 + 10.4.2. CARDDAV:prop XML Element . . . . . . . . . . . . . . . 39 + 10.5. CARDDAV:filter XML Element . . . . . . . . . . . . . . . . 40 + 10.5.1. CARDDAV:prop-filter XML Element . . . . . . . . . . . 40 + 10.5.2. CARDDAV:param-filter XML Element . . . . . . . . . . . 41 + 10.5.3. CARDDAV:is-not-defined XML Element . . . . . . . . . . 42 + 10.5.4. CARDDAV:text-match XML Element . . . . . . . . . . . . 42 + 10.6. CARDDAV:limit XML Element . . . . . . . . . . . . . . . . 43 + 10.6.1. CARDDAV:nresults XML Element . . . . . . . . . . . . . 44 + 10.7. CARDDAV:addressbook-multiget XML Element . . . . . . . . . 44 + 11. Service Discovery via SRV Records . . . . . . . . . . . . . . 45 + 12. Internationalization Considerations . . . . . . . . . . . . . 45 + 13. Security Considerations . . . . . . . . . . . . . . . . . . . 45 + 14. IANA Consideration . . . . . . . . . . . . . . . . . . . . . . 46 + 14.1. Namespace Registration . . . . . . . . . . . . . . . . . . 46 + 15. Acknowledgments . . . . . . . . . . . . . . . . . . . . . . . 46 + 16. References . . . . . . . . . . . . . . . . . . . . . . . . . . 47 + 16.1. Normative References . . . . . . . . . . . . . . . . . . . 47 + 16.2. Informative References . . . . . . . . . . . . . . . . . . 48 + + + + + + + + + + + +Daboo Standards Track [Page 3] + +RFC 6352 CardDAV August 2011 + + +1. Introduction and Overview + + Address books containing contact information are a key component of + personal information management tools, such as email, calendaring and + scheduling, and instant messaging clients. To date several protocols + have been used for remote access to contact data, including the + Lightweight Directory Access Protocol (LDAP) [RFC4510], Internet + Message Support Protocol [IMSP], and Application Configuration Access + Protocol (ACAP) [RFC2244], together with SyncML used for + synchronization of such data. + + WebDAV [RFC4918] offers a number of advantages as a framework or + basis for address book access and management. Most of these + advantages boil down to a significant reduction in the costs of + design, implementation, interoperability testing, and deployment. + + The key features of address book support with WebDAV are: + + 1. Ability to use multiple address books with hierarchical layout. + + 2. Ability to control access to individual address books and address + entries as per WebDAV Access Control List (ACL) [RFC3744]. + + 3. Principal collections can be used to enumerate and query other + users on the system as per WebDAV ACL [RFC3744]. + + 4. Server-side searching of address data, avoiding the need for + clients to download an entire address book in order to do a quick + address 'expansion' operation. + + 5. Well-defined internationalization support through WebDAV's use of + XML. + + 6. Use of vCards [RFC2426] for well-defined address schema to + enhance client interoperability. + + 7. Many limited clients (e.g., mobile devices) contain an HTTP stack + that makes implementing WebDAV much easier than other protocols. + + The key disadvantage of address book support in WebDAV is: + + 1. Lack of change notification. Many of the alternative protocols + also lack this ability. However, an extension for push + notifications could easily be developed. + + vCard is a MIME directory profile aimed at encapsulating personal + addressing and contact information about people. The specification + of vCard was originally done by the Versit consortium, with a + + + +Daboo Standards Track [Page 4] + +RFC 6352 CardDAV August 2011 + + + subsequent 3.0 version standardized by the IETF [RFC2426]. vCard is + in widespread use in email clients and mobile devices as a means of + encapsulating address information for transport via email or for + import/export and synchronization operations. + + An update to vCard -- vCard v4 -- is currently being developed + [RFC6350] and is compatible with this specification. + +2. Conventions + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + The term "protected" is used in the Conformance field of property + definitions as defined in Section 15 of [RFC4918]. + + This document uses XML DTD fragments ([W3C.REC-xml-20081126], Section + 3.2) as a purely notational convention. WebDAV request and response + bodies cannot be validated by a DTD due to the specific extensibility + rules defined in Section 17 of [RFC4918] and due to the fact that all + XML elements defined by that specification use the XML namespace name + "DAV:". In particular: + + 1. Element names use the "DAV:" namespace. + + 2. Element ordering is irrelevant unless explicitly stated. + + 3. Extension elements (elements not already defined as valid child + elements) may be added anywhere, except when explicitly stated + otherwise. + + 4. Extension attributes (attributes not already defined as valid for + this element) may be added anywhere, except when explicitly + stated otherwise. + + The namespace "urn:ietf:params:xml:ns:carddav" is reserved for the + XML elements defined in this specification, its revisions, and + related CardDAV specifications. XML elements defined by individual + implementations MUST NOT use the "urn:ietf:params:xml:ns:carddav" + namespace, and instead should use a namespace that they control. + + When XML element types in the namespaces "DAV:" and + "urn:ietf:params:xml:ns:carddav" are referenced in this document + outside of the context of an XML fragment, the strings "DAV:" and + "CARDDAV:" will be prefixed to the element types, respectively. + + + + + +Daboo Standards Track [Page 5] + +RFC 6352 CardDAV August 2011 + + + This document inherits, and sometimes extends, DTD productions from + Section 14 of [RFC4918]. + + Also, note that some CardDAV XML element names are identical to + WebDAV XML element names, though their namespace differs. Care must + be taken not to confuse the two sets of names. + +3. Requirements Overview + + This section lists what functionality is required of a CardDAV + server. To advertise support for CardDAV, a server: + + o MUST support vCard v3 [RFC2426] as a media type for the address + object resource format; + + o MUST support WebDAV Class 3 [RFC4918]; + + o MUST support WebDAV ACL [RFC3744]; + + o MUST support secure transport as defined in [RFC2818] using + Transport Layer Security (TLS) [RFC5246] and using the certificate + validation procedures described in [RFC5280]; + + o MUST support ETags [RFC2616] with additional requirements + specified in Section 6.3.2.3 of this document; + + o MUST support all address book reports defined in Section 8 of this + document; and + + o MUST advertise support on all address book collections and address + object resources for the address book reports in the + DAV:supported-report-set property, as defined in Versioning + Extensions to WebDAV [RFC3253]. + + In addition, a server: + + o SHOULD support vCard v4 [RFC6350] as a media type for the address + object resource format; + + o SHOULD support the extended MKCOL method [RFC5689] to create + address book collections as defined in Section 6.3.1 of this + document. + + o SHOULD support the DAV:current-user-principal-URL property as + defined in [RFC5397] to give clients a fast way to locate user + principals. + + + + + +Daboo Standards Track [Page 6] + +RFC 6352 CardDAV August 2011 + + +4. Address Book Data Model + + As a brief overview, a CardDAV address book is modeled as a WebDAV + collection with a well-defined structure; each of these address book + collections contains a number of resources representing address + objects as their direct child resources. Each resource representing + an address object is called an "address object resource". Each + address object resource and each address book collection can be + individually locked and have individual WebDAV properties. + Requirements derived from this model are provided in Sections 5.1 and + 5.2. + +4.1. Address Book Server + + A CardDAV server is an address-aware engine combined with a WebDAV + server. The server may include address data in some parts of its URL + namespace and non-address data in other parts. + + A WebDAV server can advertise itself as a CardDAV server if it + supports the functionality defined in this specification at any point + within the root of its repository. That might mean that address data + is spread throughout the repository and mixed with non-address data + in nearby collections (e.g., address data may be found in /lisa/ + addressbook/ as well as in /bernard/addressbook/, and non-address + data in /lisa/calendars/). Or, it might mean that address data can + be found only in certain sections of the repository (e.g., + /addressbooks/user/). Address book features are only required in the + repository sections that are or contain address objects. So, a + repository confining address data to the /carddav/ collection would + only need to support the CardDAV required features within that + collection. + + The CardDAV server is the canonical location for address data and + state information. Clients may submit requests to change data or + download data. Clients may store address objects offline and attempt + to synchronize at a later time. Address data on the server can + change between the time of last synchronization and when attempting + an update, as address book collections may be shared and accessible + via multiple clients. Entity tags and locking help this work. + +5. Address Book Resources + +5.1. Address Object Resources + + This specification uses vCard as the default format for address or + contact information being stored on the server. However, this + specification does allow other formats for address data provided that + the server advertises support for those additional formats as + + + +Daboo Standards Track [Page 7] + +RFC 6352 CardDAV August 2011 + + + described below. The requirements in this section pertain to vCard + address data or formats that follow the semantics of vCard data. + + Address object resources contained in address book collections MUST + contain a single vCard component only. + + vCard components in an address book collection MUST have a UID + property value that MUST be unique in the scope of the address book + collection in which it is contained. + +5.1.1. Data Type Conversion + + Servers might support more than one primary media type for address + object resources, for example, vCard v3.0 and vCard v4.0. In such + cases, servers have to accept all media types that they advertise via + the CARDDAV:supported-address-data WebDAV property (see + Section 6.2.2). + + However, clients can use standard HTTP content negotiation behavior + (the Accept request header defined in Section 14.1 of [RFC2616]) to + request that an address object resource's data be returned in a + specific media type format. For example, a client merely capable of + handling vCard v3.0 would only want to have address object resources + returned in v3.0 format. + + Additionally, REPORT requests, defined later in this specification, + allow for the return of address object resource data within an XML + response body. Again, the client can use content negotiation to + request that data be returned in a specific media type by specifying + appropriate attributes on the CARDDAV:address-data XML element used + in the request body (see Section 10.4). + + In some cases, it might not be possible for a server to convert from + one media type to another. When that happens, the server MUST return + the CARDDAV:supported-address-data-conversion precondition (see + below) in the response body (when the failure to convert applies to + the entire response) or use that same precondition code in the + DAV:response XML element in the response for the targeted address + object resource when one of the REPORTs defined below is used. See + Section 8.7.2 for an example of this. + +5.1.1.1. Additional Precondition for GET + + This specification creates additional preconditions for the GET + method. + + + + + + +Daboo Standards Track [Page 8] + +RFC 6352 CardDAV August 2011 + + + The new precondition is: + + (CARDDAV:supported-address-data-conversion): The resource targeted + by the GET request can be converted to the media type specified in + the Accept request header included with the request. + +5.2. Address Book Collections + + Address book collections appear to clients as a WebDAV collection + resource, identified by a URL. An address book collection MUST + report the DAV:collection and CARDDAV:addressbook XML elements in the + value of the DAV:resourcetype property. The element type declaration + for CARDDAV:addressbook is: + + + + An address book collection can be created through provisioning (e.g., + automatically created when a user's account is provisioned), or it + can be created with the extended MKCOL method (see Section 6.3.1). + This can be used by a user to create additional address books (e.g., + "soccer team members") or for users to share an address book (e.g., + "sales team contacts"). However, note that this document doesn't + define what extra address book collections are for. Users must rely + on non-standard cues to find out what an address book collection is + for, or use the CARDDAV:addressbook-description property defined in + Section 6.2.1 to provide such a cue. + + The following restrictions are applied to the resources within an + address book collection: + + a. Address book collections MUST only contain address object + resources and collections that are not address book collections. + That is, the only "top-level" non-collection resources allowed in + an address book collection are address object resources. This + ensures that address book clients do not have to deal with non- + address data in an address book collection, though they do have + to distinguish between address object resources and collections + when using standard WebDAV techniques to examine the contents of + a collection. + + b. Collections contained in address book collections MUST NOT + contain address book collections at any depth. That is, + "nesting" of address book collections within other address book + collections at any depth is not allowed. This specification does + not define how collections contained in an address book + collection are used or how they relate to any address object + resources contained in the address book collection. + + + + +Daboo Standards Track [Page 9] + +RFC 6352 CardDAV August 2011 + + + Multiple address book collections MAY be children of the same + collection. + +6. Address Book Feature + +6.1. Address Book Support + + A server supporting the features described in this document MUST + include "addressbook" as a field in the DAV response header from an + OPTIONS request on any resource that supports any address book + properties, reports, or methods. A value of "addressbook" in the DAV + response header MUST indicate that the server supports all MUST level + requirements and REQUIRED features specified in this document. + +6.1.1. Example: Using OPTIONS for the Discovery of Support for CardDAV + + >> Request << + + OPTIONS /addressbooks/users/ HTTP/1.1 + Host: addressbook.example.com + + >> Response << + + HTTP/1.1 200 OK + Allow: OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE + Allow: MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL + DAV: 1, 2, 3, access-control, addressbook + DAV: extended-mkcol + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Length: 0 + + In this example, the OPTIONS response indicates that the server + supports CardDAV in this namespace; therefore, the '/addressbooks/ + users/' collection may be used as a parent for address book + collections as the extended MKCOL method is available and as a + possible target for REPORT requests for address book reports. + +6.2. Address Book Properties + +6.2.1. CARDDAV:addressbook-description Property + + Name: addressbook-description + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Provides a human-readable description of the address book + collection. + + + + +Daboo Standards Track [Page 10] + +RFC 6352 CardDAV August 2011 + + + Value: Any text. + + Protected: SHOULD NOT be protected so that users can specify a + description. + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + allprop behavior: SHOULD NOT be returned by a PROPFIND DAV:allprop + request. + + Description: This property contains a description of the address + book collection that is suitable for presentation to a user. The + xml:lang attribute can be used to add a language tag for the value + of this property. + + Definition: + + + + + Example: + + Adresses de Oliver Daboo + +6.2.2. CARDDAV:supported-address-data Property + + Name: supported-address-data + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies what media types are allowed for address object + resources in an address book collection. + + Protected: MUST be protected as it indicates the level of support + provided by the server. + + COPY/MOVE behavior: This property value MUST be preserved in COPY + and MOVE operations. + + allprop behavior: SHOULD NOT be returned by a PROPFIND DAV:allprop + request. + + Description: The CARDDAV:supported-address-data property is used to + specify the media type supported for the address object resources + contained in a given address book collection (e.g., vCard version + + + +Daboo Standards Track [Page 11] + +RFC 6352 CardDAV August 2011 + + + 3.0). Any attempt by the client to store address object resources + with a media type not listed in this property MUST result in an + error, with the CARDDAV:supported-address-data precondition + (Section 6.3.2.1) being violated. In the absence of this + property, the server MUST only accept data with the media type + "text/vcard" and vCard version 3.0, and clients can assume that is + all the server will accept. + + Definition: + + + + + + + + + Example: + + + + + +6.2.3. CARDDAV:max-resource-size Property + + Name: max-resource-size + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Provides a numeric value indicating the maximum size in + octets of a resource that the server is willing to accept when an + address object resource is stored in an address book collection. + + Value: Any text representing a numeric value. + + Protected: MUST be protected as it indicates limits provided by the + server. + + COPY/MOVE behavior: This property value MUST be preserved in COPY + and MOVE operations. + + allprop behavior: SHOULD NOT be returned by a PROPFIND DAV:allprop + request. + + + + + + +Daboo Standards Track [Page 12] + +RFC 6352 CardDAV August 2011 + + + Description: The CARDDAV:max-resource-size is used to specify a + numeric value that represents the maximum size in octets that the + server is willing to accept when an address object resource is + stored in an address book collection. Any attempt to store an + address book object resource exceeding this size MUST result in an + error, with the CARDDAV:max-resource-size precondition + (Section 6.3.2.1) being violated. In the absence of this + property, the client can assume that the server will allow storing + a resource of any reasonable size. + + Definition: + + + + + Example: + + 102400 + +6.3. Creating Resources + + Address book collections and address object resources may be created + by either a CardDAV client or the CardDAV server. This specification + defines restrictions and a data model that both clients and servers + MUST adhere to when manipulating such address data. + +6.3.1. Extended MKCOL Method + + An HTTP request using the extended MKCOL method [RFC5689] can be used + to create a new address book collection resource. A server MAY + restrict address book collection creation to particular collections. + + To create an address book, the client sends an extended MKCOL request + to the server and in the body of the request sets the + DAV:resourcetype property to the resource type for an address book + collection as defined in Section 5.2. + + Support for creating address books on the server is only RECOMMENDED + and not REQUIRED because some address book stores only support one + address book per user (or principal), and those are typically pre- + created for each account. However, servers and clients are strongly + encouraged to support address book creation whenever possible to + allow users to create multiple address book collections to help + organize their data better. + + + + + + +Daboo Standards Track [Page 13] + +RFC 6352 CardDAV August 2011 + + + The DAV:displayname property can be used for a human-readable name of + the address book. Clients can either specify the value of the + DAV:displayname property in the request body of the extended MKCOL + request or, alternatively, issue a PROPPATCH request to change the + DAV:displayname property to the appropriate value immediately after + using the extended MKCOL request. When displaying address book + collections to users, clients SHOULD check the DAV:displayname + property and use that value as the name of the address book. In the + event that the DAV:displayname property is not set, the client MAY + use the last part of the address book collection URI as the name; + however, that path segment may be "opaque" and not represent any + meaningful human-readable text. + +6.3.1.1. Example - Successful MKCOL Request + + This example creates an address book collection called /home/lisa/ + addressbook/ on the server addressbook.example.com with specific + values for the properties DAV:resourcetype, DAV:displayname, and + CARDDAV:addressbook-description. + + >> Request << + + MKCOL /home/lisa/addressbook/ HTTP/1.1 + Host: addressbook.example.com + Content-Type: text/xml; charset="utf-8" + Content-Length: xxx + + + + + + + + + + Lisa's Contacts + My primary address book. + + + + + + + + + + + + +Daboo Standards Track [Page 14] + +RFC 6352 CardDAV August 2011 + + + >> Response << + + HTTP/1.1 201 Created + Cache-Control: no-cache + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + HTTP/1.1 200 OK + + + +6.3.2. Creating Address Object Resources + + Clients populate address book collections with address object + resources. The URL for each address object resource is entirely + arbitrary and does not need to bear a specific relationship (but + might) to the address object resource's vCard properties or other + metadata. New address object resources MUST be created with a PUT + request targeted at an unmapped URI. A PUT request targeted at a + mapped URI updates an existing address object resource. + + When servers create new resources, it's not hard for the server to + choose a unique URL. It's slightly tougher for clients, because a + client might not want to examine all resources in the collection and + might not want to lock the entire collection to ensure that a new one + isn't created with a name collision. However, there is an HTTP + feature to mitigate this. If the client intends to create a new + address resource, the client SHOULD use the HTTP header "If-None- + Match: *" on the PUT request. The Request-URI on the PUT request + MUST include the target collection, where the resource is to be + created, plus the name of the resource in the last path segment. The + "If-None-Match" header ensures that the client will not inadvertently + overwrite an existing resource even if the last path segment turned + out to already be used. + + + + + + + +Daboo Standards Track [Page 15] + +RFC 6352 CardDAV August 2011 + + + >> Request << + + PUT /lisa/addressbook/newvcard.vcf HTTP/1.1 + If-None-Match: * + Host: addressbook.example.com + Content-Type: text/vcard + Content-Length: xxx + + BEGIN:VCARD + VERSION:3.0 + FN:Cyrus Daboo + N:Daboo;Cyrus + ADR;TYPE=POSTAL:;2822 Email HQ;Suite 2821;RFCVille;PA;15213;USA + EMAIL;TYPE=INTERNET,PREF:cyrus@example.com + NICKNAME:me + NOTE:Example VCard. + ORG:Self Employed + TEL;TYPE=WORK,VOICE:412 605 0499 + TEL;TYPE=FAX:412 605 0705 + URL:http://www.example.com + UID:1234-5678-9000-1 + END:VCARD + + >> Response << + + HTTP/1.1 201 Created + Date: Thu, 02 Sep 2004 16:53:32 GMT + Content-Length: 0 + ETag: "123456789-000-111" + + The request to change an existing address object resource without + overwriting a change made on the server uses a specific ETag in an + "If-Match" header, rather than the "If-None-Match" header. + + File names for vCards are commonly suffixed by ".vcf", and clients + may choose to use the same convention for URLs. + +6.3.2.1. Additional Preconditions for PUT, COPY, and MOVE + + This specification creates additional preconditions for the PUT, + COPY, and MOVE methods. These preconditions apply: + + o When a PUT operation of an address object resource into an address + book collection occurs. + + o When a COPY or MOVE operation of an address object resource into + an address book collection occurs. + + + + +Daboo Standards Track [Page 16] + +RFC 6352 CardDAV August 2011 + + + The new preconditions are: + + (CARDDAV:supported-address-data): The resource submitted in the + PUT request, or targeted by a COPY or MOVE request, MUST be a + supported media type (i.e., vCard) for address object resources. + + (CARDDAV:valid-address-data): The resource submitted in the PUT + request, or targeted by a COPY or MOVE request, MUST be valid data + for the media type being specified (i.e., MUST contain valid vCard + data). + + (CARDDAV:no-uid-conflict): The resource submitted in the PUT + request, or targeted by a COPY or MOVE request, MUST NOT specify a + vCard UID property value already in use in the targeted address + book collection or overwrite an existing address object resource + with one that has a different UID property value. Servers SHOULD + report the URL of the resource that is already making use of the + same UID property value in the DAV:href element. + + + + (CARDDAV:addressbook-collection-location-ok): In a COPY or MOVE + request, when the Request-URI is an address book collection, the + URI targeted by the Destination HTTP Request header MUST identify + a location where an address book collection can be created. + + (CARDDAV:max-resource-size): The resource submitted in the PUT + request, or targeted by a COPY or MOVE request, MUST have a size + in octets less than or equal to the value of the + CARDDAV:max-resource-size property value (Section 6.2.3) on the + address book collection where the resource will be stored. + +6.3.2.2. Non-Standard vCard Properties and Parameters + + vCard provides a "standard mechanism for doing non-standard things". + This extension support allows implementers to make use of non- + standard vCard properties and parameters whose names are prefixed + with the text "X-". + + Servers MUST support the use of non-standard properties and + parameters in address object resources stored via the PUT method. + + Servers may need to enforce rules for their own "private" properties + or parameters, so servers MAY reject any attempt by the client to + change those or use values for those outside of any restrictions the + server may have. A server SHOULD ensure that any "private" + + + + + +Daboo Standards Track [Page 17] + +RFC 6352 CardDAV August 2011 + + + properties or parameters it uses follow the convention of including a + vendor ID in the "X-" name, as described in Section 3.8 of [RFC2426], + e.g., "X-ABC-PRIVATE". + +6.3.2.3. Address Object Resource Entity Tag + + The DAV:getetag property MUST be defined and set to a strong entity + tag on all address object resources. + + A response to a GET request targeted at an address object resource + MUST contain an ETag response header field indicating the current + value of the strong entity tag of the address object resource. + + Servers SHOULD return a strong entity tag (ETag header) in a PUT + response when the stored address object resource is equivalent by + octet equality to the address object resource submitted in the body + of the PUT request. This allows clients to reliably use the returned + strong entity tag for data synchronization purposes. For instance, + the client can do a PROPFIND request on the stored address object + resource, have the DAV:getetag property returned, compare that value + with the strong entity tag it received on the PUT response, and know + that if they are equal, then the address object resource on the + server has not been changed. + + In the case where the data stored by a server as a result of a PUT + request is not equivalent by octet equality to the submitted address + object resource, the behavior of the ETag response header is not + specified here, with the exception that a strong entity tag MUST NOT + be returned in the response. As a result, a client may need to + retrieve the modified address object resource (and ETag) as a basis + for further changes, rather than use the address object resource it + had sent with the PUT request. + +7. Address Book Access Control + + CardDAV servers MUST support and adhere to the requirements of WebDAV + ACL [RFC3744]. WebDAV ACL provides a framework for an extensible set + of privileges that can be applied to WebDAV collections and ordinary + resources. + +7.1. Additional Principal Properties + + This section defines additional properties for WebDAV principal + resources as defined in [RFC3744]. + + + + + + + +Daboo Standards Track [Page 18] + +RFC 6352 CardDAV August 2011 + + +7.1.1. CARDDAV:addressbook-home-set Property + + Name: addressbook-home-set + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Identifies the URL of any WebDAV collections that contain + address book collections owned by the associated principal + resource. + + Protected: MAY be protected if the server has fixed locations in + which address books are created. + + COPY/MOVE behavior: This property value MUST be preserved in COPY + and MOVE operations. + + allprop behavior: SHOULD NOT be returned by a PROPFIND DAV:allprop + request. + + Description: The CARDDAV:addressbook-home-set property is meant to + allow users to easily find the address book collections owned by + the principal. Typically, users will group all the address book + collections that they own under a common collection. This + property specifies the URL of collections that are either address + book collections or ordinary collections that have child or + descendant address book collections owned by the principal. + + Definition: + + + + Example: + + + /bernard/addresses/ + + +7.1.2. CARDDAV:principal-address Property + + Name: principal-address + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Identifies the URL of an address object resource that + corresponds to the user represented by the principal. + + + + + +Daboo Standards Track [Page 19] + +RFC 6352 CardDAV August 2011 + + + Protected: MAY be protected if the server provides a fixed location + for principal addresses. + + COPY/MOVE behavior: This property value MUST be preserved in COPY + and MOVE operations. + + allprop behavior: SHOULD NOT be returned by a PROPFIND DAV:allprop + request. + + Description: The CARDDAV:principal-address property is meant to + allow users to easily find contact information for users + represented by principals on the system. This property specifies + the URL of the resource containing the corresponding contact + information. The resource could be an address object resource in + an address book collection, or it could be a resource in a + "regular" collection. + + Definition: + + + + Example: + + + /system/cyrus.vcf + + +8. Address Book Reports + + This section defines the reports that CardDAV servers MUST support on + address book collections and address object resources. + + CardDAV servers MUST advertise support for these reports on all + address book collections and address object resources with the + DAV:supported-report-set property defined in Section 3.1.5 of + [RFC3253]. CardDAV servers MAY also advertise support for these + reports on ordinary collections. + + Some of these reports allow address data (from possibly multiple + resources) to be returned. + +8.1. REPORT Method + + The REPORT method (defined in Section 3.6 of [RFC3253]) provides an + extensible mechanism for obtaining information about a resource. + Unlike the PROPFIND method, which returns the value of one or more + named properties, the REPORT method can involve more complex + + + +Daboo Standards Track [Page 20] + +RFC 6352 CardDAV August 2011 + + + processing. REPORT is valuable in cases where the server has access + to all of the information needed to perform the complex request (such + as a query), and where it would require multiple requests for the + client to retrieve the information needed to perform the same + request. + + A server that supports this specification MUST support the + DAV:expand-property report (defined in Section 3.8 of [RFC3253]). + +8.2. Ordinary Collections + + Servers MAY support the reports defined in this document on ordinary + collections (collections that are not address book collections) in + addition to address book collections or address object resources. In + computing responses to the reports on ordinary collections, servers + MUST only consider address object resources contained in address book + collections that are targeted by the REPORT based on the value of the + Depth request header. + +8.3. Searching Text: Collations + + Some of the reports defined in this section do text matches of + character strings provided by the client and compared to stored + address data. Since vCard data is by default encoded in the UTF-8 + charset and may include characters outside of the US-ASCII charset + range in some property and parameter values, there is a need to + ensure that text matching follows well-defined rules. + + To deal with this, this specification makes use of the IANA Collation + Registry defined in [RFC4790] to specify collations that may be used + to carry out the text comparison operations with a well-defined rule. + + Collations supported by the server MUST support "equality" and + "substring" match operations as per [RFC4790], Section 4.2, including + the "prefix" and "suffix" options for "substring" matching. CardDAV + uses these match options for "equals", "contains", "starts-with", and + "ends-with" match operations. + + CardDAV servers are REQUIRED to support the "i;ascii-casemap" + [RFC4790] and "i;unicode-casemap" [RFC5051] collations and MAY + support other collations. + + Servers MUST advertise the set of collations that they support via + the CARDDAV:supported-collation-set property defined on any resource + that supports reports that use collations. + + + + + + +Daboo Standards Track [Page 21] + +RFC 6352 CardDAV August 2011 + + + In the absence of a collation explicitly specified by the client, or + if the client specifies the "default" collation identifier (as + defined in [RFC4790], Section 3.1), the server MUST default to using + "i;unicode-casemap" as the collation. + + Wildcards (as defined in [RFC4790], Section 3.2) MUST NOT be used in + the collation identifier. + + If the client chooses a collation not supported by the server, the + server MUST respond with a CARDDAV:supported-collation precondition + error response. + +8.3.1. CARDDAV:supported-collation-set Property + + Name: supported-collation-set + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Identifies the set of collations supported by the server + for text matching operations. + + Protected: MUST be protected as it indicates support provided by the + server. + + COPY/MOVE behavior: This property value MUST be preserved in COPY + and MOVE operations. + + allprop behavior: SHOULD NOT be returned by a PROPFIND DAV:allprop + request. + + Description: The CARDDAV:supported-collation-set property contains + two or more CARDDAV:supported-collation elements that specify the + identifiers of the collations supported by the server. + + Definition: + + + + + + + + + + + + +Daboo Standards Track [Page 22] + +RFC 6352 CardDAV August 2011 + + + Example: + + + i;ascii-casemap + i;octet + i;unicode-casemap + + +8.4. Partial Retrieval + + Some address book reports defined in this document allow partial + retrieval of address object resources. A CardDAV client can specify + what information to return in the body of an address book REPORT + request. + + A CardDAV client can request particular WebDAV property values, all + WebDAV property values, or a list of the names of the resource's + WebDAV properties. A CardDAV client can also request address data to + be returned and whether all vCard properties should be returned or + only particular ones. See CARDDAV:address-data in Section 10.4. + +8.5. Non-Standard Properties and Parameters + + Servers MUST support the use of non-standard vCard property or + parameter names in the CARDDAV:address-data XML element in address + book REPORT requests to allow clients to request that non-standard + properties and parameters be returned in the address data provided in + the response. + + Servers MAY support the use of non-standard vCard property or + parameter names in the CARDDAV:prop-filter and CARDDAV:param-filter + XML elements specified in the CARDDAV:filter XML element of address + book REPORT requests. + + Servers MUST fail with the CARDDAV:supported-filter precondition if + an address book REPORT request uses a CARDDAV:prop-filter or + CARDDAV:param-filter XML element that makes reference to a non- + standard vCard property or parameter name on which the server does + not support queries. + +8.6. CARDDAV:addressbook-query Report + + The CARDDAV:addressbook-query REPORT performs a search for all + address object resources that match a specified filter. The response + of this report will contain all the WebDAV properties and address + object resource data specified in the request. In the case of the + + + + +Daboo Standards Track [Page 23] + +RFC 6352 CardDAV August 2011 + + + CARDDAV:address-data XML element, one can explicitly specify the + vCard properties that should be returned in the address object + resource data that matches the filter. + + The format of this report is modeled on the PROPFIND method. The + request and response bodies of the CARDDAV:addressbook-query report + use XML elements that are also used by PROPFIND. In particular, the + request can include XML elements to request WebDAV properties to be + returned. When that occurs, the response should follow the same + behavior as PROPFIND with respect to the DAV:multistatus response + elements used to return specific WebDAV property results. For + instance, a request to retrieve the value of a WebDAV property that + does not exist is an error and MUST be noted with a response XML + element that contains a 404 (Not Found) status value. + + Support for the CARDDAV:addressbook-query REPORT is REQUIRED. + + Marshalling: + + The request body MUST be a CARDDAV:addressbook-query XML element + as defined in Section 10.3. + + The request MUST include a Depth header. The scope of the query + is determined by the value of the Depth header. For example, to + query all address object resources in an address book collection, + the REPORT would use the address book collection as the Request- + URI and specify a Depth of 1 or infinity. + + The response body for a successful request MUST be a + DAV:multistatus XML element (i.e., the response uses the same + format as the response for PROPFIND). In the case where there are + no response elements, the returned DAV:multistatus XML element is + empty. + + The response body for a successful CARDDAV:addressbook-query + REPORT request MUST contain a DAV:response element for each + address object that matched the search filter. Address data is + returned in the CARDDAV:address-data XML element inside the + DAV:propstat XML element. + + Preconditions: + + (CARDDAV:supported-address-data): The attributes "content-type" + and "version" of the CARDDAV:address-data XML element (see + Section 10.4) specify a media type supported by the server for + address object resources. + + + + + +Daboo Standards Track [Page 24] + +RFC 6352 CardDAV August 2011 + + + (CARDDAV:supported-filter): The CARDDAV:prop-filter (see + Section 10.5.1) and CARDDAV:param-filter (see Section 10.5.2) XML + elements used in the CARDDAV:filter XML element (see Section 10.5) + in the REPORT request only make reference to vCard properties and + parameters for which queries are supported by the server. That + is, if the CARDDAV:filter element attempts to reference an + unsupported vCard property or parameter, this precondition is + violated. A server SHOULD report the CARDDAV:prop-filter or + CARDDAV:param-filter for which it does not provide support. + + + + (CARDDAV:supported-collation): Any XML attribute specifying a + collation MUST specify a collation supported by the server as + described in Section 8.3. + + Postconditions: + + (DAV:number-of-matches-within-limits): The number of matching + address object resources must fall within server-specific, + predefined limits. For example, this condition might be triggered + if a search specification would cause the return of an extremely + large number of responses. + +8.6.1. Limiting Results + + A client can limit the number of results returned by the server + through use of the CARDDAV:limit element in the request body. This + is useful when clients are only interested in a few matches or only + have limited space to display results to users and thus don't need + the overhead of receiving more than that. When the results are + truncated by the server, the server MUST follow the rules below for + indicating a result set truncation to the client. + +8.6.2. Truncation of Results + + A server MAY limit the number of resources in a response, for + example, to limit the amount of work expended in processing a query, + or as the result of an explicit limit set by the client. If the + result set is truncated because of such a limit, the response MUST + use status code 207 (Multi-Status), return a DAV:multistatus response + body, and indicate a status of 507 (Insufficient Storage) for the + Request-URI. That DAV:response element SHOULD include a DAV:error + element with the DAV:number-of-matches-within-limits precondition, as + defined in [RFC3744], Section 9.2. + + + + + +Daboo Standards Track [Page 25] + +RFC 6352 CardDAV August 2011 + + + The server SHOULD also include the partial results in additional + DAV:response elements. If a client-requested limit is being applied, + the 507 response for the Request-URI MUST NOT be included in + calculating the limit (e.g., if the client requests that only a + single result be returned, and multiple matches are present, then the + DAV:multistatus response will include one DAV:response for the + matching resource and one DAV:response for the 507 status on the + Request-URI). + +8.6.3. Example: Partial Retrieval of vCards Matching NICKNAME + + In this example, the client requests that the server search for + address object resources that contain a NICKNAME property whose value + equals some specific text and return specific vCard properties for + those vCards found. In addition, the DAV:getetag property is also + requested and returned as part of the response. + + >> Request << + + REPORT /home/bernard/addressbook/ HTTP/1.1 + Host: addressbook.example.com + Depth: 1 + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + + + me + + + + + + + + +Daboo Standards Track [Page 26] + +RFC 6352 CardDAV August 2011 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + /home/bernard/addressbook/v102.vcf + + + "23ba4d-ff11fb" + BEGIN:VCARD + VERSION:3.0 + NICKNAME:me + UID:34222-232@example.com + FN:Cyrus Daboo + EMAIL:daboo@example.com + END:VCARD + + + HTTP/1.1 200 OK + + + + +8.6.4. Example: Partial Retrieval of vCards Matching a Full Name or + Email Address + + In this example, the client requests that the server search for + address object resources that contain a FN property whose value + contains some specific text or that contain an EMAIL property whose + value contains other text and return specific vCard properties for + those vCards found. In addition, the DAV:getetag property is also + requested and returned as part of the response. + + >> Request << + + REPORT /home/bernard/addressbook/ HTTP/1.1 + Host: addressbook.example.com + Depth: 1 + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + +Daboo Standards Track [Page 27] + +RFC 6352 CardDAV August 2011 + + + + + + + + + + + + + + + + + daboo + + + daboo + + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + /home/bernard/addressbook/v102.vcf + + + "23ba4d-ff11fb" + BEGIN:VCARD + VERSION:3.0 + NICKNAME:me + UID:34222-232@example.com + FN:David Boo + EMAIL:daboo@example.com + + + +Daboo Standards Track [Page 28] + +RFC 6352 CardDAV August 2011 + + + END:VCARD + + + HTTP/1.1 200 OK + + + + /home/bernard/addressbook/v104.vcf + + + "23ba4d-ff11fc" + BEGIN:VCARD + VERSION:3.0 + NICKNAME:oliver + UID:34222-23222@example.com + FN:Oliver Daboo + EMAIL:oliver@example.com + END:VCARD + + + HTTP/1.1 200 OK + + + + +8.6.5. Example: Truncated Results + + In this example, the client requests that the server search for + address object resources that contain a FN property whose value + contains some specific text and return the DAV:getetag property for + two results only. The server response includes a 507 status for the + Request-URI indicating that there were more than two resources that + matched the query, but that the server truncated the result set as + requested by the client. + + >> Request << + + REPORT /home/bernard/addressbook/ HTTP/1.1 + Host: addressbook.example.com + Depth: 1 + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + +Daboo Standards Track [Page 29] + +RFC 6352 CardDAV August 2011 + + + + + + + + daboo + + + + 2 + + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + /home/bernard/addressbook/ + HTTP/1.1 507 Insufficient Storage + + + Only two matching records were returned + + + + /home/bernard/addressbook/v102.vcf + + + "23ba4d-ff11fb" + + HTTP/1.1 200 OK + + + + /home/bernard/addressbook/v104.vcf + + + "23ba4d-ff11fc" + + + + +Daboo Standards Track [Page 30] + +RFC 6352 CardDAV August 2011 + + + HTTP/1.1 200 OK + + + + +8.7. CARDDAV:addressbook-multiget Report + + The CARDDAV:addressbook-multiget REPORT is used to retrieve specific + address object resources from within a collection, if the Request-URI + is a collection, or to retrieve a specific address object resource, + if the Request-URI is an address object resource. This report is + similar to the CARDDAV:addressbook-query REPORT (see Section 8.6), + except that it takes a list of DAV:href elements instead of a + CARDDAV:filter element to determine which address object resources to + return. + + Support for the addressbook-multiget REPORT is REQUIRED. + + Marshalling: + + The request body MUST be a CARDDAV:addressbook-multiget XML + element (see Section 10.7), which MUST contain at least one + DAV:href XML element and one optional CARDDAV:address-data element + as defined in Section 10.4. If DAV:href elements are present, the + scope of the request is the set of resources identified by these + elements, which all need to be members (not necessarily internal + members) of the resource identified by the Request-URI. + Otherwise, the scope is the resource identified by the Request-URI + itself. + + The request MUST include a Depth: 0 header; however, the actual + scope of the REPORT is determined as described above. + + The response body for a successful request MUST be a + DAV:multistatus XML element. + + The response body for a successful CARDDAV:addressbook-multiget + REPORT request MUST contain a DAV:response element for each + address object resource referenced by the provided set of DAV:href + elements. Address data is returned in the CARDDAV:address-data + element inside the DAV:prop element. + + In the case of an error accessing any of the provided DAV:href + resources, the server MUST return the appropriate error status + code in the DAV:status element of the corresponding DAV:response + element. + + + + + +Daboo Standards Track [Page 31] + +RFC 6352 CardDAV August 2011 + + + Preconditions: + + (CARDDAV:supported-address-data): The attributes "content-type" + and "version" of the CARDDAV:address-data XML elements (see + Section 10.4) specify a media type supported by the server for + address object resources. + + Postconditions: + + None. + +8.7.1. Example: CARDDAV:addressbook-multiget Report + + In this example, the client requests the server to return specific + vCard properties of the address components referenced by specific + URIs. In addition, the DAV:getetag property is also requested and + returned as part of the response. Note that, in this example, the + resource at + http://addressbook.example.com/home/bernard/addressbook/vcf1.vcf does + not exist, resulting in an error status response. + + >> Request << + + REPORT /home/bernard/addressbook/ HTTP/1.1 + Host: addressbook.example.com + Depth: 1 + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + + + + + + + /home/bernard/addressbook/vcf102.vcf + /home/bernard/addressbook/vcf1.vcf + + + + + + + +Daboo Standards Track [Page 32] + +RFC 6352 CardDAV August 2011 + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + /home/bernard/addressbook/vcf102.vcf + + + "23ba4d-ff11fb" + BEGIN:VCARD + VERSION:3.0 + NICKNAME:me + UID:34222-232@example.com + FN:Cyrus Daboo + EMAIL:daboo@example.com + END:VCARD + + + HTTP/1.1 200 OK + + + + /home/bernard/addressbook/vcf1.vcf + HTTP/1.1 404 Resource not found + + + +8.7.2. Example: CARDDAV:addressbook-multiget Report + + In this example, the client requests the server to return vCard v4.0 + data of the address components referenced by specific URIs. In + addition, the DAV:getetag property is also requested and returned as + part of the response. Note that, in this example, the resource at + http://addressbook.example.com/home/bernard/addressbook/vcf3.vcf + exists but in a media type format that the server is unable to + convert, resulting in an error status response. + + + + + + + + + +Daboo Standards Track [Page 33] + +RFC 6352 CardDAV August 2011 + + + >> Request << + + REPORT /home/bernard/addressbook/ HTTP/1.1 + Host: addressbook.example.com + Depth: 1 + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + + + + /home/bernard/addressbook/vcf3.vcf + + + >> Response << + + HTTP/1.1 207 Multi-Status + Date: Sat, 11 Nov 2006 09:32:12 GMT + Content-Type: text/xml; charset="utf-8" + Content-Length: xxxx + + + + + /home/bernard/addressbook/vcf3.vcf + HTTP/1.1 415 Unsupported Media Type + + Unable to convert from vCard v3.0 + to vCard v4.0 + + + +9. Client Guidelines + +9.1. Restrict the Properties Returned + + Clients may not need all the properties in a vCard object when + presenting information to the user, or looking up specific items for + their email address, for example. Since some property data can be + large (e.g., PHOTO or SOUND with in-line content) clients can choose + to ignore those by only requesting the specific items it knows it + will use, through use of the CARDDAV:address-data XML element in the + relevant reports. + + + +Daboo Standards Track [Page 34] + +RFC 6352 CardDAV August 2011 + + + However, if a client needs to make a change to a vCard, it can only + change the entire vCard data via a PUT request. There is no way to + incrementally make a change to a set of properties within a vCard + object resource. As a result, the client will have to cache the + entire set of properties on a resource that is being changed. + +9.2. Avoiding Lost Updates + + When resources are accessed by multiple clients, the possibility of + clients overwriting each other's changes exists. To alleviate this, + clients SHOULD use the If-Match request header on PUT requests with + the ETag of the previously retrieved resource data to check whether + the resource was modified since it was previously retrieved. If a + precondition failure occurs, clients need to reload the resource and + go through their own merge or conflict resolution process before + writing back the data (again using the If-Match check). + +9.3. Client Configuration + + When CardDAV clients need to be configured, the key piece of + information that they require is the principal-URL of the user whose + address book information is desired. Servers SHOULD support the + DAV:current-user-principal-URL property as defined in [RFC5397] to + give clients a fast way to locate user principals. + + Given support for SRV records (Section 11) and DAV:current-user- + principal-URL [RFC5397], users only need enter a user identifier, + host name, and password to configure their client. The client would + take the host name and do an SRV lookup to locate the CardDAV server, + then execute an authenticated PROPFIND on the root/resource looking + for the DAV:current-user-principal-URL property. The value returned + gives the client direct access to the user's principal-URL and from + there all the related CardDAV properties needed to locate address + books. + +9.4. Finding Other Users' Address Books + + For use cases of address book sharing, one might wish to find the + address book belonging to another user. To find other users' address + books on the same server, the DAV:principal-property-search REPORT + [RFC3744] can be used to search principals for matching properties + and return specified properties for the matching principal resources. + To search for an address book owned by a user named "Laurie", the + REPORT request body would look like this: + + + + + + + +Daboo Standards Track [Page 35] + +RFC 6352 CardDAV August 2011 + + + + + + + + + Laurie + + + + + + + + The server performs a case-sensitive or caseless search for a + matching string subset of "Laurie" within the DAV:displayname + property. Thus, the server might return "Laurie Dusseault", "Laurier + Desruisseaux", or "Wilfrid Laurier" all as matching DAV:displayname + values, and the address books for each of these. + +10. XML Element Definitions + +10.1. CARDDAV:addressbook XML Element + + Name: addressbook + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies the resource type of an address book collection. + + Description: See Section 5.2. + + Definition: + + + +10.2. CARDDAV:supported-collation XML Element + + Name: supported-collation + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Identifies a single collation via its collation identifier + as defined by [RFC4790]. + + Description: The CARDDAV:supported-collation contains the text of a + collation identifier as described in Section 8.3.1. + + + +Daboo Standards Track [Page 36] + +RFC 6352 CardDAV August 2011 + + + Definition: + + + + +10.3. CARDDAV:addressbook-query XML Element + + Name: addressbook-query + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Defines a report for querying address book data + + Description: See Section 8.6. + + Definition: + + + +10.4. CARDDAV:address-data XML Element + + Name: address-data + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies one of the following: + + 1. The parts of an address object resource that should be + returned by a given address book REPORT request, and the media + type and version for the returned data; or + + 2. The content of an address object resource in a response to an + address book REPORT request. + + Description: When used in an address book REPORT request, the + CARDDAV:address-data XML element specifies which parts of address + object resources need to be returned in the response. If the + CARDDAV:address-data XML element doesn't contain any CARDDAV:prop + elements, address object resources will be returned in their + entirety. Additionally, a media type and version can be specified + to request that the server return the data in that format if + possible. + + Finally, when used in an address book REPORT response, the + CARDDAV:address-data XML element specifies the content of an + address object resource. Given that XML parsers normalize the + + + +Daboo Standards Track [Page 37] + +RFC 6352 CardDAV August 2011 + + + two-character sequence CRLF (US-ASCII decimal 13 and US-ASCII + decimal 10) to a single LF character (US-ASCII decimal 10), the CR + character (US-ASCII decimal 13) MAY be omitted in address object + resources specified in the CARDDAV:address-data XML element. + Furthermore, address object resources specified in the + CARDDAV:address-data XML element MAY be invalid per their media + type specification if the CARDDAV:address-data XML element part of + the address book REPORT request did not specify required vCard + properties (e.g., UID, etc.) or specified a CARDDAV:prop XML + element with the "novalue" attribute set to "yes". + + Note: The CARDDAV:address-data XML element is specified in requests + and responses inside the DAV:prop XML element as if it were a + WebDAV property. However, the CARDDAV:address-data XML element is + not a WebDAV property and as such it is not returned in PROPFIND + responses nor used in PROPPATCH requests. + + Note: The address data embedded within the CARDDAV:address-data XML + element MUST follow the standard XML character data encoding + rules, including use of <, >, & etc., entity encoding or + the use of a construct. In the latter case, the + vCard data cannot contain the character sequence "]]>", which is + the end delimiter for the CDATA section. + + Definition: + + + + when nested in the DAV:prop XML element in an address book + REPORT request to specify which parts of address object + resources should be returned in the response; + + + + + when nested in the DAV:prop XML element in an address book + REPORT response to specify the content of a returned + address object resource. + + + + + + attributes can be used on each variant of the + CALDAV:address-data XML element. + + + + + +Daboo Standards Track [Page 38] + +RFC 6352 CardDAV August 2011 + + +10.4.1. CARDDAV:allprop XML Element + + Name: allprop + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies that all vCard properties shall be returned. + + Description: This element can be used when the client wants all + vCard properties of components returned by a report. + + Definition: + + + + Note: The CARDDAV:allprop element defined here has the same name as + the DAV:allprop element defined in WebDAV. However, the + CARDDAV:allprop element defined here uses the + "urn:ietf:params:xml:ns:carddav" namespace, as opposed to the "DAV:" + namespace used for the DAV:allprop element defined in WebDAV. + +10.4.2. CARDDAV:prop XML Element + + Name: prop + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Defines which vCard properties to return in the response. + + Description: The "name" attribute specifies the name of the vCard + property to return (e.g., "NICKNAME"). The "novalue" attribute + can be used by clients to request that the actual value of the + property not be returned (if the "novalue" attribute is set to + "yes"). In that case, the server will return just the vCard + property name and any vCard parameters and a trailing ":" without + the subsequent value data. + + vCard allows a "group" prefix to appear before a property name in + the vCard data. When the "name" attribute does not specify a + group prefix, it MUST match properties in the vCard data without a + group prefix or with any group prefix. When the "name" attribute + includes a group prefix, it MUST match properties that have + exactly the same group prefix and name. For example, a "name" set + to "TEL" will match "TEL", "X-ABC.TEL", and "X-ABC-1.TEL" vCard + properties. A "name" set to "X-ABC.TEL" will match an "X-ABC.TEL" + vCard property only; it will not match "TEL" or "X-ABC-1.TEL". + + + + + +Daboo Standards Track [Page 39] + +RFC 6352 CardDAV August 2011 + + + Definition: + + + + + + + + Note: The CARDDAV:prop element defined here has the same name as the + DAV:prop element defined in WebDAV. However, the CARDDAV:prop + element defined here uses the "urn:ietf:params:xml:ns:carddav" + namespace, as opposed to the "DAV:" namespace used for the DAV:prop + element defined in WebDAV. + +10.5. CARDDAV:filter XML Element + + Name: filter + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Determines which matching objects are returned. + + Description: The "filter" element specifies the search filter used + to match address objects that should be returned by a report. The + "test" attribute specifies whether any (logical OR) or all + (logical AND) of the prop-filter tests need to match in order for + the overall filter to match. + + Definition: + + + + + + +10.5.1. CARDDAV:prop-filter XML Element + + Name: prop-filter + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Limits the search to specific vCard properties. + + + + + + +Daboo Standards Track [Page 40] + +RFC 6352 CardDAV August 2011 + + + Description: The CARDDAV:prop-filter XML element specifies search + criteria on a specific vCard property (e.g., "NICKNAME"). An + address object is said to match a CARDDAV:prop-filter if: + + * A vCard property of the type specified by the "name" attribute + exists, and the CARDDAV:prop-filter is empty, or it matches any + specified CARDDAV:text-match or CARDDAV:param-filter + conditions. The "test" attribute specifies whether any + (logical OR) or all (logical AND) of the text-filter and param- + filter tests need to match in order for the overall filter to + match. + + or: + + * A vCard property of the type specified by the "name" attribute + does not exist, and the CARDDAV:is-not-defined element is + specified. + + vCard allows a "group" prefix to appear before a property name in + the vCard data. When the "name" attribute does not specify a + group prefix, it MUST match properties in the vCard data without a + group prefix or with any group prefix. When the "name" attribute + includes a group prefix, it MUST match properties that have + exactly the same group prefix and name. For example, a "name" set + to "TEL" will match "TEL", "X-ABC.TEL", "X-ABC-1.TEL" vCard + properties. A "name" set to "X-ABC.TEL" will match an "X-ABC.TEL" + vCard property only, it will not match "TEL" or "X-ABC-1.TEL". + + Definition: + + + + + + +10.5.2. CARDDAV:param-filter XML Element + + Name: param-filter + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Limits the search to specific parameter values. + + + + +Daboo Standards Track [Page 41] + +RFC 6352 CardDAV August 2011 + + + Description: The CARDDAV:param-filter XML element specifies search + criteria on a specific vCard property parameter (e.g., TYPE) in + the scope of a given CARDDAV:prop-filter. A vCard property is + said to match a CARDDAV:param-filter if: + + * A parameter of the type specified by the "name" attribute + exists, and the CARDDAV:param-filter is empty, or it matches + the CARDDAV:text-match conditions if specified. + + or: + + * A parameter of the type specified by the "name" attribute does + not exist, and the CARDDAV:is-not-defined element is specified. + + Definition: + + + + + + +10.5.3. CARDDAV:is-not-defined XML Element + + Name: is-not-defined + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies that a match should occur if the enclosing vCard + property or parameter does not exist. + + Description: The CARDDAV:is-not-defined XML element specifies that a + match occurs if the enclosing vCard property or parameter value + specified in an address book REPORT request does not exist in the + address data being tested. + + Definition: + + + +10.5.4. CARDDAV:text-match XML Element + + Name: text-match + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies a substring match on a vCard property or + parameter value. + + + + +Daboo Standards Track [Page 42] + +RFC 6352 CardDAV August 2011 + + + Description: The CARDDAV:text-match XML element specifies text used + for a substring match against the vCard property or parameter + value specified in an address book REPORT request. + + The "collation" attribute is used to select the collation that the + server MUST use for character string matching. In the absence of + this attribute, the server MUST use the "i;unicode-casemap" + collation. + + The "negate-condition" attribute is used to indicate that this + test returns a match if the text matches, when the attribute value + is set to "no", or return a match if the text does not match, if + the attribute value is set to "yes". For example, this can be + used to match components with a CATEGORIES property not set to + PERSON. + + The "match-type" attribute is used to indicate the type of match + operation to use. Possible choices are: + + "equals" - an exact match to the target string + + "contains" - a substring match, matching anywhere within the + target string + + "starts-with" - a substring match, matching only at the start + of the target string + + "ends-with" - a substring match, matching only at the end of + the target string + + Definition: + + + + + + +10.6. CARDDAV:limit XML Element + + Name: limit + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies different types of limits that can be applied to + the results returned by the server. + + + +Daboo Standards Track [Page 43] + +RFC 6352 CardDAV August 2011 + + + Description: The CARDDAV:limit XML element can be used to specify + different types of limits that the client can request the server + to apply to the results returned by the server. Currently, only + the CARDDAV:nresults limit can be used; other types of limit could + be defined in the future. + + Definition: + + + +10.6.1. CARDDAV:nresults XML Element + + Name: nresults + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: Specifies a limit on the number of results returned by the + server. + + Description: The CARDDAV:nresults XML element contains a requested + maximum number of DAV:response elements to be returned in the + response body of a query. The server MAY disregard this limit. + The value of this element is an unsigned integer. + + Definition: + + + + +10.7. CARDDAV:addressbook-multiget XML Element + + Name: addressbook-multiget + + Namespace: urn:ietf:params:xml:ns:carddav + + Purpose: CardDAV report used to retrieve specific address objects + via their URIs. + + Description: See Section 8.7. + + Definition: + + + + + + + +Daboo Standards Track [Page 44] + +RFC 6352 CardDAV August 2011 + + +11. Service Discovery via SRV Records + + [RFC2782] defines a DNS-based service discovery protocol that has + been widely adopted as a means of locating particular services within + a local area network and beyond, using SRV RRs. + + This specification adds two service types for use with SRV records: + + carddav: Identifies a CardDAV server that uses HTTP without TLS + [RFC2818]. + + carddavs: Identifies a CardDAV server that uses HTTP with TLS + [RFC2818]. + + Example: non-TLS service record + + _carddav._tcp SRV 0 1 80 addressbook.example.com. + + Example: TLS service + + _carddavs._tcp SRV 0 1 443 addressbook.example.com. + +12. Internationalization Considerations + + CardDAV allows internationalized strings to be stored and retrieved + for the description of address book collections (see Section 6.2.1). + + The CARDDAV:addressbook-query REPORT (Section 8.6) includes a text + searching option controlled by the CARDDAV:text-match element and + details of character handling are covered in the description of that + element (see Section 10.5.4). + +13. Security Considerations + + HTTP protocol transactions are sent in the clear over the network + unless protection from snooping is negotiated. This can be + accomplished by use of TLS as defined in [RFC2818]. In particular, + if HTTP Basic authentication [RFC2617] is available, the server MUST + allow TLS to be used at the same time, and it SHOULD prevent use of + Basic authentication when TLS is not in use. Clients SHOULD use TLS + whenever possible. + + With the ACL extension [RFC3744] present, WebDAV allows control over + who can access (read or write) any resource on the WebDAV server. In + addition, WebDAV ACL provides for an "inheritance" mechanism, whereby + resources may inherit access privileges from other resources. Often, + the "other" resource is a parent collection of the resource itself. + Servers are able to support address books that are "private" + + + +Daboo Standards Track [Page 45] + +RFC 6352 CardDAV August 2011 + + + (accessible only to the "owner"), "shared" (accessible to the owner + and other specified authenticated users), and "public" (accessible to + any authenticated or unauthenticated users). When provisioning + address books of a particular type, servers MUST ensure that the + correct privileges are applied on creation. In particular, private + and shared address books MUST NOT be accessible by unauthenticated + users (to prevent data from being automatically searched or indexed + by web "crawlers"). + + Clients SHOULD warn users in an appropriate fashion when they copy or + move address data from a private address book to a shared address + book or public address book. Clients SHOULD provide a clear + indication as to which address books are private, shared, or public. + Clients SHOULD provide an appropriate warning when changing access + privileges for a private or shared address book with data so as to + allow unauthenticated users access. + + This specification currently relies on standard HTTP authentication + mechanisms for identifying users. These comprise Basic and Digest + authentication [RFC2617] as well as TLS [RFC2818] using client-side + certificates. + +14. IANA Consideration + + This document uses a URN to describe a new XML namespace conforming + to the registry mechanism described in [RFC3688]. + +14.1. Namespace Registration + + Registration request for the carddav namespace: + + URI: urn:ietf:params:xml:ns:carddav + + Registrant Contact: The IESG + + XML: None - not applicable for namespace registrations. + +15. Acknowledgments + + Thanks go to Lisa Dusseault and Bernard Desruisseaux for their work + on CalDAV, on which CardDAV is heavily based. The following + individuals contributed their ideas and support for writing this + specification: Mike Douglass, Stefan Eissing, Helge Hess, Arnaud + Quillaud, Julian Reschke, Elias Sinderson, Greg Stein, Wilfredo + Sanchez, and Simon Vaillancourt. + + + + + + +Daboo Standards Track [Page 46] + +RFC 6352 CardDAV August 2011 + + +16. References + +16.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC2426] Dawson, F. and T. Howes, "vCard MIME Directory Profile", + RFC 2426, September 1998. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., + Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext + Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999. + + [RFC2617] Franks, J., Hallam-Baker, P., Hostetler, J., Lawrence, S., + Leach, P., Luotonen, A., and L. Stewart, "HTTP + Authentication: Basic and Digest Access Authentication", + RFC 2617, June 1999. + + [RFC2782] Gulbrandsen, A., Vixie, P., and L. Esibov, "A DNS RR for + specifying the location of services (DNS SRV)", RFC 2782, + February 2000. + + [RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, May 2000. + + [RFC3253] Clemm, G., Amsden, J., Ellison, T., Kaler, C., and J. + Whitehead, "Versioning Extensions to WebDAV + (Web Distributed Authoring and Versioning)", RFC 3253, + March 2002. + + [RFC3688] Mealling, M., "The IETF XML Registry", BCP 81, RFC 3688, + January 2004. + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web + Distributed Authoring and Versioning (WebDAV) + Access Control Protocol", RFC 3744, May 2004. + + [RFC4790] Newman, C., Duerst, M., and A. Gulbrandsen, "Internet + Application Protocol Collation Registry", RFC 4790, + March 2007. + + [RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed + Authoring and Versioning (WebDAV)", RFC 4918, June 2007. + + [RFC5051] Crispin, M., "i;unicode-casemap - Simple Unicode Collation + Algorithm", RFC 5051, October 2007. + + + + + +Daboo Standards Track [Page 47] + +RFC 6352 CardDAV August 2011 + + + [RFC5246] Dierks, T. and E. Rescorla, "The Transport Layer Security + (TLS) Protocol Version 1.2", RFC 5246, August 2008. + + [RFC5280] Cooper, D., Santesson, S., Farrell, S., Boeyen, S., + Housley, R., and W. Polk, "Internet X.509 Public Key + Infrastructure Certificate and Certificate Revocation List + (CRL) Profile", RFC 5280, May 2008. + + [RFC5397] Sanchez, W. and C. Daboo, "WebDAV Current Principal + Extension", RFC 5397, December 2008. + + [RFC5689] Daboo, C., "Extended MKCOL for Web Distributed Authoring + and Versioning (WebDAV)", RFC 5689, September 2009. + + [RFC6350] Perreault, S., "vCard Format Specification", RFC 6350, + August 2011. + + [W3C.REC-xml-20081126] + Bray, T., Paoli, J., Sperberg-McQueen, C., Maler, E., and + F. Yergeau, "Extensible Markup Language (XML) 1.0 (Fifth + Edition)", World Wide Web Consortium Recommendation REC- + xml-20081126, November 2008, + . + +16.2. Informative References + + [IMSP] Myers, J., "IMSP - Internet Message Support Protocol", + Work in Progress, June 1995. + + [RFC2244] Newman, C. and J. Myers, "ACAP -- Application + Configuration Access Protocol", RFC 2244, November 1997. + + [RFC4510] Zeilenga, K., "Lightweight Directory Access Protocol + (LDAP): Technical Specification Road Map", RFC 4510, + June 2006. + +Author's Address + + Cyrus Daboo + Apple, Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + EMail: cyrus@daboo.name + URI: http://www.apple.com/ + + + + + +Daboo Standards Track [Page 48] + diff --git a/doc/rfc6638-scheduling-extensions-to-caldav.txt b/doc/rfc6638-scheduling-extensions-to-caldav.txt new file mode 100644 index 0000000..39154d8 --- /dev/null +++ b/doc/rfc6638-scheduling-extensions-to-caldav.txt @@ -0,0 +1,4371 @@ + + + + + + +Internet Engineering Task Force (IETF) C. Daboo +Request for Comments: 6638 Apple Inc. +Updates: 4791, 5546 B. Desruisseaux +Category: Standards Track Oracle +ISSN: 2070-1721 June 2012 + + + Scheduling Extensions to CalDAV + +Abstract + + This document defines extensions to the Calendaring Extensions to + WebDAV (CalDAV) "calendar-access" feature to specify a standard way + of performing scheduling operations with iCalendar-based calendar + components. This document defines the "calendar-auto-schedule" + feature of CalDAV. + +Status of This Memo + + This is an Internet Standards Track document. + + This document is a product of the Internet Engineering Task Force + (IETF). It represents the consensus of the IETF community. It has + received public review and has been approved for publication by the + Internet Engineering Steering Group (IESG). Further information on + Internet Standards is available in Section 2 of RFC 5741. + + Information about the current status of this document, any errata, + and how to provide feedback on it may be obtained at + http://www.rfc-editor.org/info/rfc6638. + +Copyright Notice + + Copyright (c) 2012 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (http://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. Code Components extracted from this document must + include Simplified BSD License text as described in Section 4.e of + the Trust Legal Provisions and are provided without warranty as + described in the Simplified BSD License. + + + + + + +Daboo & Desruisseaux Standards Track [Page 1] + +RFC 6638 CalDAV Scheduling June 2012 + + + This document may contain material from IETF Documents or IETF + Contributions published or made publicly available before November + 10, 2008. The person(s) controlling the copyright in some of this + material may not have granted the IETF Trust the right to allow + modifications of such material outside the IETF Standards Process. + Without obtaining an adequate license from the person(s) controlling + the copyright in such materials, this document may not be modified + outside the IETF Standards Process, and derivative works of it may + not be created outside the IETF Standards Process, except to format + it for publication as an RFC or to translate it into languages other + than English. + +Table of Contents + + 1. Introduction ....................................................5 + 1.1. Terminology ................................................6 + 1.2. Notational Conventions .....................................7 + 1.3. XML Namespaces and Processing ..............................7 + 2. Scheduling Support ..............................................8 + 2.1. Scheduling Outbox Collection ...............................9 + 2.1.1. CALDAV:schedule-outbox-URL Property ................10 + 2.2. Scheduling Inbox Collection ...............................10 + 2.2.1. CALDAV:schedule-inbox-URL Property .................11 + 2.3. Calendaring Reports Extensions ............................12 + 2.4. Additional Principal Properties ...........................12 + 2.4.1. CALDAV:calendar-user-address-set Property ..........12 + 2.4.2. CALDAV:calendar-user-type Property .................13 + 3. Scheduling Operations ..........................................14 + 3.1. Identifying Scheduling Object Resources ...................14 + 3.2. Handling Scheduling Object Resources ......................15 + 3.2.1. Organizer Scheduling Object Resources ..............15 + 3.2.1.1. Create ....................................16 + 3.2.1.2. Modify ....................................17 + 3.2.1.3. Remove ....................................18 + 3.2.2. Attendee Scheduling Object Resources ...............18 + 3.2.2.1. Allowed "Attendee" Changes ................18 + 3.2.2.2. Create ....................................19 + 3.2.2.3. Modify ....................................20 + 3.2.2.4. Remove ....................................21 + 3.2.3. HTTP Methods .......................................21 + 3.2.3.1. PUT .......................................22 + 3.2.3.2. DELETE ....................................22 + 3.2.3.3. COPY ......................................23 + 3.2.3.4. MOVE ......................................24 + + + + + + + +Daboo & Desruisseaux Standards Track [Page 2] + +RFC 6638 CalDAV Scheduling June 2012 + + + 3.2.4. Additional Method Preconditions ....................24 + 3.2.4.1. CALDAV:unique-scheduling-object-resource + Precondition ..............................24 + 3.2.4.2. CALDAV:same-organizer-in-all-components + Precondition ..............................25 + 3.2.4.3. CALDAV:allowed-organizer-scheduling- + object-change Precondition .............25 + 3.2.4.4. CALDAV:allowed-attendee-scheduling- + object-change Precondition .............26 + 3.2.5. DTSTAMP and SEQUENCE Properties ....................26 + 3.2.6. Restrict Recurrence Instances Sent to "Attendees" ..27 + 3.2.7. Forcing the Server to Send a Scheduling Message ....27 + 3.2.8. "Attendee" Participation Status ....................28 + 3.2.9. Schedule Status Values .............................29 + 3.2.10. Avoiding Conflicts when Updating Scheduling Object + Resources .........................................31 + 3.2.10.1. PUT .....................................33 + 3.2.10.2. DELETE, COPY, or MOVE ...................33 + 4. Processing Incoming Scheduling Messages ........................34 + 4.1. Processing "Organizer" Requests, Additions, and + Cancellations .............................................34 + 4.2. Processing "Attendee" Replies .............................35 + 4.3. Default Calendar Collection ...............................35 + 4.3.1. Additional Method Preconditions ....................36 + 4.3.1.1. CALDAV:default-calendar-needed + Precondition ..............................36 + 4.3.1.2. CALDAV:valid-schedule-default-calendar-URL + Precondition ..............................36 + 5. Request for Busy Time Information ..............................37 + 5.1. Status Codes ..............................................38 + 5.2. Additional Method Preconditions ...........................38 + 5.2.1. CALDAV:valid-scheduling-message Precondition .......38 + 5.2.2. CALDAV:valid-organizer Precondition ................39 + 6. Scheduling Privileges ..........................................39 + 6.1. Privileges on Scheduling Inbox Collections ................39 + 6.1.1. CALDAV:schedule-deliver Privilege ..................40 + 6.1.2. CALDAV:schedule-deliver-invite Privilege ...........40 + 6.1.3. CALDAV:schedule-deliver-reply Privilege ............40 + 6.1.4. CALDAV:schedule-query-freebusy Privilege ...........40 + 6.2. Privileges on Scheduling Outbox Collections ...............40 + 6.2.1. CALDAV:schedule-send Privilege .....................41 + 6.2.2. CALDAV:schedule-send-invite Privilege ..............41 + 6.2.3. CALDAV:schedule-send-reply Privilege ...............41 + 6.2.4. CALDAV:schedule-send-freebusy Privilege ............41 + 6.3. Aggregation of Scheduling Privileges ......................42 + + + + + + +Daboo & Desruisseaux Standards Track [Page 3] + +RFC 6638 CalDAV Scheduling June 2012 + + + 7. Additional iCalendar Property Parameters .......................42 + 7.1. Schedule Agent Parameter ..................................42 + 7.2. Schedule Force Send Parameter .............................44 + 7.3. Schedule Status Parameter .................................45 + 8. Additional Message Header Fields ...............................46 + 8.1. Schedule-Reply Request Header .............................46 + 8.2. Schedule-Tag Response Header ..............................46 + 8.3. If-Schedule-Tag-Match Request Header ......................47 + 9. Additional WebDAV Properties ...................................47 + 9.1. CALDAV:schedule-calendar-transp Property ..................47 + 9.2. CALDAV:schedule-default-calendar-URL Property .............48 + 9.3. CALDAV:schedule-tag Property ..............................49 + 10. XML Element Definitions .......................................50 + 10.1. CALDAV:schedule-response XML Element .....................50 + 10.2. CALDAV:response XML Element ..............................50 + 10.3. CALDAV:recipient XML Element .............................50 + 10.4. CALDAV:request-status XML Element ........................51 + 11. Security Considerations .......................................51 + 11.1. Preventing Denial-of-Service Attacks .....................51 + 11.2. Verifying Scheduling Operations ..........................52 + 11.3. Verifying Busy Time Information Requests .................52 + 11.4. Privacy Issues ...........................................53 + 11.5. Mitigation of iTIP Threats ...............................53 + 12. IANA Considerations ...........................................54 + 12.1. Message Header Field Registrations .......................54 + 12.1.1. Schedule-Reply ....................................54 + 12.1.2. Schedule-Tag ......................................54 + 12.1.3. If-Schedule-Tag-Match .............................54 + 12.2. iCalendar Property Parameter Registrations ...............55 + 12.3. iCalendar REQUEST-STATUS Value Registrations .............55 + 12.4. Additional iCalendar Elements Registries .................55 + 12.4.1. Schedule Agent Values Registry ....................56 + 12.4.2. Schedule Force Send Values Registry ...............56 + 13. Acknowledgements ..............................................56 + 14. References ....................................................57 + 14.1. Normative References .....................................57 + 14.2. Informative References ...................................58 + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 4] + +RFC 6638 CalDAV Scheduling June 2012 + + + Appendix A. Scheduling Privileges Summary .........................59 + A.1. Scheduling Inbox Privileges ................................59 + A.2. Scheduling Outbox Privileges ...............................60 + Appendix B. Example Scheduling Operations .........................60 + B.1. Example: "Organizer" Inviting Multiple "Attendees" .........61 + B.2. Example: "Attendee" Receiving an Invitation ................63 + B.3. Example: "Attendee" Replying to an Invitation ..............64 + B.4. Example: "Organizer" Receiving a Reply to an Invitation ....66 + B.5. Example: "Organizer" Requesting Busy Time Information ......69 + B.6. Example: User Attempting to Invite "Attendee" on + Behalf of "Organizer" ......................................71 + B.7. Example: "Attendee" Declining an Instance of a + Recurring Event ............................................72 + B.8. Example: "Attendee" Removing an Instance of a + Recurring Event ............................................75 + +1. Introduction + + This document specifies extensions to the CalDAV "calendar-access" + [RFC4791] feature to enable scheduling of iCalendar-based [RFC5545] + calendar components between calendar users. + + This extension leverages the scheduling methods defined in the + iCalendar Transport-independent Interoperability Protocol (iTIP) + [RFC5546] to permit calendar users to perform scheduling operations + such as schedule, reschedule, respond to scheduling request, or + cancel calendar components, as well as search for busy time + information. However, the following iTIP [RFC5546] features are not + covered: publishing, countering, delegating, refreshing, and + forwarding calendar components, as well as replacing the "Organizer" + of a calendar component. It is expected that future extensions will + be developed to address these. + + This specification defines a client/server scheduling protocol, where + the server is made responsible for sending scheduling messages and + processing incoming scheduling messages. The client operations of + creating, modifying, or deleting a calendar component in a calendar + are enough to trigger the server to deliver the necessary scheduling + messages to the appropriate calendar users. This approach is + sometimes referred to as "implicit scheduling". + + This specification only addresses how scheduling occurs with users on + a single system (i.e., scheduling between CalDAV servers, or some + other calendaring and scheduling system, is not defined). However, + this specification is compatible with servers being able to send or + receive scheduling messages with "external" users (e.g., using the + iCalendar Message-Based Interoperability Protocol (iMIP) [RFC6047]). + + + + +Daboo & Desruisseaux Standards Track [Page 5] + +RFC 6638 CalDAV Scheduling June 2012 + + + Section 3 defines the automated "Scheduling Operations" that allow a + client to store iCalendar data on a CalDAV server, with the server + taking specific actions in response. One of three scheduling + operations can take place -- "create", "modify", or "remove", based + on the HTTP method used for the request -- in addition to a + comparison between any existing and any new iCalendar data. + + Section 4 defines how the server processes scheduling messages sent + as the result of a scheduling operation. + + Section 5 defines how freebusy requests with an immediate response + are accomplished. + + Section 6 defines access control privileges for the scheduling + operations defined in this specification. + + For the majority of the following discussion, scheduling of events + will be discussed. However, scheduling of to-dos is also fully + supported by this specification. + + This specification has been under development for a number of years, + and most current implementations of CalDAV support it. With the + publication of this document, it is expected that all new CalDAV + implementations will support it by default. Interoperability tests + have been performed regularly. Significant issues with incompatible + CalDAV implementations are not anticipated. + +1.1. Terminology + + This specification reuses much of the same terminology as iCalendar + [RFC5545], iTIP [RFC5546], WebDAV [RFC4918], and CalDAV [RFC4791]. + Additional terms used by this specification are as follows: + + Scheduling object resource: A calendar object resource contained in + a calendar collection for which the server will take care of + sending scheduling messages on behalf of the owner of the calendar + collection. + + Organizer scheduling object resource: A scheduling object resource + owned by the "Organizer". + + Attendee scheduling object resource: A scheduling object resource + owned by an "Attendee". + + Scheduling operation: Add, change, or remove operations on a + scheduling object resource for which the server will deliver + scheduling messages to other calendar users. + + + + +Daboo & Desruisseaux Standards Track [Page 6] + +RFC 6638 CalDAV Scheduling June 2012 + + + Scheduling message: A calendar object that describes a scheduling + operation such as schedule, reschedule, reply, or cancel. + + Scheduling Outbox collection: A resource at which busy time + information requests are targeted. + + Scheduling Inbox collection: A collection in which incoming + scheduling messages are delivered. + +1.2. Notational Conventions + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + + The Augmented BNF (ABNF) syntax used by this document to specify the + format definition of new iCalendar elements is defined in [RFC5234]. + + The ABNF syntax used by this document to specify the format + definition of new message header fields to be used with the HTTP/1.1 + protocol is described in Section 2.1 of [RFC2616]. Since this + Augmented BNF uses the basic production rules provided in Section 2.2 + of [RFC2616], these rules apply to this document as well. + + The term "protected" is used in the Conformance field of WebDAV + property definitions as defined in Section 15 of [RFC4918]. + + Calendaring and scheduling roles are referred to in quoted-strings of + text with the first character of each word in uppercase. For + example, "Organizer" refers to a role of a calendar user within the + scheduling protocol defined by [RFC5546]. + +1.3. XML Namespaces and Processing + + This document uses XML DTD fragments ([W3C.REC-xml-20081126], + Section 3.2) as a purely notational convention. WebDAV request and + response bodies cannot be validated by a DTD due to the specific + extensibility rules defined in Section 17 of [RFC4918] and due to the + fact that all XML elements defined by that specification use the XML + namespace name "DAV:". In particular, + + 1. element names use the "DAV:" namespace, + + 2. element ordering is irrelevant unless explicitly stated, + + + + + + + +Daboo & Desruisseaux Standards Track [Page 7] + +RFC 6638 CalDAV Scheduling June 2012 + + + 3. extension elements (elements not already defined as valid child + elements) can be added anywhere, except when explicitly stated + otherwise, and + + 4. extension attributes (attributes not already defined as valid for + this element) can be added anywhere, except when explicitly + stated otherwise. + + The XML elements specified in this document are defined in the + "urn:ietf:params:xml:ns:caldav" XML namespace registered by CalDAV + [RFC4791]. + + When XML element types in the namespaces "DAV:" and + "urn:ietf:params:xml:ns:caldav" are referenced in this document + outside of the context of an XML fragment, the strings "DAV:" and + "CALDAV:" will be prefixed to the element types, respectively. + + This document inherits, and sometimes extends, DTD productions from + Section 14 of [RFC4918]. + + Also note that some CalDAV XML element names are identical to WebDAV + XML element names, though their namespace differs. Care needs to be + taken not to confuse the two sets of names. + +2. Scheduling Support + + A server that supports the features described in this document is + REQUIRED to support the CalDAV "calendar-access" [RFC4791] feature. + Servers include "calendar-auto-schedule" as a field in the DAV + response header from an OPTIONS request on any resource that supports + any scheduling operations, properties, privileges, or methods. + + This specification introduces new collection resource types that are + used to manage scheduling object resources, and scheduling privileges + (as per Section 6), as well as provide scheduling functionality. It + is the server's responsibility to create these collection resources, + and clients have no way to create or delete them. + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 8] + +RFC 6638 CalDAV Scheduling June 2012 + + +2.1. Scheduling Outbox Collection + + A scheduling Outbox collection is used as the target for busy time + information requests, and to manage privileges that apply to outgoing + scheduling requests. + + A scheduling Outbox collection MUST report the DAV:collection and + CALDAV:schedule-outbox XML elements in the value of the DAV: + resourcetype property. The element type declaration for CALDAV: + schedule-outbox is + + + + Example: + + + + + + + A scheduling Outbox collection MUST NOT be a child (at any depth) of + a calendar collection resource. + + The following WebDAV properties specified in CalDAV "calendar-access" + [RFC4791] MAY also be defined on scheduling Outbox collections and + apply to scheduling messages submitted to the scheduling Outbox + collection with the POST method: + + o CALDAV:supported-calendar-component-set + + o CALDAV:supported-calendar-data + + o CALDAV:max-resource-size + + o CALDAV:min-date-time + + o CALDAV:max-date-time + + o CALDAV:max-attendees-per-instance + + The use of child resources in a scheduling Outbox collection is + reserved for future revisions or extensions of this specification. + + The following WebDAV property is defined on principal resources and + used to locate the corresponding Outbox collection for the associated + principal. + + + + + +Daboo & Desruisseaux Standards Track [Page 9] + +RFC 6638 CalDAV Scheduling June 2012 + + +2.1.1. CALDAV:schedule-outbox-URL Property + + Name: schedule-outbox-URL + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identify the URL of the scheduling Outbox collection owned + by the associated principal resource. + + Protected: This property MAY be protected. + + PROPFIND behavior: This property SHOULD NOT be returned by a + PROPFIND DAV:allprop request (as defined in Section 14.2 of + [RFC4918]). + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: This property is needed for a client to determine where + the scheduling Outbox collection of the current user is located so + that sending of scheduling messages can occur. If not present, + then the associated calendar user is not enabled for the sending + of scheduling messages on the server. + + Definition: + + + +2.2. Scheduling Inbox Collection + + A scheduling Inbox collection contains copies of incoming scheduling + messages. These can be requests sent by an "Organizer", or replies + sent by an "Attendee" in response to a request. The scheduling Inbox + collection is also used to manage scheduling privileges. + + A scheduling Inbox collection MUST report the DAV:collection and + CALDAV:schedule-inbox XML elements in the value of the DAV: + resourcetype property. The element type declaration for CALDAV: + schedule-inbox is + + + + Example: + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 10] + +RFC 6638 CalDAV Scheduling June 2012 + + + Scheduling Inbox collections MUST only contain calendar object + resources that obey the restrictions specified in iTIP [RFC5546]. + Consequently, scheduling Inbox collections MUST NOT contain any types + of collection resources. Restrictions defined in Section 4.1 of + CalDAV "calendar-access" [RFC4791] on calendar object resources + contained in calendar collections (e.g., Unique Identifier ("UID") + uniqueness) do not apply to calendar object resources contained in a + scheduling Inbox collection. Thus, multiple calendar object + resources contained in a scheduling Inbox collection can have the + same "UID" property value (i.e., multiple scheduling messages for the + same calendar component). + + A scheduling Inbox collection MUST NOT be a child (at any depth) of a + calendar collection resource. + + The following WebDAV properties specified in CalDAV "calendar-access" + [RFC4791] MAY also be defined on scheduling Inbox collections and + apply to scheduling messages delivered to the collection: + + o CALDAV:supported-calendar-component-set + + o CALDAV:supported-calendar-data + + o CALDAV:max-resource-size + + o CALDAV:min-date-time + + o CALDAV:max-date-time + + o CALDAV:max-instances + + o CALDAV:max-attendees-per-instance + + o CALDAV:calendar-timezone + + The following WebDAV property is defined on principal resources and + used to locate the corresponding Inbox collection for the associated + principal. + +2.2.1. CALDAV:schedule-inbox-URL Property + + Name: schedule-inbox-URL + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identify the URL of the scheduling Inbox collection owned + by the associated principal resource. + + + + +Daboo & Desruisseaux Standards Track [Page 11] + +RFC 6638 CalDAV Scheduling June 2012 + + + Protected: This property MAY be protected. + + PROPFIND behavior: This property SHOULD NOT be returned by a + PROPFIND DAV:allprop request (as defined in Section 14.2 of + [RFC4918]). + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: This property allows a client to determine where the + scheduling Inbox collection of the current user is located so that + processing of scheduling messages can occur. If not present, then + the associated calendar user is not enabled for reception of + scheduling messages on the server. + + Definition: + + + +2.3. Calendaring Reports Extensions + + This specification extends the CALDAV:calendar-query and CALDAV: + calendar-multiget REPORTs to return results for calendar object + resources in scheduling Inbox collections. + + When a CALDAV:calendar-query REPORT includes a time-range query and + targets a scheduling Inbox collection, if any calendar object + resources contain "VEVENT" calendar components that do not include a + "DTSTART" iCalendar property (as allowed by iTIP [RFC5546]) then such + components MUST always match the time-range query test. + + Note that the CALDAV:free-busy-query REPORT is not supported on + scheduling Inbox collections. + +2.4. Additional Principal Properties + + This section defines new properties for WebDAV principal resources as + defined in [RFC3744]. These properties are likely to be protected, + but the server MAY allow them to be written by appropriate users. + +2.4.1. CALDAV:calendar-user-address-set Property + + Name: calendar-user-address-set + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identify the calendar addresses of the associated principal + resource. + + + +Daboo & Desruisseaux Standards Track [Page 12] + +RFC 6638 CalDAV Scheduling June 2012 + + + Protected: This property MAY be protected. + + PROPFIND behavior: This property SHOULD NOT be returned by a + PROPFIND DAV:allprop request (as defined in Section 14.2 of + [RFC4918]). + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: Support for this property is REQUIRED. This property + is needed to map calendar user addresses in iCalendar data to + principal resources and their associated scheduling Inbox and + Outbox collections. In the event that a user has no well-defined + identifier for his calendar user address, the URI of his principal + resource can be used. This property SHOULD be searchable using + the DAV:principal-property-search REPORT. The DAV:principal- + search-property-set REPORT SHOULD identify this property as such. + If not present, then the associated calendar user is not enabled + for scheduling on the server. + + Definition: + + + + Example: + + + mailto:bernard@example.com + mailto:bernard.desruisseaux@example.com + + +2.4.2. CALDAV:calendar-user-type Property + + Name: calendar-user-type + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Identifies the calendar user type of the associated + principal resource. + + Value: Same values allowed for the iCalendar "CUTYPE" property + parameter defined in Section 3.2.3 of [RFC5545]. + + Protected: This property MAY be protected. + + + + + + +Daboo & Desruisseaux Standards Track [Page 13] + +RFC 6638 CalDAV Scheduling June 2012 + + + PROPFIND behavior: This property SHOULD NOT be returned by a + PROPFIND DAV:allprop request (as defined in Section 14.2 of + [RFC4918]). + + COPY/MOVE behavior: This property value SHOULD be preserved in COPY + and MOVE operations. + + Description: Clients can query principal resources in order to look + up "Attendees" available on the server. When doing this, it is + useful to know, or restrict the query to, certain types of + calendar users (e.g., only search for "people", or only search for + "rooms"). This property MAY be defined on principal resources to + indicate the type of calendar user associated with the principal + resource. Its value is the same as the iCalendar "CUTYPE" + property parameter that can be used on "ATTENDEE" properties. + This property SHOULD be searchable using the DAV:principal- + property-search REPORT. The DAV:principal-search-property-set + REPORT SHOULD identify this property as such. + + Definition: + + + + Example: + + INDIVIDUAL< + /C:calendar-user-type> + +3. Scheduling Operations + + When a calendar object resource is created, modified, or removed from + a calendar collection, the server examines the calendar data and + checks to see whether the data represents a scheduling object + resource. If it does, the server will automatically attempt to + deliver a scheduling message to the appropriate calendar users. + Several types of scheduling operations can occur in this case, + equivalent to iTIP "REQUEST", "REPLY", "CANCEL", and "ADD" + operations. + +3.1. Identifying Scheduling Object Resources + + Calendar object resources on which the server performs scheduling + operations are referred to as scheduling object resources. There are + two types of scheduling object resources: organizer scheduling object + resources, and attendee scheduling object resources. + + + + + +Daboo & Desruisseaux Standards Track [Page 14] + +RFC 6638 CalDAV Scheduling June 2012 + + + A calendar object resource is considered to be a valid organizer + scheduling object resource if the "ORGANIZER" iCalendar property is + present and set in all the calendar components to a value that + matches one of the calendar user addresses of the owner of the + calendar collection. + + A calendar object resource is considered to be a valid attendee + scheduling object resource if the "ORGANIZER" iCalendar property is + present and set in all the calendar components to the same value and + doesn't match one of the calendar user addresses of the owner of the + calendar collection, and if at least one of the "ATTENDEE" iCalendar + property values matches one of the calendar user addresses of the + owner of the calendar collection. + + The creation of attendee scheduling object resources is typically + done by the server, with the resource being created in an appropriate + calendar collection (see Section 4.3). + +3.2. Handling Scheduling Object Resources + + The server's behavior when processing a scheduling object resource + depends on whether it is owned by the "Organizer" or an "Attendee" + specified in the calendar data. + +3.2.1. Organizer Scheduling Object Resources + + An "Organizer" can create, modify, or remove a scheduling object + resource, subject to access privileges, preconditions, and the + restrictions defined in Section 4.1 of [RFC4791]. These operations + are each described next, and how they are invoked via HTTP requests + is described in Section 3.2.3. + + The "Organizer" of a calendar component can also be an "Attendee" of + that calendar component. In such cases, the server MUST NOT send a + scheduling message to the "Attendee" that matches the "Organizer". + + The server SHOULD reject any attempt to set the "PARTSTAT" iCalendar + property parameter value of the "ATTENDEE" iCalendar property of + other users in the calendar object resource to a value other than + "NEEDS-ACTION" if the "SCHEDULE-AGENT" property parameter value is + not present or set to the value "SERVER". + + The server MAY reject attempts to create a scheduling object resource + that specifies a "UID" property value already specified in a + scheduling object resource contained in another calendar collection + of the "Organizer". + + + + + +Daboo & Desruisseaux Standards Track [Page 15] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.1.1. Create + + When an "Organizer" creates a scheduling object resource, the server + MUST inspect each "ATTENDEE" property to determine whether to send a + scheduling message. The table below indicates the appropriate iTIP + method used by the server, taking into account any "SCHEDULE-AGENT" + property parameter (see Section 7.1) specified on each "ATTENDEE" + property. + + +------------------+-------------+ + | SCHEDULE-AGENT | iTIP METHOD | + +------------------+-------------+ + | SERVER (default) | REQUEST | + | | | + | CLIENT | -- | + | | | + | NONE | -- | + +------------------+-------------+ + + "SCHEDULE-STATUS" iCalendar property parameters are added or changed + on "ATTENDEE" iCalendar properties in the scheduling object resource + being created as described in Section 7.3, with the value set as + described in Section 3.2.9. This will result in the created calendar + object resource differing from the calendar data sent in the HTTP + request. As a result, clients MAY reload the calendar data from the + server in order to update to the new server-generated state + information. + + The server MUST add a "SCHEDULE-STATUS" iCalendar property parameter + (see Section 7.3) to the "ATTENDEE" iCalendar property in the + scheduling object resource being created, and set its value as + described in Section 3.2.9. This will result in the created calendar + object resource differing from the calendar data sent in the HTTP + request. As a result, clients MAY reload the calendar data from the + server in order to update to the new server-generated state + information. Servers MUST NOT set the "SCHEDULE-STATUS" property + parameter on the "ATTENDEE" property of "Attendees" for which it did + not attempt to deliver a scheduling message. + + The server MUST return an error with the CALDAV:allowed-organizer- + scheduling-object-change precondition code (Section 3.2.4.3) when the + "Organizer" attempts to change the iCalendar data in a manner that is + forbidden. + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 16] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.1.2. Modify + + When an "Organizer" modifies a scheduling object resource, the server + MUST inspect each "ATTENDEE" property in both the original and + modified iCalendar data on a per-instance basis to determine whether + to send a scheduling message. The table below indicates the + appropriate iTIP method used by the server, taking into account any + "SCHEDULE-AGENT" property parameter (see Section 7.1) specified on + each "ATTENDEE" property. The values "SERVER", "CLIENT", and "NONE" + in the top and left titles of the table refer to the "SCHEDULE-AGENT" + parameter value of the "ATTENDEE" property, and the values "" + and "" are used to cover the cases where the "ATTENDEE" + property is not present (Original) or is being removed (Modified). + + +---------------+-----------------------------------------------+ + | | Modified | + | +-----------+-----------+-----------+-----------+ + | | | SERVER | CLIENT | NONE | + | | | (default) | | | + +===+===========+===========+===========+===========+===========+ + | | | -- | REQUEST / | -- | -- | + | O | | | ADD | | | + | r +-----------+-----------+-----------+-----------+-----------+ + | i | SERVER | CANCEL | REQUEST | CANCEL | CANCEL | + | g | (default) | | | | | + | i +-----------+-----------+-----------+-----------+-----------+ + | n | CLIENT | -- | REQUEST / | -- | -- | + | a | | | ADD | | | + | l +-----------+-----------+-----------+-----------+-----------+ + | | NONE | -- | REQUEST / | -- | -- | + | | | | ADD | | | + +---+-----------+-----------+-----------+-----------+-----------+ + + "SCHEDULE-STATUS" iCalendar property parameters are added or changed + on "ATTENDEE" iCalendar properties in the scheduling object resource + being modified as described in Section 7.3, with the value set as + described in Section 3.2.9. This will result in the created calendar + object resource differing from the calendar data sent in the HTTP + request. As a result, clients MAY reload the calendar data from the + server in order to update to the new server-generated state + information. + + The server MUST return an error with the CALDAV:allowed-organizer- + scheduling-object-change precondition code (Section 3.2.4.3) when the + "Organizer" attempts to change the iCalendar data in a manner that is + forbidden. + + + + + +Daboo & Desruisseaux Standards Track [Page 17] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.1.3. Remove + + When an "Organizer" removes a scheduling object resource, the server + MUST inspect each "ATTENDEE" property to determine whether to send a + scheduling message. The table below indicates the appropriate iTIP + method used by the server, taking into account any "SCHEDULE-AGENT" + property parameter (see Section 7.1) specified on each "ATTENDEE" + property. + + +------------------+-------------+ + | SCHEDULE-AGENT | iTIP METHOD | + +------------------+-------------+ + | SERVER (default) | CANCEL | + | | | + | CLIENT | -- | + | | | + | NONE | -- | + +------------------+-------------+ + +3.2.2. Attendee Scheduling Object Resources + + An "Attendee" can create, modify, or remove a scheduling object + resource. These operations are each described next, and how they are + invoked via HTTP requests is described in Section 3.2.3. + +3.2.2.1. Allowed "Attendee" Changes + + "Attendees" are allowed to make some changes to a scheduling object + resource, though key properties such as start time, end time, + location, and summary are typically under the control of the + "Organizer". + + Servers MUST allow "Attendees" to make the following iCalendar data + changes, subject to other restrictions, such as access privileges and + preconditions: + + 1. change their own "PARTSTAT" iCalendar property parameter value. + + 2. add, modify, or remove any "TRANSP" iCalendar properties. + + 3. add, modify, or remove any "PERCENT-COMPLETE" iCalendar + properties. + + 4. add, modify, or remove any "COMPLETED" iCalendar properties. + + 5. add, modify, or remove any "VALARM" iCalendar components. + + + + + +Daboo & Desruisseaux Standards Track [Page 18] + +RFC 6638 CalDAV Scheduling June 2012 + + + 6. add, modify, or remove the "CALSCALE" iCalendar property within + the top-level "VCALENDAR" component. + + 7. modify the "PRODID" iCalendar property within the top-level + "VCALENDAR" component. + + 8. add "EXDATE" iCalendar properties and possibly remove components + for overridden recurrence instances. + + 9. add, modify, or remove any "CREATED", "DTSTAMP", and + "LAST-MODIFIED" iCalendar properties. + + 10. add, modify, or remove "SCHEDULE-STATUS" iCalendar property + parameters on "ATTENDEE" properties that have a "SCHEDULE-AGENT" + parameter set to "CLIENT". + + 11. add new components to represent overridden recurrence instances, + provided the only changes to the recurrence instance follow the + rules above. + + The server MUST return an error with the CALDAV:allowed-attendee- + scheduling-object-change precondition code (Section 3.2.4.4) when the + "Attendee" attempts to change the iCalendar data in a manner + forbidden by the server. + +3.2.2.2. Create + + Typically, an "Attendee" does not create scheduling object resources, + as scheduling messages delivered to him on the server are + automatically processed by the server and placed on one of his + calendars (see Section 4). However, in some cases, a scheduling + message can get delivered directly to the client (e.g., via email + [RFC6047]), and the "Attendee" might wish to store that on the + server. In that case, the client creates a scheduling object + resource in a calendar belonging to the "Attendee". It can then set + the "SCHEDULE-AGENT" iCalendar property parameter on all "ORGANIZER" + iCalendar properties in the resource to determine how the server + treats the resource. The value of the "SCHEDULE-AGENT" iCalendar + property parameter on all "ORGANIZER" iCalendar properties MUST be + the same. + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 19] + +RFC 6638 CalDAV Scheduling June 2012 + + + +----------------+--------------------------------------------------+ + | SCHEDULE-AGENT | Action | + +----------------+--------------------------------------------------+ + | SERVER | The server will attempt to process changes to | + | (default) | the resource using the normal rules for attendee | + | | scheduling object resources. | + | | | + | CLIENT | The server does no special processing of the | + | | resource. The client is assumed to be handling | + | | "Attendee" replies, etc. | + | | | + | NONE | The server does no special processing of the | + | | resource. | + +----------------+--------------------------------------------------+ + + "SCHEDULE-STATUS" iCalendar property parameters are added or changed + on "ORGANIZER" iCalendar properties in the scheduling object resource + being created as described in Section 7.3, with the value set as + described in Section 3.2.9. + +3.2.2.3. Modify + + When a scheduling object resource is modified by an "Attendee", the + server's behavior depends on the value of the "SCHEDULE-AGENT" + iCalendar property parameter on the "ORGANIZER" iCalendar properties: + + +----------------+--------------------------------------------------+ + | SCHEDULE-AGENT | Action | + +----------------+--------------------------------------------------+ + | SERVER | The server will attempt to process the update | + | (default) | using the behavior listed below. | + | | | + | CLIENT | The server does no special processing of the | + | | resource. The client is assumed to be handling | + | | any "Attendee" replies, etc. | + | | | + | NONE | The server does no special processing of the | + | | resource. | + +----------------+--------------------------------------------------+ + + The server will inspect the changes by comparing the new scheduling + object resource with the existing scheduling object resource. + + If the "Attendee" changes one or more "PARTSTAT" iCalendar property + values on any component, or adds an overridden component with a + changed "PARTSTAT" property, then the server MUST deliver an iTIP + "REPLY" scheduling message to the "Organizer" to indicate the new + participation status of the "Attendee". + + + +Daboo & Desruisseaux Standards Track [Page 20] + +RFC 6638 CalDAV Scheduling June 2012 + + + If the "Attendee" adds an "EXDATE" property value to effectively + remove a recurrence instance, the server MUST deliver an iTIP "REPLY" + scheduling message to the "Organizer" to indicate that the "Attendee" + has declined the instance. + + "SCHEDULE-STATUS" iCalendar property parameters are added or changed + on "ORGANIZER" iCalendar properties in the scheduling object resource + being modified as described in Section 7.3, with the value set as + described in Section 3.2.9. This will result in the updated calendar + object resource differing from the calendar data sent in the HTTP + request. As a result, clients MAY reload the calendar data from the + server in order to update to the new server-generated state + information. + +3.2.2.4. Remove + + When a scheduling object resource is removed by an "Attendee", the + server's behavior depends on the value of the "SCHEDULE-AGENT" + iCalendar property parameter on the "ORGANIZER" iCalendar properties: + + +----------------+--------------------------------------------------+ + | SCHEDULE-AGENT | Action | + +----------------+--------------------------------------------------+ + | SERVER | The server will attempt to process the removal, | + | (default) | taking into account any "Schedule-Reply" request | + | | header as per Section 8.1. | + | | | + | CLIENT | The server does no special processing of the | + | | resource. The client is assumed to be handling | + | | any "Attendee" replies, etc. | + | | | + | NONE | The server does no special processing of the | + | | resource. | + +----------------+--------------------------------------------------+ + +3.2.3. HTTP Methods + + This section describes how the use of various HTTP [RFC2616] and + WebDAV [RFC4918] methods on a scheduling object resource will cause a + create, modify, or remove operation on that resource as described + above. The use of these methods is subject to the restrictions in + [RFC4791], in addition to what is described below. + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 21] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.3.1. PUT + + When the server receives a PUT method request, it MUST execute the + following operations, provided all appropriate preconditions are met: + + +------------------------+--------------------------+---------------+ + | Existing Destination | Resulting Destination | Server | + | Resource | Resource | Operation | + +------------------------+--------------------------+---------------+ + | None | Calendar object resource | None | + | | | | + | None | Scheduling object | Create | + | | resource | | + | | | | + | Calendar object | Calendar object resource | None | + | resource | | | + | | | | + | Calendar object | Scheduling object | Create | + | resource | resource | | + | Scheduling object | Calendar object resource | Remove | + | resource | | | + | | | | + | Scheduling object | Scheduling object | Modify | + | resource | resource | | + +------------------------+--------------------------+---------------+ + +3.2.3.2. DELETE + + When the server receives a DELETE method request targeted at a + scheduling object resource, it MUST execute the Remove operation. + + When the server receives a DELETE method request targeted at a + calendar collection, it MUST execute the Remove operation on all + scheduling object resources contained in the calendar collection. + + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 22] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.3.3. COPY + + When the server receives a COPY method request, it MUST execute the + following operations based on the source and destination collections + in the request: + + +-----------------------+------------------------+------------------+ + | Source Collection | Destination Collection | Server Operation | + +-----------------------+------------------------+------------------+ + | Non-calendar | Non-calendar | None | + | collection | collection | | + | | | | + | Non-calendar | Calendar collection | (1) | + | collection | | | + | | | | + | Calendar collection | Non-calendar | None | + | | collection | | + | | | | + | Calendar collection | Calendar collection | (2) | + +-----------------------+------------------------+------------------+ + + Note (1): The rules in Section 3.2.3.1 are applied for the + destination of the COPY request. + + Note (2): The server MAY reject this as per Section 3.2.4.1; + otherwise, None. + + The behavior of a COPY method request on a calendar collection is + undefined. + + + + + + + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 23] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.3.4. MOVE + + When the server receives a MOVE method request, it MUST execute the + following operations based on the source and destination collections + in the request: + + +-----------------------+------------------------+------------------+ + | Source Collection | Destination Collection | Server Operation | + +-----------------------+------------------------+------------------+ + | Non-calendar | Non-calendar | None | + | collection | collection | | + | | | | + | Non-calendar | Calendar collection | (1) | + | collection | | | + | | | | + | Calendar collection | Non-calendar | (2) | + | | collection | | + | | | | + | Calendar collection | Calendar collection | None | + +-----------------------+------------------------+------------------+ + + Note (1): The rules in Section 3.2.3.1 are applied for the + destination of the MOVE request. + + Note (2): The rules in Section 3.2.3.2 are applied for the source of + the MOVE request. + + The behavior of a MOVE method request on a calendar collection is + undefined. + +3.2.4. Additional Method Preconditions + + This specification defines method preconditions (see Section 16 of + WebDAV [RFC4918]), in addition to those in [RFC4791], to provide + machine-parseable information in error responses. + +3.2.4.1. CALDAV:unique-scheduling-object-resource Precondition + + Name: unique-scheduling-object-resource + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: PUT, COPY, and MOVE + + Use with: 403 Forbidden + + + + + + +Daboo & Desruisseaux Standards Track [Page 24] + +RFC 6638 CalDAV Scheduling June 2012 + + + Purpose: (precondition) -- Servers MAY reject requests to create a + scheduling object resource with an iCalendar "UID" property value + already in use by another scheduling object resource owned by the + same user in other calendar collections. Servers SHOULD report + the URL of the scheduling object resource that is already making + use of the same "UID" property value in the DAV:href element. + + Definition: + + + + Example: + + + /home/bernard/calendars/personal/abc123.ics + + +3.2.4.2. CALDAV:same-organizer-in-all-components Precondition + + Name: same-organizer-in-all-components + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: PUT, COPY, and MOVE + + Use with: 403 Forbidden + + Purpose: (precondition) -- All the calendar components in a + scheduling object resource MUST contain the same "ORGANIZER" + property value when present. + + Definition: + + + +3.2.4.3. CALDAV:allowed-organizer-scheduling-object-change Precondition + + Name: allowed-organizer-scheduling-object-change + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: PUT, COPY, and MOVE + + Use with: 403 Forbidden + + + + + + +Daboo & Desruisseaux Standards Track [Page 25] + +RFC 6638 CalDAV Scheduling June 2012 + + + Purpose: (precondition) -- Servers MAY impose restrictions on + modifications allowed by an "Organizer". For instance, servers + MAY prevent the "Organizer" from setting the "PARTSTAT" property + parameter to a value other than "NEEDS-ACTION" if the + corresponding "ATTENDEE" property has the "SCHEDULE-AGENT" + property parameter set to "SERVER", or does not have the + "SCHEDULE-AGENT" property parameter. See Section 3.2.1. + + Definition: + + + +3.2.4.4. CALDAV:allowed-attendee-scheduling-object-change Precondition + + Name: allowed-attendee-scheduling-object-change + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: PUT, COPY, and MOVE + + Use with: 403 Forbidden + + Purpose: (precondition) -- Servers MAY impose restrictions on + modifications allowed by an "Attendee", subject to the allowed + changes specified in Section 3.2.2.1. + + Definition: + + + +3.2.5. DTSTAMP and SEQUENCE Properties + + The server MUST ensure that a "DTSTAMP" iCalendar property is present + and set the value to the UTC time that the scheduling message was + generated (as required by iCalendar). + + The server MUST ensure that for each type of scheduling operation, + the "SEQUENCE" iCalendar property value is updated as per iTIP + [RFC5546]. + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 26] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.6. Restrict Recurrence Instances Sent to "Attendees" + + Servers MUST ensure that "Attendees" only get information about + recurrence instances that explicitly include them as an "Attendee", + when delivering scheduling messages for recurring calendar + components. + + For example, if an "Attendee" is invited to only a single instance of + a recurring event, the organizer scheduling object resource will + contain an overridden instance in the form of a separate calendar + component. That separate calendar component will include the + "ATTENDEE" property referencing the "one-off" "Attendee". That + "Attendee" will not be listed in any other calendar components in the + scheduling object resource. Any scheduling messages delivered to the + "Attendee" will only contain information about this overridden + instance. + + As another example, an "Attendee" could be excluded from one instance + of a recurring event. In that case, the organizer scheduling object + resource will include an overridden instance with an "ATTENDEE" list + that does not include the "Attendee" being excluded. Any scheduling + messages delivered to the "Attendee" will not specify the overridden + instance but rather will include an "EXDATE" property in the "master" + component that defines the recurrence set. + +3.2.7. Forcing the Server to Send a Scheduling Message + + The iCalendar property parameter "SCHEDULE-FORCE-SEND", defined in + Section 7.2, can be used by a calendar user to force the server to + send a scheduling message to an "Attendee" or the "Organizer" in a + situation where the server would not normally send a scheduling + message. For instance, an "Organizer" could use this property + parameter to request an "Attendee" that previously declined an + invitation to reconsider his participation status without being + forced to modify the event. + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 27] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.8. "Attendee" Participation Status + + This section specifies additional requirements on the handling of the + "PARTSTAT" property parameter when the "SCHEDULE-AGENT" property + parameter on the corresponding "ATTENDEE" property is set to the + value "SERVER" or is not present. + + A reschedule occurs when any "DTSTART", "DTEND", "DURATION", "DUE", + "RRULE", "RDATE", or "EXDATE" property changes in a calendar + component such that existing recurrence instances are impacted by the + changes, as shown in the table below. Servers MUST reset the + "PARTSTAT" property parameter value of all "ATTENDEE" properties, + except the one that corresponds to the "Organizer", to "NEEDS-ACTION" + for each calendar component change that causes any instance to be + rescheduled. + + +-----------+-------------------------------------------------------+ + | Property | Server Action | + +-----------+-------------------------------------------------------+ + | DTSTART, | Any change to these properties results in "PARTSTAT" | + | DTEND, | being set to "NEEDS-ACTION". | + | DURATION, | | + | DUE | | + | | | + | RRULE | A change to or addition of this property that results | + | | in the addition of new recurring instances or a | + | | change in time for existing recurring instances | + | | results in "PARTSTAT" being reset to "NEEDS-ACTION" | + | | on each affected component. | + | | | + | RDATE | A change to or addition of this property that results | + | | in the addition of new recurring instances or a | + | | change in time for existing recurring instances | + | | results in "PARTSTAT" being reset to "NEEDS-ACTION" | + | | on each affected component. | + | | | + | EXDATE | A change to or removal of this property that results | + | | in the reinstatement of recurring instances results | + | | in "PARTSTAT" being set to "NEEDS-ACTION" on each | + | | affected component. | + +-----------+-------------------------------------------------------+ + + The server MAY allow the "Organizer's" client to change an + "Attendee's" "PARTSTAT" property parameter value to "NEEDS-ACTION" at + any other time (e.g., when the "LOCATION" property value changes, an + "Organizer" might wish to re-invite "Attendees" who might be impacted + by the change). + + + + +Daboo & Desruisseaux Standards Track [Page 28] + +RFC 6638 CalDAV Scheduling June 2012 + + +3.2.9. Schedule Status Values + + When scheduling with an "Attendee", there are two types of status + information that can be returned during the operation. The first + type of status information is a "delivery" status that indicates + whether the scheduling message from the "Organizer" to the "Attendee" + was delivered or not, or what the current status of delivery is. The + second type of status information is a "reply" status corresponding + to the "Attendee's" own "REQUEST-STATUS" information from the + scheduling message reply that is sent back to the "Organizer". + + Similarly, when an "Attendee" sends a reply back to the "Organizer", + there will be "delivery" status information for the scheduling + message sent to the "Organizer". However, there is no + "REQUEST-STATUS" sent back by the "Organizer", so there is no + equivalent of the "reply" status as per scheduling messages to + "Attendees". + + The "delivery" status information on an "ORGANIZER" or "ATTENDEE" + iCalendar property is conveyed in the "SCHEDULE-STATUS" property + parameter value (Section 7.3). The status code value for "delivery" + status can be one of the following: + + +----------+--------------------------------------------------------+ + | Delivery | Description | + | Status | | + | Code | | + +----------+--------------------------------------------------------+ + | 1.0 | The scheduling message is pending. That is, the | + | | server is still in the process of sending the message. | + | | The status code value can be expected to change once | + | | the server has completed its sending and delivery | + | | attempts. | + | | | + | 1.1 | The scheduling message has been successfully sent. | + | | However, the server does not have explicit information | + | | about whether the scheduling message was successfully | + | | delivered to the recipient. This state can occur with | + | | "store and forward" style scheduling protocols such as | + | | iMIP [RFC6047] (iTIP using email). | + | | | + | 1.2 | The scheduling message has been successfully | + | | delivered. | + | | | + + + + + + + +Daboo & Desruisseaux Standards Track [Page 29] + +RFC 6638 CalDAV Scheduling June 2012 + + + | 3.7 | The scheduling message was not delivered because the | + | | server did not recognize the calendar user address as | + | | a valid calendar user. Note that this code applies to | + | | both "Organizer" and "Attendee" calendar user | + | | addresses. | + | | | + | 3.8 | The scheduling message was not delivered due to | + | | insufficient privileges. Note that this code applies | + | | to privileges granted by both the "Organizer" and | + | | "Attendee" calendar users. | + | | | + | 5.1 | The scheduling message was not delivered because the | + | | server could not complete delivery of the message. | + | | This is likely due to a temporary failure, and the | + | | originator can try to send the message again at a | + | | later time. | + | | | + | 5.2 | The scheduling message was not delivered because the | + | | server was not able to find a way to deliver the | + | | message. This is likely a permanent failure, and the | + | | originator ought not try to send the message again, at | + | | least without verifying/correcting the calendar user | + | | address of the recipient. | + | | | + | 5.3 | The scheduling message was not delivered and was | + | | rejected because scheduling with that recipient is not | + | | allowed. This is likely a permanent failure, and the | + | | originator ought not try to send the message again. | + +----------+--------------------------------------------------------+ + + The status code for "reply" status can be any of the valid iTIP + [RFC5546] "REQUEST-STATUS" values. + + The 1.xx "REQUEST-STATUS" codes are new. This specification modifies + item (2) of Section 3.6 of [RFC5546] by adding the following + restriction: + + For a 1.xx code, all components MUST have exactly the same code. + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 30] + +RFC 6638 CalDAV Scheduling June 2012 + + + Definition of the new 1.xx codes is as follows: + +3.2.9.1. Status Code 1.0 + + Status Code: 1.0 + + Status Description: Pending. + + Status Exception Data: None. + + Description: Delivery of the iTIP message is pending. + +3.2.9.2. Status Code 1.1 + + Status Code: 1.1 + + Status Description: Sent. + + Status Exception Data: None. + + Description: The iTIP message has been sent, though no information + about successful delivery is known. + +3.2.9.3. Status Code 1.2 + + Status Code: 1.2 + + Status Description: Delivered. + + Status Exception Data: None. + + Description: The iTIP message has been sent and delivered. + +3.2.10. Avoiding Conflicts when Updating Scheduling Object Resources + + Scheduling object resources on the server might change frequently as + "Attendees" change their participation status, triggering updates to + the "Organizer", and refreshes of other "Attendees'" copies of the + scheduling object resource. This can lead to an "inconsequential" + change to a calendar user's data -- one that does not directly impact + the user's own participation status. When this occurs, clients have + to reload calendar data and reconcile with changes being made by + calendar users. To avoid the need for this, the server can instead + merge calendar data changes from a client with changes made as a + result of a scheduling operation carried out by some other calendar + user. + + + + + +Daboo & Desruisseaux Standards Track [Page 31] + +RFC 6638 CalDAV Scheduling June 2012 + + + This specification introduces a new WebDAV resource property CALDAV: + schedule-tag with a corresponding response header "Schedule-Tag", and + a new "If-Schedule-Tag-Match" request header to allow client changes + to be appropriately merged with server changes in the case where the + changes on the server were the result of an "inconsequential" + scheduling message update (one that simply updates the status + information of "Attendees" due to a reply from another "Attendee"). + + Servers MUST automatically resolve conflicts with "inconsequential" + changes done to scheduling object resources when the "If-Schedule- + Tag-Match" request header is specified. The If-Schedule-Tag-Match + request header applies only to the Request-URI, and not to the + destination of a COPY or MOVE. + + A response to any successful GET or PUT request targeting a + scheduling object resource MUST include a Schedule-Tag response + header with the value set to the same value as the CALDAV:schedule- + tag WebDAV property of the resource. + + A response to any successful COPY or MOVE request that specifies a + Destination request header targeting a scheduling object resource + MUST include a Schedule-Tag response header with the value set to the + same value as the CALDAV:schedule-tag WebDAV property of the + destination resource. + + Clients SHOULD use the If-Schedule-Tag-Match header on requests that + update scheduling object resources, instead of HTTP ETag-based + precondition tests (e.g., If-Match). Normal ETag-based precondition + tests are used in all other cases, e.g., for synchronization. + + The value of the CALDAV:schedule-tag property changes according to + these rules: + + o For an "Organizer's" copy of a scheduling object resource: + + 1. The server MUST NOT change the CALDAV:schedule-tag property + value when the scheduling object resource is updated as the + result of automatically processing a scheduling message reply + from an "Attendee". For instance, when an "Attendee" replies + to the "Organizer", the CALDAV:schedule-tag property is + unchanged after the "Organizer's" scheduling object resource + has been automatically updated by the server with the + "Attendee's" new participation status. + + 2. The server MUST change the CALDAV:schedule-tag property value + when the scheduling object resource is changed directly via an + HTTP request (e.g., PUT, COPY, or MOVE). + + + + +Daboo & Desruisseaux Standards Track [Page 32] + +RFC 6638 CalDAV Scheduling June 2012 + + + o For an "Attendee's" copy of a scheduling object resource: + + 1. The server MUST change the CALDAV:schedule-tag property value + when the scheduling object resource is changed as the result + of processing a scheduling message update from an "Organizer" + that contains changes other than just the participation status + of "Attendees". + + 2. The server MUST NOT change the CALDAV:schedule-tag property + value when the scheduling object resource is changed as the + result of processing a scheduling message update from an + "Organizer" that only specifies changes in the participation + status of "Attendees". For instance, when "Attendee" "A" + replies to "Organizer" "O", and "Attendee" "B" receives a + scheduling message update from "Organizer" "O" with the new + participation status of "Attendee" "A", the CALDAV:schedule- + tag property of "Attendee" "B"'s scheduling object resource + would remain the same. + + 3. The server MUST change the CALDAV:schedule-tag property value + when the scheduling object resource is changed directly via an + HTTP request (e.g., PUT, COPY, or MOVE). + +3.2.10.1. PUT + + Clients MAY use the If-Schedule-Tag-Match request header to do a PUT + request that ensures that "inconsequential" changes on the server do + not result in a precondition error. The value of the request header + is set to the last Schedule-Tag value received for the resource being + modified. If the value of the If-Schedule-Tag-Match header matches + the current value of the CALDAV:schedule-tag property, the server + MUST take any "ATTENDEE" property changes for all "Attendees" other + than the owner of the scheduling object resource and apply those to + the new resource being stored. Otherwise, the server MUST fail the + request with a 412 Precondition Failed status code. + +3.2.10.2. DELETE, COPY, or MOVE + + Clients MAY use the If-Schedule-Tag-Match request header to do a + DELETE, COPY, or MOVE request that ensures that "inconsequential" + changes on the server do not result in a precondition error. The + value of the request header is set to the last Schedule-Tag value + received for the resource being deleted. If the value of the + If-Schedule-Tag-Match header matches the current value of the CALDAV: + schedule-tag property, the server performs the normal DELETE, COPY, + or MOVE request processing for the resource. Otherwise, the server + MUST fail the request with a 412 Precondition Failed status code. + + + + +Daboo & Desruisseaux Standards Track [Page 33] + +RFC 6638 CalDAV Scheduling June 2012 + + +4. Processing Incoming Scheduling Messages + + Scheduling operations can cause the delivery of a scheduling message + into an "Organizer's" or "Attendee's" scheduling Inbox collection. + Servers MUST automatically process incoming scheduling messages using + the rules defined by [RFC5546], by creating or updating the + corresponding scheduling object resources on calendars owned by the + owner of the scheduling Inbox collection. In addition, the + scheduling message is stored in the scheduling Inbox collection as an + indicator to the client that a scheduling operation has taken place. + Scheduling messages are typically removed from the scheduling Inbox + collection by the client once the calendar user has acknowledged the + change. + + The server MUST take into account privileges on the scheduling Inbox + collection when processing incoming scheduling messages, to determine + whether delivery of the scheduling message is allowed. Privileges on + calendars containing any matching scheduling object resource are not + considered in this case (i.e., a schedule message from another user + can cause modifications to resources in calendar collections that the + other user would not normally have read or write access to). + Additionally, servers MUST take into account any scheduling Inbox + collection preconditions (see Section 2.2) when delivering the + scheduling message, and MUST take into account the similar + preconditions on any calendar collection that contains, or would + contain, the corresponding scheduling object resource. + +4.1. Processing "Organizer" Requests, Additions, and Cancellations + + For a scheduling message sent by an "Organizer", the server first + tries to locate a corresponding scheduling object resource belonging + to the "Attendee". If no matching scheduling object resource exists, + the server treats the scheduling message as a new message; otherwise, + it is treated as an update. + + In the case of a new message, the server processes the scheduling + message and creates a new scheduling object resource as per + Section 4.3. + + In the case of an update, the server processes the scheduling message + and updates the matching scheduling object resource belonging to the + "Attendee" to reflect the changes sent by the "Organizer". + + In each case, the scheduling message MUST only appear in the + "Attendee's" scheduling Inbox collection once all automatic + processing has been done. + + + + + +Daboo & Desruisseaux Standards Track [Page 34] + +RFC 6638 CalDAV Scheduling June 2012 + + +4.2. Processing "Attendee" Replies + + For a scheduling message reply sent by an "Attendee", the server + first locates the corresponding scheduling object resource belonging + to the "Organizer". If the corresponding scheduling object resource + cannot be found, the server SHOULD ignore the scheduling message. + + The server MUST then update the "PARTSTAT" iCalendar property + parameter value of each "ATTENDEE" iCalendar property in the + scheduling object resource to match the changes indicated in the + reply (taking into account the fact that an "Attendee" could have + created a new overridden iCalendar component to indicate different + participation status on one or more instances of a recurring event). + + The server MUST also update or add the "SCHEDULE-STATUS" property + parameter on each matching "ATTENDEE" iCalendar property and set its + value to that of the "REQUEST-STATUS" property in the reply, or to + "2.0" if "REQUEST-STATUS" is not present (also taking into account + recurrence instances). If there are multiple "REQUEST-STATUS" + properties in the reply, the "SCHEDULE-STATUS" property parameter + value is set to a comma-separated list of status codes, one from each + "REQUEST-STATUS" property. + + The server SHOULD send scheduling messages to all the other + "Attendees" indicating the change in participation status of the + "Attendee" replying, subject to the recurrence requirements of + Section 3.2.6. + + The scheduling message MUST only appear in the "Organizer's" + scheduling Inbox collection once all automatic processing has been + done. + +4.3. Default Calendar Collection + + The server processes scheduling messages received for an "Attendee" + by creating a new scheduling object resource in a calendar collection + belonging to the "Attendee", when one does not already exist. A + calendar user that is an "Attendee" in a scheduling operation MUST + have at least one valid calendar collection available. If there is + no valid calendar collection, then the server MUST reject the attempt + to deliver the scheduling message to the "Attendee". + + Servers MAY provide support for a default calendar collection -- that + is, the calendar collection in which new scheduling object resources + will be created. The CALDAV:schedule-default-calendar-URL WebDAV + property, which can be present on the scheduling Inbox collection of + a calendar user, specifies whether this calendar user has a default + calendar collection. See Section 9.2. + + + +Daboo & Desruisseaux Standards Track [Page 35] + +RFC 6638 CalDAV Scheduling June 2012 + + + Servers SHOULD create new scheduling object resources in the default + calendar collection, if the CALDAV:schedule-default-calendar-URL + WebDAV property is set. + + Servers MAY allow clients to change the default calendar collection + by changing the value of the CALDAV:schedule-default-calendar-URL + WebDAV property on the scheduling Inbox collection. However, the + server MUST ensure that any new value for that property refers to a + valid calendar collection belonging to the owner of the scheduling + Inbox collection. + + Servers MUST reject any attempt to delete the default calendar + collection. + +4.3.1. Additional Method Preconditions + + This specification defines additional method preconditions (see + Section 16 of WebDAV [RFC4918]) to provide machine-parseable + information in error responses. + +4.3.1.1. CALDAV:default-calendar-needed Precondition + + Name: default-calendar-needed + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: DELETE + + Use with: 403 Forbidden + + Purpose: (precondition) -- The client attempted to delete the + calendar collection currently referenced by the CALDAV:schedule- + default-calendar-URL property, or attempted to remove the CALDAV: + schedule-default-calendar-URL property on the scheduling Inbox + collection on a server that doesn't allow such operations. + + Definition: + + + +4.3.1.2. CALDAV:valid-schedule-default-calendar-URL Precondition + + Name: valid-schedule-default-calendar-URL + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: PROPPATCH + + + + +Daboo & Desruisseaux Standards Track [Page 36] + +RFC 6638 CalDAV Scheduling June 2012 + + + Use with: 403 Forbidden + + Purpose: (precondition) -- The client attempted to set the CALDAV: + schedule-default-calendar-URL property to a DAV:href element that + doesn't reference a valid calendar collection. Note: Servers that + do not allow clients to change the CALDAV:schedule-default- + calendar-URL property would simply return the DAV:cannot-modify- + protected-property precondition defined in Section 16 of WebDAV + [RFC4918]. + + Definition: + + + +5. Request for Busy Time Information + + Busy time information of one or more calendar users can be determined + by submitting a POST request targeted at the scheduling Outbox + collection of the calendar user requesting the information (the + "Organizer"). To accomplish this, the request body MUST contain a + "VFREEBUSY" calendar component with the "METHOD" iCalendar property + set to the value "REQUEST" as specified in Section 3.3.2 of iTIP + [RFC5546]. The resource identified by the Request-URI MUST be a + resource collection of type CALDAV:schedule-outbox (Section 2.1). + The "ORGANIZER" property value in the "VFREEBUSY" component MUST + match one of the calendar user addresses of the owner of the Outbox + collection. + + A response to a busy time request that indicates status for one or + more calendar users MUST be an XML document with a CALDAV:schedule- + response XML element as its root element. This element MUST contain + one CALDAV:response element for each calendar user, with each such + element in turn containing elements that indicate which calendar user + they correspond to, the scheduling status for that calendar user, any + error codes, and an optional description. For a successful busy time + request, a CALDAV:calendar-data element is also present for each + calendar user, containing the actual busy time information (i.e., an + iCalendar "VFREEBUSY" component). See Section 10 for details on the + child elements. See Appendix B.5 for an example busy time request + and response. + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 37] + +RFC 6638 CalDAV Scheduling June 2012 + + +5.1. Status Codes + + The list below summarizes the most common status codes used for this + method. However, clients need to be prepared to handle other + 2/3/4/5xx series status codes as well. + + 200 (OK) - The command succeeded. + + 204 (No Content) - The command succeeded. + + 400 (Bad Request) - The client has provided an invalid scheduling + message. + + 403 (Forbidden) - The client cannot submit a scheduling message to + the specified Request-URI. + + 404 (Not Found) - The URL in the Request-URI was not present. + + 423 (Locked) - The specified resource is locked, and the client + either is not a lock owner or the lock type requires a lock token + to be submitted and the client did not submit it. + +5.2. Additional Method Preconditions + + The following are existing preconditions that are reused for the POST + method on an Outbox collection. + + o DAV:need-privileges [RFC3744] + + o CALDAV:supported-calendar-data [RFC4791] + + o CALDAV:valid-calendar-data [RFC4791] + + o CALDAV:max-resource-size [RFC4791] + + The following are new method preconditions for the POST method on an + Outbox collection. + +5.2.1. CALDAV:valid-scheduling-message Precondition + + Name: valid-scheduling-message + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: POST + + Use with: 400 Bad Request + + + + +Daboo & Desruisseaux Standards Track [Page 38] + +RFC 6638 CalDAV Scheduling June 2012 + + + Purpose: (precondition) -- The resource submitted in the POST + request MUST obey all the restrictions specified in Section 3.3.2 + of iTIP [RFC5546]. + + Definition: + + + +5.2.2. CALDAV:valid-organizer Precondition + + Name: valid-organizer + + Namespace: urn:ietf:params:xml:ns:caldav + + Apply to: POST + + Use with: 403 Forbidden + + Purpose: (precondition) -- The "ORGANIZER" property value in the + POST request's scheduling message MUST match one of the calendar + user addresses of the owner of the scheduling Outbox collection + being targeted by the request. + + Definition: + + + +6. Scheduling Privileges + + New scheduling privileges are defined in this section. All the + scheduling privileges MUST be non-abstract and MUST appear in the + DAV:supported-privilege-set property of scheduling Outbox and Inbox + collections on which they are defined. + + The tables specified in Appendix A clarify which scheduling methods + (e.g., "REQUEST", "REPLY", etc.) are controlled by each scheduling + privilege defined in this section. + +6.1. Privileges on Scheduling Inbox Collections + + This section defines new WebDAV Access Control List (ACL) [RFC3744] + privileges that are defined for use on scheduling Inbox collections. + These privileges determine whether delivery of scheduling messages + from a calendar user is allowed by the calendar user who "owns" the + scheduling Inbox collection. This allows calendar users to choose + which other calendar users can schedule with them. + + + + + +Daboo & Desruisseaux Standards Track [Page 39] + +RFC 6638 CalDAV Scheduling June 2012 + + + Note that when a scheduling message is delivered to a calendar user, + in addition to a scheduling object resource being created in the + calendar user's scheduling Inbox collection, a new scheduling object + resource might be created or an existing one updated in a calendar + belonging to the calendar user. In that case, the ability to create + or update the scheduling object resource in the calendar is + controlled by the privileges assigned to the scheduling Inbox + collection. + + The privileges defined in this section are ignored if applied to a + resource other than a scheduling Inbox collection. + +6.1.1. CALDAV:schedule-deliver Privilege + + CALDAV:schedule-deliver is an aggregate privilege as per Section 6.3. + + + +6.1.2. CALDAV:schedule-deliver-invite Privilege + + The CALDAV:schedule-deliver-invite privilege controls the processing + and delivery of scheduling messages coming from an "Organizer". + + + +6.1.3. CALDAV:schedule-deliver-reply Privilege + + The CALDAV:schedule-deliver-reply privilege controls the processing + and delivery of scheduling messages coming from an "Attendee". + + + +6.1.4. CALDAV:schedule-query-freebusy Privilege + + The CALDAV:schedule-query-freebusy privilege controls freebusy + requests targeted at the owner of the scheduling Inbox collection. + + + +6.2. Privileges on Scheduling Outbox Collections + + This section defines new WebDAV ACL [RFC3744] privileges that are + defined for use on scheduling Outbox collections. These privileges + determine which calendar users are allowed to send scheduling + messages on behalf of the calendar user who "owns" the scheduling + Outbox collection. This allows calendar users to choose other + calendar users who can act on their behalf (e.g., assistants working + on behalf of their boss). + + + +Daboo & Desruisseaux Standards Track [Page 40] + +RFC 6638 CalDAV Scheduling June 2012 + + + The privileges defined in this section are ignored if applied to a + resource other than a scheduling Outbox collection. + +6.2.1. CALDAV:schedule-send Privilege + + CALDAV:schedule-send is an aggregate privilege as per Section 6.3. + + + +6.2.2. CALDAV:schedule-send-invite Privilege + + The CALDAV:schedule-send-invite privilege controls the sending of + scheduling messages by "Organizers". + + Users granted the DAV:bind privilege on a calendar collection, or the + DAV:write privilege on scheduling object resources, will also need + the CALDAV:schedule-send-invite privilege granted on the scheduling + Outbox collection of the owner of the calendar collection or + scheduling object resource in order to be allowed to create, modify, + or delete scheduling object resources in a way that will trigger the + CalDAV server to deliver scheduling messages to "Attendees". + + + +6.2.3. CALDAV:schedule-send-reply Privilege + + The CALDAV:schedule-send-reply privilege controls the sending of + scheduling messages by "Attendees". + + Users granted the DAV:bind privilege on a calendar collection, or the + DAV:write privilege on scheduling object resources, will also need + the CALDAV:schedule-send-reply privilege granted on the scheduling + Outbox collection of the owner of the calendar collection or + scheduling object resource in order to be allowed to create, modify, + or delete scheduling object resources in a way that will trigger the + CalDAV server to deliver scheduling message replies to the + "Organizer". + + + +6.2.4. CALDAV:schedule-send-freebusy Privilege + + The CALDAV:schedule-send-freebusy privilege controls the use of the + POST method to submit scheduling messages that specify the scheduling + method "REQUEST" with a "VFREEBUSY" calendar component. + + + + + + +Daboo & Desruisseaux Standards Track [Page 41] + +RFC 6638 CalDAV Scheduling June 2012 + + +6.3. Aggregation of Scheduling Privileges + + Server implementations MUST aggregate the scheduling privileges as + follows: + + DAV:all contains CALDAV:schedule-deliver and CALDAV:schedule-send; + + CALDAV:schedule-deliver contains CALDAV:schedule-deliver-invite, + CALDAV:schedule-deliver-reply, and CALDAV:schedule-query-freebusy; + + CALDAV:schedule-send contains CALDAV:schedule-send-invite, CALDAV: + schedule-send-reply, and CALDAV:schedule-send-freebusy. + + The following diagram illustrates how scheduling privileges are + aggregated according to the above requirements. + + [DAV:all] (aggregate) + | + +-- [CALDAV:schedule-deliver] (aggregate) + | | + | +-- [CALDAV:schedule-deliver-invite] + | +-- [CALDAV:schedule-deliver-reply] + | +-- [CALDAV:schedule-query-freebusy] + | + +-- [CALDAV:schedule-send] (aggregate) + | + +-- [CALDAV:schedule-send-invite] + +-- [CALDAV:schedule-send-reply] + +-- [CALDAV:schedule-send-freebusy] + +7. Additional iCalendar Property Parameters + + This specification defines additional iCalendar property parameters + to support the CalDAV scheduling extensions. + +7.1. Schedule Agent Parameter + + Parameter Name: SCHEDULE-AGENT + + Purpose: To specify the agent expected to deliver scheduling + messages to the corresponding "Organizer" or "Attendee". + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 42] + +RFC 6638 CalDAV Scheduling June 2012 + + + Format Definition: This property parameter is defined by the + following notation: + + scheduleagentparam = "SCHEDULE-AGENT" "=" + ("SERVER" ; The server handles scheduling + / "CLIENT" ; The client handles scheduling + / "NONE" ; No scheduling + / x-name ; Experimental type + / iana-token) ; Other IANA-registered type + ; + ; If the parameter is not present, its value defaults to SERVER. + ; "x-name" and "iana-token" are defined in Section 3.1 of + ; [RFC5545]. + + Description: This property parameter MAY be specified on "ORGANIZER" + or "ATTENDEE" iCalendar properties. In the absence of this + parameter, the value "SERVER" MUST be used for the default + behavior. The value determines whether or not a scheduling + operation on a server will cause a scheduling message to be sent + to the corresponding calendar user identified by the "ORGANIZER" + or "ATTENDEE" property value. When the value "SERVER" is + specified, or the parameter is absent, then it is the server's + responsibility to send a scheduling message as part of a + scheduling operation. When the value "CLIENT" is specified, that + indicates that the client is handling scheduling messages with the + calendar user itself. When "NONE" is specified, no scheduling + messages are being sent to the calendar user. + + Servers MUST NOT include this parameter in any scheduling messages + sent as the result of a scheduling operation. + + Clients MUST NOT include this parameter in any scheduling messages + that they themselves send. + + The parameter value MUST be the same on every "ORGANIZER" property + in a scheduling object resource. + + The parameter value MUST be the same on each "ATTENDEE" property + whose values match in a scheduling object resource. + + Servers and clients MUST treat x-name and iana-token values they + do not recognize the same way as they would the "NONE" value. + + Example: + + ORGANIZER;SCHEDULE-AGENT=SERVER:mailto:bernard@example.com + ATTENDEE;SCHEDULE-AGENT=NONE:mailto:cyrus@example.com + + + + +Daboo & Desruisseaux Standards Track [Page 43] + +RFC 6638 CalDAV Scheduling June 2012 + + +7.2. Schedule Force Send Parameter + + Parameter Name: SCHEDULE-FORCE-SEND + + Purpose: To force a scheduling message to be sent to the calendar + user specified by the property. + + Format Definition: This property parameter is defined by the + following notation: + + scheduleforcesendparam = "SCHEDULE-FORCE-SEND" "=" + ("REQUEST" ; Force a "REQUEST" + / "REPLY" ; Force a "REPLY" + / iana-token) + ; + ; "iana-token" is defined in Section 3.1 of [RFC5545]. Its value + ; MUST be an IANA-registered iCalendar "METHOD" property value. + + Description: This property parameter MAY be specified on "ATTENDEE" + and "ORGANIZER" properties on which the "SCHEDULE-AGENT" property + parameter is set to the value "SERVER" or is not specified. This + property parameter is used to force a server to send a scheduling + message to a specific calendar user in situations where the server + would not send a scheduling message otherwise (e.g., when no + change that warrants the delivery of a new scheduling message was + performed on the scheduling object resource). An "Organizer" MAY + specify this parameter on an "ATTENDEE" property with the value + "REQUEST" to force a "REQUEST" scheduling message to be sent to + this "Attendee". An "Attendee" MAY specify this parameter on the + "ORGANIZER" with the value "REPLY" to force a "REPLY" scheduling + message to be sent to the "Organizer". + + Servers MUST NOT preserve this property parameter in scheduling + object resources, nor include it in any scheduling messages sent + as the result of a scheduling operation. + + Clients MUST NOT include this parameter in any scheduling messages + that they themselves send. + + Servers MUST set the "SCHEDULE-STATUS" parameter of the "ATTENDEE" + or "ORGANIZER" to 2.3 (i.e., "Success; invalid property parameter + ignored"; see Section 3.6 of [RFC5546]) when the "SCHEDULE-FORCE- + SEND" parameter is set to an iana-token value they do not + recognize. + + + + + + + +Daboo & Desruisseaux Standards Track [Page 44] + +RFC 6638 CalDAV Scheduling June 2012 + + + Example: + + ORGANIZER;SCHEDULE-FORCE-SEND=REPLY:mailto:cyrus@example.com + ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST:mailto:bernard@example.com + +7.3. Schedule Status Parameter + + Parameter Name: SCHEDULE-STATUS + + Purpose: To specify the status codes returned from processing of the + most recent scheduling message sent to the corresponding + "Attendee", or received from the corresponding "Organizer". + + Format Definition: This property parameter is defined by the + following notation: + + schedulestatusparam = "SCHEDULE-STATUS" "=" + ( statcode + / DQUOTE statcode *("," statcode) DQUOTE) + ; + ; "statcode" is defined in Section 3.8.8.3 of [RFC5545]. The + ; value is a single "statcode" or a comma-separated list of + ; "statcode" values. + + Description: This property parameter MAY be specified on the + "ATTENDEE" and "ORGANIZER" properties. + + Servers MUST only add or change this property parameter on any + "ATTENDEE" properties corresponding to calendar users who were + sent a scheduling message via a scheduling operation. Clients + SHOULD NOT change or remove this parameter if it was provided by + the server. In the case where the client is handling the + scheduling, the client MAY add, change, or remove this parameter + to indicate the last scheduling message status it received. + + Servers MUST add this parameter to any "ORGANIZER" properties + corresponding to calendar users who were sent a scheduling message + reply by an "Attendee" via a scheduling operation. Clients SHOULD + NOT change or remove this parameter if it was provided by the + server. In the case where the client is handling the scheduling, + the client MAY add, change, or remove this parameter to indicate + the last scheduling message status it received. + + Servers MUST NOT include this parameter in any scheduling messages + sent as the result of a scheduling operation. + + Clients MUST NOT include this parameter in any scheduling messages + that they themselves send. + + + +Daboo & Desruisseaux Standards Track [Page 45] + +RFC 6638 CalDAV Scheduling June 2012 + + + Values for this property parameter are described in Section 3.2.9. + + Example: + + ATTENDEE;SCHEDULE-STATUS="2.0":mailto:bernard@example.com + ATTENDEE;SCHEDULE-STATUS="2.0,2.4":mailto:cyrus@example.com + +8. Additional Message Header Fields + + This specification defines additional HTTP request and response + headers for use with CalDAV. + +8.1. Schedule-Reply Request Header + + Schedule-Reply = "Schedule-Reply" ":" ("T" | "F") + + Example: + + Schedule-Reply: F + + When an "Attendee" removes a scheduling object resource as per + Section 3.2.2.4, and the Schedule-Reply header is set to the value + "T" (true) or is not present, the server MUST send an appropriate + reply scheduling message with the "Attendee's" "PARTSTAT" iCalendar + property parameter value set to "DECLINED" as part of its normal + scheduling operation processing. + + When the Schedule-Reply header is set to the value "F" (false), the + server MUST NOT send a scheduling message as part of its normal + scheduling operation processing. + + The Schedule-Reply request header is used by a client to indicate to + a server whether or not a scheduling operation ought to occur when an + "Attendee" deletes a scheduling object resource. In particular, it + controls whether a reply scheduling message is sent to the + "Organizer" as a result of the removal. There are situations in + which unsolicited scheduling messages need to be silently removed (or + ignored) for security or privacy reasons. This request header allows + the scheduling object resource to be removed if such a need arises. + +8.2. Schedule-Tag Response Header + + The Schedule-Tag response header provides the current value of the + CALDAV:schedule-tag property value. The behavior of this response + header is described in Section 3.2.10. + + All scheduling object resources MUST support the Schedule-Tag header. + + + + +Daboo & Desruisseaux Standards Track [Page 46] + +RFC 6638 CalDAV Scheduling June 2012 + + + Schedule-Tag = "Schedule-Tag" ":" opaque-tag + ; "opaque-tag" is defined in Section 3.11 of [RFC2616]. + + Example: + + Schedule-Tag: "12ab34-cd56ef" + +8.3. If-Schedule-Tag-Match Request Header + + The If-Schedule-Tag-Match request header field is used with a method + to make it conditional. Clients can set this header to the value + returned in the Schedule-Tag response header, or the CALDAV:schedule- + tag property, of a scheduling object resource previously retrieved + from the server to avoid overwriting "consequential" changes to the + scheduling object resource. + + All scheduling object resources MUST support the If-Schedule-Tag- + Match header. + + If-Schedule-Tag-Match = "If-Schedule-Tag-Match" ":" opaque-tag + ; "opaque-tag" is defined in Section 3.11 of [RFC2616]. + + Example: + + If-Schedule-Tag-Match: "12ab34-cd56ef" + +9. Additional WebDAV Properties + + This specification defines the following new WebDAV properties for + use with CalDAV. + +9.1. CALDAV:schedule-calendar-transp Property + + Name: schedule-calendar-transp + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Determines whether the calendar object resources in a + calendar collection will affect the owner's busy time information. + + Protected: This property MAY be protected and SHOULD NOT be returned + by a PROPFIND DAV:allprop request (as defined in Section 14.2 of + [RFC4918]). + + COPY/MOVE behavior: This property value SHOULD be kept during a MOVE + operation, and SHOULD be copied and preserved in a COPY. + + + + + +Daboo & Desruisseaux Standards Track [Page 47] + +RFC 6638 CalDAV Scheduling June 2012 + + + Description: This property SHOULD be defined on all calendar + collections. If present, it contains one of two XML elements that + indicate whether the calendar object resources in the calendar + collection ought to contribute to the owner's busy time. When the + CALDAV:opaque element is used, all calendar object resources in + the corresponding calendar collection MUST contribute to busy + time, assuming that access privileges and other iCalendar + properties allow it to. When the CALDAV:transparent XML element + is used, the calendar object resources in the corresponding + calendar collection MUST NOT contribute to busy time. + + If this property is not present on a calendar collection, then the + default value CALDAV:opaque MUST be assumed. + + Definition: + + + + + + + + + + Example: + + + + + +9.2. CALDAV:schedule-default-calendar-URL Property + + Name: schedule-default-calendar-URL + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Specifies a default calendar for an "Attendee" where new + scheduling object resources are created. + + Protected: This property MAY be protected in the case where a server + does not support changing the default calendar, or does not + support a default calendar. + + COPY/MOVE behavior: This property is only defined on a scheduling + Inbox collection that cannot be moved or copied. + + + + + +Daboo & Desruisseaux Standards Track [Page 48] + +RFC 6638 CalDAV Scheduling June 2012 + + + Description: This property MAY be defined on a scheduling Inbox + collection. If present, it contains zero or one DAV:href XML + elements. When a DAV:href element is present, its value indicates + a URL to a calendar collection that is used as the default + calendar. When no DAV:href element is present, it indicates that + there is no default calendar. In the absence of this property, + there is no default calendar. When there is no default calendar, + the server is free to choose the calendar in which a new + scheduling object resource is created. See Section 4.3. + + Definition: + + + + Example: + + + /home/cyrus/calendars/work/ + + +9.3. CALDAV:schedule-tag Property + + Name: schedule-tag + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Indicates whether a scheduling object resource has had a + "consequential" change made to it. + + Value: opaque-tag (defined in Section 3.11 of [RFC2616]) + + Protected: This property MUST be protected, as only the server can + update the value. + + COPY/MOVE behavior: This property value is determined by the server + and MAY be different from the value on the source resource. + + Description: The CALDAV:schedule-tag property MUST be defined on all + scheduling object resources. This property is described in + Section 3.2.10. + + Definition: + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 49] + +RFC 6638 CalDAV Scheduling June 2012 + + + Example: + + "12345-67890" + +10. XML Element Definitions + +10.1. CALDAV:schedule-response XML Element + + Name: schedule-response + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Contains the set of responses for a POST method request. + + Description: See Section 5. + + Definition: + + + +10.2. CALDAV:response XML Element + + Name: response + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: Contains a single response for a POST method request. + + Description: See Section 5. + + Definition: + + + + + +10.3. CALDAV:recipient XML Element + + Name: recipient + + Namespace: urn:ietf:params:xml:ns:caldav + + + +Daboo & Desruisseaux Standards Track [Page 50] + +RFC 6638 CalDAV Scheduling June 2012 + + + Purpose: The calendar user address that the enclosing response for a + POST method request is for. + + Description: See Section 5. + + Definition: + + + +10.4. CALDAV:request-status XML Element + + Name: request-status + + Namespace: urn:ietf:params:xml:ns:caldav + + Purpose: The iTIP "REQUEST-STATUS" property value for this response. + + Description: See Section 5. + + Definition: + + + +11. Security Considerations + + The process of scheduling involves the sending and receiving of + scheduling messages. As a result, the security problems related to + messaging in general are relevant here. In particular, the + authenticity of the scheduling messages needs to be verified. + Servers and clients MUST use an HTTP connection protected with + Transport Layer Security (TLS) as defined in [RFC2818] for all + scheduling operations. Clients MUST use the procedures detailed in + Section 6 of [RFC6125] to verify the authenticity of the server. + Servers MUST make use of HTTP authentication [RFC2617] to verify the + authenticity of the calendar user for whom the client is sending + requests. + +11.1. Preventing Denial-of-Service Attacks + + Servers MUST ensure that clients cannot consume excessive server + resources by carrying out "large" scheduling operations. In + particular, servers SHOULD enforce CALDAV:max-resource-size, CALDAV: + max-instances, and CALDAV:max-attendees-per-instance preconditions as + applicable for scheduling Inbox and Outbox collections. + + + + + + + +Daboo & Desruisseaux Standards Track [Page 51] + +RFC 6638 CalDAV Scheduling June 2012 + + +11.2. Verifying Scheduling Operations + + When handling a scheduling operation: + + 1. Servers MUST verify that the principal associated with the DAV: + owner of the calendar collection in which a scheduling object + resource is being manipulated contains a CALDAV:schedule-outbox- + URL property value. + + 2. Servers MUST verify that the currently authenticated user has the + CALDAV:schedule-send privilege, or a sub-privilege aggregated + under this privilege, on the scheduling Outbox collection of the + DAV:owner of the calendar collection in which a scheduling object + resource is being manipulated. + + 3. Servers MUST only deliver scheduling messages to recipients when + the CALDAV:schedule-deliver privilege, or a sub-privilege + aggregated under this privilege, is granted on the recipient's + scheduling Inbox collection for the principal associated with the + DAV:owner of the calendar collection in which a scheduling object + resource is being manipulated. + + 4. To prevent impersonation of calendar users, the server MUST + verify that the "ORGANIZER" property in an organizer scheduling + object resource matches one of the calendar user addresses of the + DAV:owner of the calendar collection in which the resource is + stored. + + 5. To prevent spoofing of an existing scheduling object resource, + servers MUST verify that the "UID" iCalendar property value in a + new scheduling object resource does not match that of an existing + scheduling object resource with a different "ORGANIZER" property + value. + +11.3. Verifying Busy Time Information Requests + + When handling a POST request on a scheduling Outbox collection: + + 1. Servers MUST verify that the principal associated with the + calendar user address specified in the "ORGANIZER" property of + the scheduling message data in the request contains a CALDAV: + schedule-outbox-URL property value that matches the scheduling + Outbox collection targeted by the request. + + 2. Servers MUST verify that the currently authenticated user has the + CALDAV:schedule-send privilege, or a sub-privilege aggregated + under this privilege, on the scheduling Outbox collection + targeted by the request. + + + +Daboo & Desruisseaux Standards Track [Page 52] + +RFC 6638 CalDAV Scheduling June 2012 + + + 3. Servers MUST only return valid freebusy information for + recipients when the CALDAV:schedule-deliver privilege, or a + sub-privilege aggregated under this privilege, is granted on the + recipient's scheduling Inbox collection for the principal + associated with the DAV:owner of the scheduling Outbox collection + targeted by the request. + +11.4. Privacy Issues + + This specification only defines how calendar users on the same server + are able to schedule with each other -- unauthenticated users have no + way to carry out scheduling operations. Access control privileges + (as per Section 6) can control which of those users can schedule with + others. Calendar users not wishing to expose their calendar + information to other users can do so by denying privileges to + specific users, or all users, for all scheduling operations, or + perhaps only freebusy. + + "Attendees" can also use the Schedule-Reply request header + (Section 8.1) with the value set to "F" to prevent notification to an + "Organizer" that a scheduling object resource was deleted. This + allows "Attendees" to remove unwanted scheduling messages without any + response to the "Organizer". + + Servers MUST NOT expose any private iCalendar data, or WebDAV + resource state information (URLs, WebDAV properties, etc.) for one + calendar user to another via scheduling messages or error responses + to scheduling operations. In particular, as per Section 8.1 of + [RFC4918], authorization errors MUST take preference over other + errors. + +11.5. Mitigation of iTIP Threats + + Section 6.1 of iTIP [RFC5546] defines a set of potential threats in a + scheduling system, and Section 6.2 of [RFC5546] defines + recommendations on how those can be addressed in protocols using + iTIP. This specification addresses the iTIP threats in the following + manner: + + Spoofing the "Organizer": Addressed by item 4 in Section 11.2. + + Spoofing the "Attendee": Addressed by Section 3.2.2.1 and item 2 in + Section 11.2. + + Unauthorized Replacement of the "Organizer": Addressed by item 5 in + Section 11.2. + + Eavesdropping and Data Integrity: Addressed by requiring TLS. + + + +Daboo & Desruisseaux Standards Track [Page 53] + +RFC 6638 CalDAV Scheduling June 2012 + + + Flooding a Calendar: Addressed by requirements in Section 11.1. + + Unauthorized REFRESH Requests: This specification does not support + the REFRESH method. + +12. IANA Considerations + +12.1. Message Header Field Registrations + + The message header fields below have been added to the Permanent + Message Header Field Registry (see [RFC3864]). + +12.1.1. Schedule-Reply + + Header field name: Schedule-Reply + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document(s): this specification (Section 8.1) + + Related information: none + +12.1.2. Schedule-Tag + + Header field name: Schedule-Tag + + Applicable protocol: http + + Status: standard + + Author/Change controller: IETF + + Specification document(s): this specification (Section 8.2) + + Related information: none + +12.1.3. If-Schedule-Tag-Match + + Header field name: If-Schedule-Tag-Match + + Applicable protocol: http + + Status: standard + + + + +Daboo & Desruisseaux Standards Track [Page 54] + +RFC 6638 CalDAV Scheduling June 2012 + + + Author/Change controller: IETF + + Specification document(s): this specification (Section 8.3) + + Related information: none + +12.2. iCalendar Property Parameter Registrations + + The following iCalendar property parameter names have been added to + the iCalendar Parameters Registry defined in Section 8.3.3 of + [RFC5545]. + + +---------------------+---------+-----------------------+ + | Parameter | Status | Reference | + +---------------------+---------+-----------------------+ + | SCHEDULE-AGENT | Current | RFC 6638, Section 7.1 | + | | | | + | SCHEDULE-STATUS | Current | RFC 6638, Section 7.3 | + | | | | + | SCHEDULE-FORCE-SEND | Current | RFC 6638, Section 7.2 | + +---------------------+---------+-----------------------+ + +12.3. iCalendar REQUEST-STATUS Value Registrations + + The following iCalendar "REQUEST-STATUS" values have been added to + the iCalendar REQUEST-STATUS Value Registry defined in Section 7.3 of + [RFC5546]. + + +-------------+---------+---------------------------+ + | Status Code | Status | Reference | + +-------------+---------+---------------------------+ + | 1.0 | Current | RFC 6638, Section 3.2.9.1 | + | | | | + | 1.1 | Current | RFC 6638, Section 3.2.9.2 | + | | | | + | 1.2 | Current | RFC 6638, Section 3.2.9.3 | + +-------------+---------+---------------------------+ + +12.4. Additional iCalendar Elements Registries + + Per this specification, two new IANA registries for iCalendar + elements have been added. Additional codes MAY be used, provided the + process described in Section 8.2.1 of [RFC5545] is used to register + them. + + + + + + + +Daboo & Desruisseaux Standards Track [Page 55] + +RFC 6638 CalDAV Scheduling June 2012 + + +12.4.1. Schedule Agent Values Registry + + The following table has been used to initialize the Schedule Agent + Values Registry. + + +----------------+---------+-----------------------+ + | Schedule Agent | Status | Reference | + +----------------+---------+-----------------------+ + | SERVER | Current | RFC 6638, Section 7.1 | + | | | | + | CLIENT | Current | RFC 6638, Section 7.1 | + | | | | + | NONE | Current | RFC 6638, Section 7.1 | + +----------------+---------+-----------------------+ + +12.4.2. Schedule Force Send Values Registry + + The following table has been used to initialize the Schedule Force + Send Values Registry. + + +---------------------+---------+-----------------------+ + | Schedule Force Send | Status | Reference | + +---------------------+---------+-----------------------+ + | REQUEST | Current | RFC 6638, Section 7.2 | + | | | | + | REPLY | Current | RFC 6638, Section 7.2 | + +---------------------+---------+-----------------------+ + +13. Acknowledgements + + The authors would like to thank the following individuals for + contributing their ideas and support for writing this specification: + Mike Douglass, Lisa Dusseault, Red Dutta, Jacob Farkas, Jeffrey + Harris, Helge Hess, Eliot Lear, Andrew McMillan, Alexey Melnikov, + Arnaud Quillaud, Julian F. Reschke, Wilfredo Sanchez Vega, and Simon + Vaillancourt. + + The authors would also like to thank the Calendaring and Scheduling + Consortium for advice with this specification, and for organizing + interoperability testing events to help refine it. + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 56] + +RFC 6638 CalDAV Scheduling June 2012 + + +14. References + +14.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., + Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext + Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999. + + [RFC2617] Franks, J., Hallam-Baker, P., Hostetler, J., Lawrence, S., + Leach, P., Luotonen, A., and L. Stewart, "HTTP + Authentication: Basic and Digest Access Authentication", + RFC 2617, June 1999. + + [RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, May 2000. + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web + Distributed Authoring and Versioning (WebDAV) + Access Control Protocol", RFC 3744, May 2004. + + [RFC3864] Klyne, G., Nottingham, M., and J. Mogul, "Registration + Procedures for Message Header Fields", BCP 90, RFC 3864, + September 2004. + + [RFC4791] Daboo, C., Desruisseaux, B., and L. Dusseault, + "Calendaring Extensions to WebDAV (CalDAV)", RFC 4791, + March 2007. + + [RFC4918] Dusseault, L., Ed., "HTTP Extensions for Web Distributed + Authoring and Versioning (WebDAV)", RFC 4918, June 2007. + + [RFC5234] Crocker, D., Ed., and P. Overell, "Augmented BNF for + Syntax Specifications: ABNF", STD 68, RFC 5234, + January 2008. + + [RFC5545] Desruisseaux, B., Ed., "Internet Calendaring and + Scheduling Core Object Specification (iCalendar)", + RFC 5545, September 2009. + + [RFC5546] Daboo, C., Ed., "iCalendar Transport-Independent + Interoperability Protocol (iTIP)", RFC 5546, + December 2009. + + + + + + + +Daboo & Desruisseaux Standards Track [Page 57] + +RFC 6638 CalDAV Scheduling June 2012 + + + [RFC6125] Saint-Andre, P. and J. Hodges, "Representation and + Verification of Domain-Based Application Service Identity + within Internet Public Key Infrastructure Using X.509 + (PKIX) Certificates in the Context of Transport Layer + Security (TLS)", RFC 6125, March 2011. + + [W3C.REC-xml-20081126] + Bray, T., Paoli, J., Sperberg-McQueen, C., Maler, E., + and F. Yergeau, "Extensible Markup Language (XML) 1.0 + (Fifth Edition)", World Wide Web Consortium + Recommendation REC-xml-20081126, November 2008, + . + +14.2. Informative References + + [RFC6047] Melnikov, A., Ed., "iCalendar Message-Based + Interoperability Protocol (iMIP)", RFC 6047, + December 2010. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 58] + +RFC 6638 CalDAV Scheduling June 2012 + + +Appendix A. Scheduling Privileges Summary + +A.1. Scheduling Inbox Privileges + + The following tables specify which scheduling privileges grant the + right to a calendar user to deliver a scheduling message to the + scheduling Inbox collection of another calendar user. The + appropriate behavior depends on the calendar component type as well + as the scheduling "METHOD" specified in the scheduling message. + + +--------------------------------+ + | METHOD for VEVENT and VTODO | + +-----------------------------+---------+-------+-----+--------+ + | Scheduling Inbox Privilege | REQUEST | REPLY | ADD | CANCEL | + +-----------------------------+---------+-------+-----+--------+ + | schedule-deliver | * | * | * | * | + | schedule-deliver-invite | * | | * | * | + | schedule-deliver-reply | | * | | | + | schedule-query-freebusy | | | | | + +-----------------------------+---------+-------+-----+--------+ + + + +----------------------+ + | METHOD for VFREEBUSY | + +-----------------------------+----------------------+ + | Scheduling Inbox Privilege | REQUEST | + +-----------------------------+----------------------+ + | schedule-deliver | * | + | schedule-deliver-invite | | + | schedule-deliver-reply | | + | schedule-query-freebusy | * | + +-----------------------------+----------------------+ + + + + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 59] + +RFC 6638 CalDAV Scheduling June 2012 + + +A.2. Scheduling Outbox Privileges + + The following tables specify which scheduling privileges grant the + right to a calendar user to perform busy time information requests + and to submit scheduling messages to other calendar users as the + result of a scheduling operation. The appropriate behavior depends + on the calendar component type as well as the scheduling "METHOD" + specified in the scheduling message. + + +--------------------------------+ + | METHOD for VEVENT and VTODO | + +-----------------------------+---------+-------+-----+--------+ + | Scheduling Outbox Privilege | REQUEST | REPLY | ADD | CANCEL | + +-----------------------------+---------+-------+-----+--------+ + | schedule-send | * | * | * | * | + | schedule-send-invite | * | | * | * | + | schedule-send-reply | | * | | | + | schedule-send-freebusy | | | | | + +-----------------------------+---------+-------+-----+--------+ + + + +----------------------+ + | METHOD for VFREEBUSY | + +-----------------------------+----------------------+ + | Scheduling Outbox Privilege | REQUEST | + +-----------------------------+----------------------+ + | schedule-send | * | + | schedule-send-invite | | + | schedule-send-reply | | + | schedule-send-freebusy | * | + +-----------------------------+----------------------+ + +Appendix B. Example Scheduling Operations + + This section describes some example scheduling operations that give a + general idea of how scheduling is carried out between CalDAV clients + and servers from the perspective of meeting "Organizers" and + "Attendees". + + The server is assumed to be hosted in the "example.com" domain, and + users whose email addresses are at the "example.com" domain are + assumed to be hosted by the server. In addition, the email addresses + in the "example.net" domain are also valid email addresses for + calendar users hosted by the server. Calendar users with an email + address at the "example.org" domain are assumed to not be hosted by + the server. + + + + + +Daboo & Desruisseaux Standards Track [Page 60] + +RFC 6638 CalDAV Scheduling June 2012 + + + In the following examples, the requests and responses are incomplete + and are only for illustrative purposes. In particular, HTTP + authentication headers and behaviors are not shown, even though they + are required in normal operation. + +B.1. Example: "Organizer" Inviting Multiple "Attendees" + + In the following example, Cyrus invites Wilfredo, Bernard, and Mike + to a single instance event by simply creating a new scheduling object + resource in one of his calendar collections by using the PUT method. + + >> Request << + + PUT /home/cyrus/calendars/work/9263504FD3AD.ics HTTP/1.1 + Host: cal.example.com + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + If-None-Match: * + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185254Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + TRANSP:OPAQUE + SUMMARY:Lunch + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT + =NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@ + example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@ex + ample.net + ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-A + CTION;RSVP=TRUE:mailto:mike@example.org + END:VEVENT + END:VCALENDAR + + >> Response << + + HTTP/1.1 201 Created + Content-Length: 0 + + + +Daboo & Desruisseaux Standards Track [Page 61] + +RFC 6638 CalDAV Scheduling June 2012 + + + Date: Tue, 02 Jun 2009 18:52:54 GMT + Last-Modified: Tue, 02 Jun 2009 18:52:54 GMT + ETag: "d85561cfe74a4e785eb4639451b434fb" + Schedule-Tag: "488177c8-2ea7-4176-a6cb-fab8cfccdea2" + + Once the event creation has been completed, Cyrus's client will + retrieve the event back from the server to get the schedule status of + each "Attendee", as well as record the Schedule-Tag value for future + use. In this example, the server reports that a scheduling message + was delivered to Wilfredo, a scheduling message is still pending for + Bernard, and the server was unable to deliver a scheduling message to + Mike. + + >> Request << + + GET /home/cyrus/calendars/work/9263504FD3AD.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 18:52:58 GMT + Last-Modified: Tue, 02 Jun 2009 18:52:58 GMT + ETag: "eb897deabc8939589da116714bc99265" + Schedule-Tag: "488177c8-2ea7-4176-a6cb-fab8cfccdea2" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185300Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + TRANSP:OPAQUE + SUMMARY:Lunch + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT + =NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS= + 1.2:mailto:wilfredo@e + xample.com + + + + + +Daboo & Desruisseaux Standards Track [Page 62] + +RFC 6638 CalDAV Scheduling June 2012 + + + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS= + 1.0:mailto:bernard@example.net + ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-A + CTION;RSVP=TRUE;SCHEDULE-STATUS=3.7:mailto:mike@example.org + END:VEVENT + END:VCALENDAR + +B.2. Example: "Attendee" Receiving an Invitation + + In the following example, Wilfredo's client retrieves and deletes the + new scheduling message that appeared in his scheduling Inbox + collection after the server automatically processed it and created a + new scheduling object resource in his default calendar collection. + + >> Request << + + GET /home/wilfredo/calendars/inbox/27d93fc0a58c.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 18:59:58 GMT + Last-Modified: Tue, 02 Jun 2009 18:59:58 GMT + ETag: "da116714bc9926c89395895eb897deab" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + METHOD:REQUEST + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185254Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + TRANSP:OPAQUE + SUMMARY:Lunch + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT + =NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@ + example.com + + + + +Daboo & Desruisseaux Standards Track [Page 63] + +RFC 6638 CalDAV Scheduling June 2012 + + + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@ex + ample.net + ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-A + CTION;RSVP=TRUE:mailto:mike@example.org + END:VEVENT + END:VCALENDAR + + >> Request << + + DELETE /home/wilfredo/calendars/inbox/27d93fc0a58c.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 204 No Content + Date: Tue, 02 Jun 2009 20:40:36 GMT + +B.3. Example: "Attendee" Replying to an Invitation + + In the following example, Wilfredo accepts Cyrus's invitation and + sets an alarm reminder on the event. It uses the If-Schedule-Tag- + Match precondition behavior to ensure it does not overwrite any + significant changes from the "Organizer" that might have occurred + after it retrieved the initial resource data. + + >> Request << + + PUT /home/wilfredo/calendars/work/BB64861C2228.ics HTTP/1.1 + Host: cal.example.com + If-Schedule-Tag-Match: "e78f23ed-0188-4bab-938d-2aeb3324c7e8" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185254Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + TRANSP:OPAQUE + SUMMARY:Lunch + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + + + +Daboo & Desruisseaux Standards Track [Page 64] + +RFC 6638 CalDAV Scheduling June 2012 + + + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT + =ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@exam + ple.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@ex + ample.net + ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-A + CTION;RSVP=TRUE:mailto:mike@example.org + BEGIN:VALARM + TRIGGER:-PT15M + ACTION:DISPLAY + DESCRIPTION:Reminder + END:VALARM + END:VEVENT + END:VCALENDAR + + >> Response << + + HTTP/1.1 200 OK + Content-Length: 0 + Date: Tue, 02 Jun 2009 18:57:54 GMT + Last-Modified: Tue, 02 Jun 2009 18:57:54 GMT + ETag: "eb4639451b434fbd85561cfe74a4e785" + Schedule-Tag: "8893ee45-eb9d-428f-b53c-c777daf19e41" + + Once the event modification has been completed, Wilfredo's client + will retrieve the event back from the server to get the schedule + status of the "Organizer". + + >> Request << + + GET /home/wilfredo/calendars/work/BB64861C2228.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 19:03:03 GMT + Last-Modified: Tue, 02 Jun 2009 19:02:21 GMT + ETag: "5eb897deabda116714bc9926c8939589" + Schedule-Tag: "8893ee45-eb9d-428f-b53c-c777daf19e41" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 65] + +RFC 6638 CalDAV Scheduling June 2012 + + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T190221Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + TRANSP:OPAQUE + SUMMARY:Lunch + ORGANIZER;CN="Cyrus Daboo";SCHEDULE-STATUS=1.2:mailto:cyrus@ex + ample.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT + =ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:wilfredo@exam + ple.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@ex + ample.net + ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-A + CTION;RSVP=TRUE:mailto:mike@example.org + BEGIN:VALARM + TRIGGER:-PT15M + ACTION:DISPLAY + DESCRIPTION:Reminder + END:VALARM + END:VEVENT + END:VCALENDAR + +B.4. Example: "Organizer" Receiving a Reply to an Invitation + + On reception of Wilfredo's reply, Cyrus's server will automatically + update Cyrus's scheduling object resource, make Wilfredo's scheduling + message available in Cyrus's scheduling Inbox collection, and deliver + an updated scheduling message to Bernard to share Wilfredo's updated + participation status. In this example, Cyrus's client retrieves and + deletes this scheduling message in his scheduling Inbox collection. + + >> Request << + + GET /home/cyrus/calendars/inbox/c0a58c27d93f.ics HTTP/1.1 + Host: cal.example.com + + + + + + + +Daboo & Desruisseaux Standards Track [Page 66] + +RFC 6638 CalDAV Scheduling June 2012 + + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 19:05:02 GMT + Last-Modified: Tue, 02 Jun 2009 19:04:20 GMT + ETag: "9265eb897deabc8939589da116714bc9" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + METHOD:REPLY + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185754Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";PARTSTAT=ACCEPTED:mailto:w + ilfredo@example.com + REQUEST-STATUS:2.0;Success + END:VEVENT + END:VCALENDAR + + >> Request << + + DELETE /home/cyrus/calendars/inbox/c0a58c27d93f.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 204 No Content + Date: Tue, 02 Jun 2009 19:05:05 GMT + + Cyrus's client then retrieves the event back from the server with + Wilfredo's updated participation status. + + >> Request << + + GET /home/cyrus/calendars/work/9263504FD3AD.ics HTTP/1.1 + Host: cal.example.com + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 67] + +RFC 6638 CalDAV Scheduling June 2012 + + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 19:05:02 GMT + Last-Modified: Tue, 02 Jun 2009 19:04:20 GMT + ETag: "eb897deabc8939589da116714bc99265" + Schedule-Tag: "132cab27-1fe3-67ab-de13-abd348d1dee3" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T190420Z + DTSTART:20090602T160000Z + DTEND:20090602T170000Z + TRANSP:OPAQUE + SUMMARY:Lunch + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT + =ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=2.0: + mailto:wilfredo@example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE;SCHEDULE-STATUS=1 + .0:mailto:bernard@example.net + ATTENDEE;CN="Mike Douglass";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-A + CTION;RSVP=TRUE;SCHEDULE-STATUS=3.7:mailto:mike@example.org + END:VEVENT + END:VCALENDAR + + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 68] + +RFC 6638 CalDAV Scheduling June 2012 + + +B.5. Example: "Organizer" Requesting Busy Time Information + + In this example, Cyrus requests the busy time information of + Wilfredo, Bernard, and Mike. + + >> Request << + + POST /home/cyrus/calendars/outbox/ HTTP/1.1 + Host: cal.example.com + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + METHOD:REQUEST + BEGIN:VFREEBUSY + UID:4FD3AD926350 + DTSTAMP:20090602T190420Z + DTSTART:20090602T000000Z + DTEND:20090604T000000Z + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega":mailto:wilfredo@example.com + ATTENDEE;CN="Bernard Desruisseaux":mailto:bernard@example.net + ATTENDEE;CN="Mike Douglass":mailto:mike@example.org + END:VFREEBUSY + END:VCALENDAR + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 20:07:34 GMT + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + mailto:wilfredo@example.com + + 2.0;Success + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + METHOD:REPLY + BEGIN:VFREEBUSY + + + +Daboo & Desruisseaux Standards Track [Page 69] + +RFC 6638 CalDAV Scheduling June 2012 + + + UID:4FD3AD926350 + DTSTAMP:20090602T200733Z + DTSTART:20090602T000000Z + DTEND:20090604T000000Z + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega":mailto:wilfredo@example.com + FREEBUSY;FBTYPE=BUSY:20090602T110000Z/20090602T120000Z + FREEBUSY;FBTYPE=BUSY:20090603T170000Z/20090603T180000Z + END:VFREEBUSY + END:VCALENDAR + + + + + mailto:bernard@example.net + + 2.0;Success + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Server//EN + METHOD:REPLY + BEGIN:VFREEBUSY + UID:4FD3AD926350 + DTSTAMP:20090602T200733Z + DTSTART:20090602T000000Z + DTEND:20090604T000000Z + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux":mailto:bernard@example.net + FREEBUSY;FBTYPE=BUSY:20090602T150000Z/20090602T160000Z + FREEBUSY;FBTYPE=BUSY:20090603T090000Z/20090603T100000Z + FREEBUSY;FBTYPE=BUSY:20090603T180000Z/20090603T190000Z + END:VFREEBUSY + END:VCALENDAR + + + + + mailto:mike@example.org + + 3.7;Invalid calendar user + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 70] + +RFC 6638 CalDAV Scheduling June 2012 + + +B.6. Example: User Attempting to Invite "Attendee" on Behalf of + "Organizer" + + In the following example, Cyrus attempts to create, on behalf of + Wilfredo, an event with Bernard specified as an "Attendee". The + request fails, since Wilfredo didn't grant Cyrus the right to invite + other calendar users on his behalf. + + >> Request << + + PUT /home/wilfredo/calendars/work/def456.ics HTTP/1.1 + Host: cal.example.com + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + If-None-Match: * + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VEVENT + UID:3504F926D3AD + SEQUENCE:0 + DTSTAMP:20090602T190221Z + DTSTART:20090602T230000Z + DTEND:20090603T000000Z + TRANSP:OPAQUE + SUMMARY:Dinner + ORGANIZER;CN="Wilfredo Sanchez Vega":mailto:wilfredo@example.com + ATTENDEE;CN="Wilfredo Sanchez Vega";CUTYPE=INDIVIDUAL;PARTSTAT=A + CCEPTED:mailto:wilfredo@example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=NE + EDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@exampl + e.net + END:VEVENT + END:VCALENDAR + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 71] + +RFC 6638 CalDAV Scheduling June 2012 + + + >> Response << + + HTTP/1.1 403 Forbidden + Content-Type: application/xml; charset="utf-8" + Content-Length: xxxx + + + + + + /home/wilfredo/calendars/outbox/ + + + + + +B.7. Example: "Attendee" Declining an Instance of a Recurring Event + + In the following example, Bernard declines the second recurrence + instance of a daily recurring event he's been invited to by Cyrus. + + >> Request << + + PUT /home/bernard/calendars/work/4FD3AD926350.ics HTTP/1.1 + Host: cal.example.com + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + If-Schedule-Tag-Match: "7775FB30-7534-489E-A79A-0EA147B933EB" + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + TZID:America/Montreal + BEGIN:STANDARD + DTSTART:20071104T020000 + RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:20070311T020000 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + + + +Daboo & Desruisseaux Standards Track [Page 72] + +RFC 6638 CalDAV Scheduling June 2012 + + + END:VTIMEZONE + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185254Z + DTSTART;TZID=America/Montreal:20090601T150000 + DTEND;TZID=America/Montreal:20090601T160000 + RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 + TRANSP:OPAQUE + SUMMARY:Review Internet-Draft + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@exampl + e.net + END:VEVENT + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090603T183823Z + RECURRENCE-ID;TZID=America/Montreal:20090602T150000 + DTSTART;TZID=America/Montreal:20090602T150000 + DTEND;TZID=America/Montreal:20090602T160000 + TRANSP:TRANSPARENT + SUMMARY:Review Internet-Draft + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + DECLINED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@exampl + e.net + END:VEVENT + END:VCALENDAR + + >> Response << + + HTTP/1.1 200 OK + Content-Length: 0 + Date: Tue, 02 Jun 2009 18:52:54 GMT + Last-Modified: Tue, 02 Jun 2009 18:52:54 GMT + ETag: "d85561cfe74a4e785eb4639451b434fb" + Schedule-Tag: "488177c8-2ea7-4176-a6cb-fab8cfccdea2" + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 73] + +RFC 6638 CalDAV Scheduling June 2012 + + + Bernard's participation status update will cause his server to + deliver a scheduling message to Cyrus. Cyrus's client will find the + following reply message from Bernard in Cyrus's scheduling Inbox + collection: + + >> Request << + + GET /home/cyrus/calendars/inbox/9263504FD3AD.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 18:52:58 GMT + Last-Modified: Tue, 02 Jun 2009 18:52:58 GMT + ETag: "eb897deabc8939589da116714bc99265" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + METHOD:REPLY + BEGIN:VTIMEZONE + TZID:America/Montreal + BEGIN:STANDARD + DTSTART:20071104T020000 + RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:20070311T020000 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090603T183823Z + RECURRENCE-ID;TZID=America/Montreal:20090602T150000 + DTSTART;TZID=America/Montreal:20090602T150000 + DTEND;TZID=America/Montreal:20090602T160000 + SUMMARY:Review Internet-Draft + + + +Daboo & Desruisseaux Standards Track [Page 74] + +RFC 6638 CalDAV Scheduling June 2012 + + + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux";PARTSTAT=DECLINED: + mailto:bernard@example.net + REQUEST-STATUS:2.0;Success + END:VEVENT + END:VCALENDAR + +B.8. Example: "Attendee" Removing an Instance of a Recurring Event + + In the following example, Bernard removes from his calendar the third + recurrence instance of a daily recurring event he's been invited to + by Cyrus. This is accomplished by the addition of an "EXDATE" + property to the scheduling object resource stored by Bernard. + + >> Request << + + PUT /home/bernard/calendars/work/4FD3AD926350.ics HTTP/1.1 + Host: cal.example.com + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + If-Schedule-Tag-Match: "488177c8-2ea7-4176-a6cb-fab8cfccdea2" + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + BEGIN:VTIMEZONE + TZID:America/Montreal + BEGIN:STANDARD + DTSTART:20071104T020000 + RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:20070311T020000 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090602T185254Z + DTSTART;TZID=America/Montreal:20090601T150000 + DTEND;TZID=America/Montreal:20090601T160000 + + + +Daboo & Desruisseaux Standards Track [Page 75] + +RFC 6638 CalDAV Scheduling June 2012 + + + RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 + EXDATE;TZID=America/Montreal:20090603T150000 + TRANSP:OPAQUE + SUMMARY:Review Internet-Draft + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@exampl + e.net + END:VEVENT + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090603T183823Z + RECURRENCE-ID;TZID=America/Montreal:20090602T150000 + DTSTART;TZID=America/Montreal:20090602T150000 + DTEND;TZID=America/Montreal:20090602T160000 + TRANSP:TRANSPARENT + SUMMARY:Review Internet-Draft + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Cyrus Daboo";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED: + mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT= + DECLINED;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@exampl + e.net + END:VEVENT + END:VCALENDAR + + Bernard's deletion of a recurrence instance will cause his server to + deliver a scheduling message to Cyrus. Cyrus's client will find the + following reply message from Bernard in Cyrus's scheduling Inbox + collection: + + >> Request << + + GET /home/cyrus/calendars/inbox/6504923FD3AD.ics HTTP/1.1 + Host: cal.example.com + + >> Response << + + HTTP/1.1 200 OK + Date: Tue, 02 Jun 2009 18:52:58 GMT + Last-Modified: Tue, 02 Jun 2009 18:52:58 GMT + ETag: "eb897deabc8939589da116714bc99265" + Content-Type: text/calendar; charset="utf-8" + Content-Length: xxxx + + + + +Daboo & Desruisseaux Standards Track [Page 76] + +RFC 6638 CalDAV Scheduling June 2012 + + + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp.//CalDAV Client//EN + METHOD:REPLY + BEGIN:VTIMEZONE + TZID:America/Montreal + BEGIN:STANDARD + DTSTART:20071104T020000 + RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU + TZNAME:EST + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:20070311T020000 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU + TZNAME:EDT + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:9263504FD3AD + SEQUENCE:0 + DTSTAMP:20090603T183823Z + RECURRENCE-ID;TZID=America/Montreal:20090603T150000 + DTSTART;TZID=America/Montreal:20090603T150000 + DTEND;TZID=America/Montreal:20090603T160000 + SUMMARY:Review Internet-Draft + ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com + ATTENDEE;CN="Bernard Desruisseaux";PARTSTAT=DECLINED: + mailto:bernard@example.net + REQUEST-STATUS:2.0;Success + END:VEVENT + END:VCALENDAR + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 77] + +RFC 6638 CalDAV Scheduling June 2012 + + +Authors' Addresses + + Cyrus Daboo + Apple Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + EMail: cyrus@daboo.name + URI: http://www.apple.com/ + + + Bernard Desruisseaux + Oracle Corporation + 600 Blvd. de Maisonneuve West + Suite 1900 + Montreal, QC H3A 3J2 + CANADA + + EMail: bernard.desruisseaux@oracle.com + URI: http://www.oracle.com/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo & Desruisseaux Standards Track [Page 78] + diff --git a/doc/rfc6764-caldav-carddav-service-discovery.txt b/doc/rfc6764-caldav-carddav-service-discovery.txt new file mode 100644 index 0000000..aed17a8 --- /dev/null +++ b/doc/rfc6764-caldav-carddav-service-discovery.txt @@ -0,0 +1,787 @@ + + + + + + +Internet Engineering Task Force (IETF) C. Daboo +Request for Comments: 6764 Apple Inc. +Updates: 4791, 6352 February 2013 +Category: Standards Track +ISSN: 2070-1721 + + + Locating Services for Calendaring Extensions to + WebDAV (CalDAV) and vCard Extensions to WebDAV (CardDAV) + +Abstract + + This specification describes how DNS SRV records, DNS TXT records, + and well-known URIs can be used together or separately to locate + CalDAV (Calendaring Extensions to Web Distributed Authoring and + Versioning (WebDAV)) or CardDAV (vCard Extensions to WebDAV) + services. + +Status of This Memo + + This is an Internet Standards Track document. + + This document is a product of the Internet Engineering Task Force + (IETF). It represents the consensus of the IETF community. It has + received public review and has been approved for publication by the + Internet Engineering Steering Group (IESG). Further information on + Internet Standards is available in Section 2 of RFC 5741. + + Information about the current status of this document, any errata, + and how to provide feedback on it may be obtained at + http://www.rfc-editor.org/info/rfc6764. + +Copyright Notice + + Copyright (c) 2013 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (http://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. Code Components extracted from this document must + include Simplified BSD License text as described in Section 4.e of + the Trust Legal Provisions and are provided without warranty as + described in the Simplified BSD License. + + + + + +Daboo Standards Track [Page 1] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + +Table of Contents + + 1. Introduction ....................................................2 + 2. Conventions Used in This Document ...............................3 + 3. CalDAV SRV Service Labels .......................................3 + 4. CalDAV and CardDAV Service TXT Records ..........................4 + 5. CalDAV and CardDAV Service Well-Known URI .......................4 + 5.1. Example: Well-Known URI Redirects to Actual + "Context Path" .............................................5 + 6. Client "Bootstrapping" Procedures ...............................5 + 7. Guidance for Service Providers ..................................8 + 8. Security Considerations .........................................9 + 9. IANA Considerations .............................................9 + 9.1. Well-Known URI Registrations ...............................9 + 9.1.1. caldav Well-Known URI Registration .................10 + 9.1.2. carddav Well-Known URI Registration ................10 + 9.2. Service Name Registrations ................................10 + 9.2.1. caldav Service Name Registration ...................10 + 9.2.2. caldavs Service Name Registration ..................11 + 9.2.3. carddav Service Name Registration ..................11 + 9.2.4. carddavs Service Name Registration .................12 + 10. Acknowledgments ...............................................12 + 11. References ....................................................12 + 11.1. Normative References .....................................12 + 11.2. Informative References ...................................14 + +1. Introduction + + [RFC4791] defines the CalDAV calendar access protocol, based on HTTP + [RFC2616], for accessing calendar data stored on a server. CalDAV + clients need to be able to discover appropriate CalDAV servers within + their local area network and at other domains, e.g., to minimize the + need for end users to know specific details such as the fully + qualified domain name (FQDN) and port number for their servers. + + [RFC6352] defines the CardDAV address book access protocol based on + HTTP [RFC2616], for accessing contact data stored on a server. As + with CalDAV, clients also need to be able to discover CardDAV + servers. + + [RFC2782] defines a DNS-based service discovery protocol that has + been widely adopted as a means of locating particular services within + a local area network and beyond, using DNS SRV Resource Records + (RRs). This has been enhanced to provide additional service meta- + data by use of DNS TXT RRs as per [RFC6763]. + + + + + + +Daboo Standards Track [Page 2] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + This specification defines new SRV service types for the CalDAV + protocol and gives an example of how clients can use this together + with other protocol features to enable simple client configuration. + SRV service types for CardDAV are already defined in Section 11 of + [RFC6352]. + + Another issue with CalDAV or CardDAV service discovery is that the + service might not be located at the "root" URI of the HTTP server + hosting it. Thus, a client needs to be able to determine the + complete path component of the Request-URI to use in HTTP requests: + the "context path". For example, if CalDAV is implemented as a + "servlet" in a web server "container", the servlet "context path" + might be "/caldav/". So the URI for the CalDAV service would be, + e.g., "http://caldav.example.com/caldav/" rather than + "http://caldav.example.com/". SRV RRs by themselves only provide an + FQDN and port number for the service, not a path. Since the client + "bootstrapping" process requires initial access to the "context path" + of the service, there needs to be a simple way for clients to also + discover what that path is. + + This specification makes use of the "well-known URI" feature + [RFC5785] of HTTP servers to provide a well-known URI for CalDAV or + CardDAV services that clients can use. The well-known URI will point + to a resource on the server that is simply a "stub" resource that + provides a redirect to the actual "context path" resource + representing the service endpoint. + +2. Conventions Used in This Document + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + document are to be interpreted as described in [RFC2119]. + +3. CalDAV SRV Service Labels + + This specification adds two SRV service labels for use with CalDAV: + + _caldav: Identifies a CalDAV server that uses HTTP without + Transport Layer Security (TLS) [RFC2818]. + + _caldavs: Identifies a CalDAV server that uses HTTP with TLS + [RFC2818]. + + + + + + + + + +Daboo Standards Track [Page 3] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + Clients MUST honor Priority and Weight values in the SRV RRs, as + described by [RFC2782]. + + Example: service record for server without TLS + + _caldav._tcp SRV 0 1 80 calendar.example.com. + + Example: service record for server with TLS + + _caldavs._tcp SRV 0 1 443 calendar.example.com. + +4. CalDAV and CardDAV Service TXT Records + + When SRV RRs are used to advertise CalDAV and CardDAV services, it is + also convenient to be able to specify a "context path" in the DNS to + be retrieved at the same time. To enable that, this specification + uses a TXT RR that follows the syntax defined in Section 6 of + [RFC6763] and defines a "path" key for use in that record. The value + of the key MUST be the actual "context path" to the corresponding + service on the server. + + A site might provide TXT records in addition to SRV records for each + service. When present, clients MUST use the "path" value as the + "context path" for the service in HTTP requests. When not present, + clients use the ".well-known" URI approach described next. + + Example: text record for service with TLS + + _caldavs._tcp TXT path=/caldav + +5. CalDAV and CardDAV Service Well-Known URI + + Two ".well-known" URIs are registered by this specification for + CalDAV and CardDAV services, "caldav" and "carddav" respectively (see + Section 9). These URIs point to a resource that the client can use + as the initial "context path" for the service they are trying to + connect to. The server MUST redirect HTTP requests for that resource + to the actual "context path" using one of the available mechanisms + provided by HTTP (e.g., using a 301, 303, or 307 response). Clients + MUST handle HTTP redirects on the ".well-known" URI. Servers MUST + NOT locate the actual CalDAV or CardDAV service endpoint at the + ".well-known" URI as per Section 1.1 of [RFC5785]. + + Servers SHOULD set an appropriate Cache-Control header value (as per + Section 14.9 of [RFC2616]) in the redirect response to ensure caching + occurs or does not occur as needed or as required by the type of + response generated. For example, if it is anticipated that the + + + + +Daboo Standards Track [Page 4] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + location of the redirect might change over time, then a "no-cache" + value would be used. + + To facilitate "context paths" that might differ from user to user, + the server MAY require authentication when a client tries to access + the ".well-known" URI (i.e., the server would return a 401 status + response to the unauthenticated request from the client, then return + the redirect response only after a successful authentication by the + client). + +5.1. Example: Well-Known URI Redirects to Actual "Context Path" + + A CalDAV server has a "context path" that is "/servlet/caldav". The + client will use "/.well-known/caldav" as the path for its + "bootstrapping" process after it has first found the FQDN and port + number via an SRV lookup or via manual entry of information by the + user, from which the client can parse suitable information. When the + client makes an HTTP request against "/.well-known/caldav", the + server would issue an HTTP redirect response with a Location response + header using the path "/servlet/caldav". The client would then + "follow" this redirect to the new resource and continue making HTTP + requests there to complete its "bootstrapping" process. + +6. Client "Bootstrapping" Procedures + + This section describes a procedure that CalDAV or CardDAV clients + SHOULD use to do their initial configuration based on minimal user + input. The goal is to determine an http: or https: URI that + describes the full path to the user's principal-URL [RFC3744]. + + 1. Processing user input: + + * For a CalDAV server: + + + Minimal input from a user would consist of a calendar user + address and a password. A calendar user address is defined + by iCalendar [RFC5545] to be a URI [RFC3986]. Provided a + user identifier and a domain name can be extracted from the + URI, this simple "bootstrapping" configuration can be done. + + + If the calendar user address is a "mailto:" [RFC6068] URI, + the "mailbox" portion of the URI is examined, and the + "local-part" and "domain" portions are extracted. + + + If the calendar user address is an "http:" [RFC2616] or + "https:" [RFC2818] URI, the "userinfo" and "host" portion + of the URI [RFC3986] is extracted. + + + + +Daboo Standards Track [Page 5] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + * For a CardDAV server: + + + Minimal input from a user would consist of their email + address [RFC5322] for the domain where the CardDAV service + is hosted, and a password. The "mailbox" portion of the + email address is examined, and the "local-part" and + "domain" portions are extracted. + + 2. Determination of service FQDN and port number: + + * An SRV lookup for _caldavs._tcp (for CalDAV) or _carddavs._tcp + (for CardDAV) is done with the extracted "domain" as the + service domain. + + * If no result is found, the client can try _caldav._tcp (for + CalDAV) or _carddav._tcp (for CardDAV) provided non-TLS + connections are appropriate. + + * If an SRV record is returned, the client extracts the target + FQDN and port number. If multiple SRV records are returned, + the client MUST use the Priority and Weight fields in the + record to determine which one to pick (as per [RFC2782]). + + * If an SRV record is not found, the client will need to prompt + the user to enter the FQDN and port number information + directly or use some other heuristic, for example, using the + extracted "domain" as the FQDN and default HTTPS or HTTP port + numbers. In this situation, clients MUST first attempt an + HTTP connection with TLS. + + 3. Determination of initial "context path": + + * When an SRV lookup is done and a valid SRV record returned, + the client MUST also query for a corresponding TXT record and + check for the presence of a "path" key in its response. If + present, the value of the "path" key is used for the initial + "context path". + + * When an initial "context path" has not been determined from a + TXT record, the initial "context path" is taken to be + "/.well-known/caldav" (for CalDAV) or "/.well-known/carddav" + (for CardDAV). + + * If the initial "context path" derived from a TXT record + generates HTTP errors when targeted by requests, the client + SHOULD repeat its "bootstrapping" procedure using the + appropriate ".well-known" URI instead. + + + + +Daboo Standards Track [Page 6] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + 4. Determination of user identifier: + + * The client will need to make authenticated HTTP requests to + the service. Typically, a "user identifier" is required for + some form of user/password authentication. When a user + identifier is required, clients MUST first use the "mailbox" + portion of the calendar user address provided by the user in + the case of a "mailto:" address and, if that results in an + authentication failure, SHOULD fall back to using the "local- + part" extracted from the "mailto:" address. For an "http:" or + "https:" calendar user address, the "userinfo" portion is used + as the user identifier for authentication. This is in line + with the guidance outlined in Section 7. If these user + identifiers result in authentication failure, the client + SHOULD prompt the user for a valid identifier. + + 5. Connecting to the service: + + * Subsequent to configuration, the client will make HTTP + requests to the service. When using "_caldavs" or "_carddavs" + services, a TLS negotiation is done immediately upon + connection. The client MUST do certificate verification using + the procedure outlined in Section 6 of [RFC6125] in regard to + verification with an SRV RR as the starting point. + + * The client does a "PROPFIND" [RFC4918] request with the + request URI set to the initial "context path". The body of + the request SHOULD include the DAV:current-user-principal + [RFC5397] property as one of the properties to return. Note + that clients MUST properly handle HTTP redirect responses for + the request. The server will use the HTTP authentication + procedure outlined in [RFC2617] or use some other appropriate + authentication schemes to authenticate the user. + + * If the server returns a 404 ("Not Found") HTTP status response + to the request on the initial "context path", clients MAY try + repeating the request on the "root" URI "/" or prompt the user + for a suitable path. + + * If the DAV:current-user-principal property is returned on the + request, the client uses that value for the principal-URL of + the authenticated user. With that, it can execute a + "PROPFIND" request on the principal-URL and discover + additional properties for configuration (e.g., calendar or + address book "home" collections). + + + + + + +Daboo Standards Track [Page 7] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + * If the DAV:current-user-principal property is not returned, + then the client will need to request the principal-URL path + from the user in order to continue with configuration. + + Once a successful account discovery step has been done, clients + SHOULD cache the service details that were successfully used (user + identity, principal-URL with full scheme/host/port details) and reuse + those when connecting again at a later time. + + If a subsequent connection attempt fails, or authentication fails + persistently, clients SHOULD retry the SRV lookup and account + discovery to "refresh" the cached data. + +7. Guidance for Service Providers + + Service providers wanting to offer CalDAV or CardDAV services that + can be configured by clients using SRV records need to follow certain + procedures to ensure proper operation. + + o CalDAV or CardDAV servers SHOULD be configured to allow + authentication with calendar user addresses (just taking the + "mailbox" portion of any "mailto:" URI) or email addresses + respectively, or with "user identifiers" extracted from them. In + the former case, the addresses MUST NOT conflict with other forms + of a permitted user login name. In the latter case, the extracted + "user identifiers" need to be unique across the server and MUST + NOT conflict with any login name on the server. + + o Servers MUST force authentication for "PROPFIND" requests that + retrieve the DAV:current-user-principal property to ensure that + the value of the DAV:current-user-principal property returned + corresponds to the principal-URL of the user making the request. + + o If the service provider uses TLS, the service provider MUST ensure + a certificate is installed that can be verified by clients using + the procedure outlined in Section 6 of [RFC6125] in regard to + verification with an SRV RR as the starting point. In particular, + certificates SHOULD include SRV-ID and DNS-ID identifiers as + appropriate, as described in Section 8. + + o Service providers should install the appropriate SRV records for + the offered services and optionally include TXT records. + + + + + + + + + +Daboo Standards Track [Page 8] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + +8. Security Considerations + + Clients that support TLS as defined by [RFC2818] SHOULD try the + "_caldavs" or "_carddavs" services first before trying the "_caldav" + or "_carddav" services respectively. If a user has explicitly + requested a connection with TLS, the client MUST NOT use any service + information returned for the "_caldav" or "_carddav" services. + Clients MUST follow the certificate-verification process specified in + [RFC6125]. + + A malicious attacker with access to the DNS server data, or that is + able to get spoofed answers cached in a recursive resolver, can + potentially cause clients to connect to any server chosen by the + attacker. In the absence of a secure DNS option, clients SHOULD + check that the target FQDN returned in the SRV record matches the + original service domain that was queried. If the target FQDN is not + in the queried domain, clients SHOULD verify with the user that the + SRV target FQDN is suitable for use before executing any connections + to the host. Alternatively, if TLS is being used for the service, + clients MUST use the procedure outlined in Section 6 of [RFC6125] to + verify the service. When the target FQDN does not match the original + service domain that was queried, clients MUST check the SRV-ID + identifier in the server's certificate. If the FQDN does match, + clients MUST check any SRV-ID identifiers in the server's certificate + or, if no SRV-ID identifiers are present, MUST check the DNS-ID + identifiers in the server's certificate. + + Implementations of TLS [RFC5246], used as the basis for TLS + ([RFC2818]), typically support multiple versions of the protocol as + well as the older SSL (Secure Sockets Layer) protocol. Because of + known security vulnerabilities, clients and servers MUST NOT request, + offer, or use SSL 2.0. See Appendix E.2 of [RFC5246] for further + details. + +9. IANA Considerations + +9.1. Well-Known URI Registrations + + This document defines two ".well-known" URIs using the registration + procedure and template from Section 5.1 of [RFC5785]. + + + + + + + + + + + +Daboo Standards Track [Page 9] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + +9.1.1. caldav Well-Known URI Registration + + URI suffix: caldav + + Change controller: IETF + + Specification document(s): This RFC + + Related information: See also [RFC4791]. + +9.1.2. carddav Well-Known URI Registration + + URI suffix: carddav + + Change controller: IETF + + Specification document(s): This RFC + + Related information: See also [RFC6352]. + +9.2. Service Name Registrations + + This document registers four new service names as per [RFC6335]. Two + are defined in this document, and two are defined in [RFC6352], + Section 11. + +9.2.1. caldav Service Name Registration + + Service Name: caldav + + Transport Protocol(s): TCP + + Assignee: IESG + + Contact: IETF Chair + + Description: Calendaring Extensions to WebDAV (CalDAV) - non-TLS + + Reference: [RFC6764] + + Assignment Note: This is an extension of the http service. Defined + TXT keys: path= + + + + + + + + + +Daboo Standards Track [Page 10] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + +9.2.2. caldavs Service Name Registration + + Service Name: caldavs + + Transport Protocol(s): TCP + + Assignee: IESG + + Contact: IETF Chair + + Description: Calendaring Extensions to WebDAV (CalDAV) - over TLS + + Reference: [RFC6764] + + Assignment Note: This is an extension of the https service. Defined + TXT keys: path= + +9.2.3. carddav Service Name Registration + + Service Name: carddav + + Transport Protocol(s): TCP + + Assignee: IESG + + Contact: IETF Chair + + Description: vCard Extensions to WebDAV (CardDAV) - non-TLS + + Reference: [RFC6352] + + Assignment Note: This is an extension of the http service. Defined + TXT keys: path= + + + + + + + + + + + + + + + + + + +Daboo Standards Track [Page 11] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + +9.2.4. carddavs Service Name Registration + + Service Name: carddavs + + Transport Protocol(s): TCP + + Assignee: IESG + + Contact: IETF Chair + + Description: vCard Extensions to WebDAV (CardDAV) - over TLS + + Reference: [RFC6352] + + Assignment Note: This is an extension of the https service. Defined + TXT keys: path= + +10. Acknowledgments + + This specification was suggested by discussion that took place within + the Calendaring and Scheduling Consortium's CalDAV Technical + Committee. The author thanks the following for their contributions: + Stuart Cheshire, Bernard Desruisseaux, Eran Hammer-Lahav, Helge Hess, + Arnaud Quillaud, Wilfredo Sanchez, and Joe Touch. + +11. References + +11.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., + Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext + Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999. + + [RFC2617] Franks, J., Hallam-Baker, P., Hostetler, J., Lawrence, S., + Leach, P., Luotonen, A., and L. Stewart, "HTTP + Authentication: Basic and Digest Access Authentication", + RFC 2617, June 1999. + + [RFC2782] Gulbrandsen, A., Vixie, P., and L. Esibov, "A DNS RR for + specifying the location of services (DNS SRV)", RFC 2782, + February 2000. + + [RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, May 2000. + + + + + +Daboo Standards Track [Page 12] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + + [RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web + Distributed Authoring and Versioning (WebDAV) + Access Control Protocol", RFC 3744, May 2004. + + [RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform + Resource Identifier (URI): Generic Syntax", STD 66, + RFC 3986, January 2005. + + [RFC4791] Daboo, C., Desruisseaux, B., and L. Dusseault, + "Calendaring Extensions to WebDAV (CalDAV)", RFC 4791, + March 2007. + + [RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed + Authoring and Versioning (WebDAV)", RFC 4918, June 2007. + + [RFC5246] Dierks, T. and E. Rescorla, "The Transport Layer Security + (TLS) Protocol Version 1.2", RFC 5246, August 2008. + + [RFC5322] Resnick, P., Ed., "Internet Message Format", RFC 5322, + October 2008. + + [RFC5397] Sanchez, W. and C. Daboo, "WebDAV Current Principal + Extension", RFC 5397, December 2008. + + [RFC5785] Nottingham, M. and E. Hammer-Lahav, "Defining Well-Known + Uniform Resource Identifiers (URIs)", RFC 5785, + April 2010. + + [RFC6068] Duerst, M., Masinter, L., and J. Zawinski, "The 'mailto' + URI Scheme", RFC 6068, October 2010. + + [RFC6125] Saint-Andre, P. and J. Hodges, "Representation and + Verification of Domain-Based Application Service Identity + within Internet Public Key Infrastructure Using X.509 + (PKIX) Certificates in the Context of Transport Layer + Security (TLS)", RFC 6125, March 2011. + + [RFC6335] Cotton, M., Eggert, L., Touch, J., Westerlund, M., and S. + Cheshire, "Internet Assigned Numbers Authority (IANA) + Procedures for the Management of the Service Name and + Transport Protocol Port Number Registry", BCP 165, + RFC 6335, August 2011. + + [RFC6352] Daboo, C., "CardDAV: vCard Extensions to Web Distributed + Authoring and Versioning (WebDAV)", RFC 6352, August 2011. + + [RFC6763] Cheshire, S. and M. Krochmal, "DNS-Based Service + Discovery", RFC 6763, February 2013. + + + +Daboo Standards Track [Page 13] + +RFC 6764 SRV for CalDAV & CardDAV February 2013 + + +11.2. Informative References + + [RFC5545] Desruisseaux, B., "Internet Calendaring and Scheduling + Core Object Specification (iCalendar)", RFC 5545, + September 2009. + +Author's Address + + Cyrus Daboo + Apple Inc. + 1 Infinite Loop + Cupertino, CA 95014 + USA + + EMail: cyrus@daboo.name + URI: http://www.apple.com/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Daboo Standards Track [Page 14] + diff --git a/doc/undraw-license.pdf b/doc/undraw-license.pdf new file mode 100644 index 0000000..5ac7420 Binary files /dev/null and b/doc/undraw-license.pdf differ diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt new file mode 100644 index 0000000..69104c3 --- /dev/null +++ b/fastlane/metadata/android/ar/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ هو تطبيق إدارة ومزامنة لآندرويد يتكامل مع التطبيقات الأصيلة للتقويم وجهات الاتصال في نظام آندرويد. + +استخدمه مع خادمك الخاص أو مع جهة استضافة تثق بها لإبقاء جهات اتصالك و أحداثك و مهامك تحت سيطرتك. + +للحصول على قائمة بالخادمات/الخدمات التي تمت تجربتها ، وللمزيد من المعلومات ، ألق نظرة على موقع الويب. diff --git a/fastlane/metadata/android/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 100644 index 0000000..6d35f91 --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +مزامنة وعميل لـ CalDAV/CardDAV diff --git a/fastlane/metadata/android/bg/full_description.txt b/fastlane/metadata/android/bg/full_description.txt new file mode 100644 index 0000000..4721a73 --- /dev/null +++ b/fastlane/metadata/android/bg/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ е приложение за Android, което синхронизира данни по протокола CalDAV/CardDAV и се интегрира с приложенията за календар и контакти. + +Използвайте го със собствен сървър или с доверен доставчик и дръжте контактите, събитията и задачите си под свой контрол. + +За повече информация и списък на проверени сървъри и услуги посетете страницата. diff --git a/fastlane/metadata/android/bg/short_description.txt b/fastlane/metadata/android/bg/short_description.txt new file mode 100644 index 0000000..70c7b4f --- /dev/null +++ b/fastlane/metadata/android/bg/short_description.txt @@ -0,0 +1 @@ +Синхронизация и клиент за CalDAV/CardDAV diff --git a/fastlane/metadata/android/ca/full_description.txt b/fastlane/metadata/android/ca/full_description.txt new file mode 100644 index 0000000..ac75cf2 --- /dev/null +++ b/fastlane/metadata/android/ca/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ és una aplicació que permet administrar i sincronitzar CalDAV/CardDAV, i s'integra nativament amb les aplicacions de contactes/calendari. + +Es pot utilitzar amb un servidor propi, o bé amb un proveïdor de confiança que permeti mantenir els contactes, esdeveniments i tasques sota el teu control. + +Per obtenir més informació i una llista de servidors/serveis verificats, dirigeix-te al lloc web. diff --git a/fastlane/metadata/android/ca/short_description.txt b/fastlane/metadata/android/ca/short_description.txt new file mode 100644 index 0000000..e542763 --- /dev/null +++ b/fastlane/metadata/android/ca/short_description.txt @@ -0,0 +1 @@ +Client i sincronització de CalDAV/CardDAV diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt new file mode 100644 index 0000000..f46e06f --- /dev/null +++ b/fastlane/metadata/android/cs/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ je aplikace pro správu a synchronizaci CalDAV/CardDAV pro Android, která se nativně propojuje s aplikacemi kalendář/kontakty v systému Android. + +Použijte ji ve spojení s vlastním serverem nebo hostingem, kterému důvěřujete a uchovejte si tak kontrolu nad svými kontakty, událostmi a úkoly. + +Další informace a seznam vyzkoušených serverů/služeb naleznete na webových stránkách projektu. diff --git a/fastlane/metadata/android/cs/short_description.txt b/fastlane/metadata/android/cs/short_description.txt new file mode 100644 index 0000000..0006e6d --- /dev/null +++ b/fastlane/metadata/android/cs/short_description.txt @@ -0,0 +1 @@ +Synchronizace CalDAV/CardDAV a klient diff --git a/fastlane/metadata/android/da/full_description.txt b/fastlane/metadata/android/da/full_description.txt new file mode 100644 index 0000000..8dd7d9c --- /dev/null +++ b/fastlane/metadata/android/da/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ er en CalDAV/CardDAV håndtering og synkronisering program til Android som naturlig integrere med Android kalender/kontakt programmer. + +Brug den med lokal server eller med troværdig vært for at gemme kontakter, begivenheder og opgaver under egen kontrol. + +For mere information og en liste over testede server/tjenester, gå til netstedet. diff --git a/fastlane/metadata/android/da/short_description.txt b/fastlane/metadata/android/da/short_description.txt new file mode 100644 index 0000000..230bc1e --- /dev/null +++ b/fastlane/metadata/android/da/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV synkronisering og klient diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt new file mode 100644 index 0000000..7e8d54b --- /dev/null +++ b/fastlane/metadata/android/de/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ ist eine CalDAV/CardDAV-Verwaltungs- und Synchronisierungs-App für Android, die sich nahtlos mit Kalender- und Kontakte-Apps integriert. + +Mit DAVx⁵ haben Sie Ihre Kontakte, Termine und Aufgaben auf Ihrem eigenen Server oder einem vertrauenswürdigen CalDAV/CardDAV-Dienst unter eigener Kontrolle! + +Weitere Informationen und eine Liste der getesteten Server/Dienste finden sich auf der Website. diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt new file mode 100644 index 0000000..6cd2b62 --- /dev/null +++ b/fastlane/metadata/android/de/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV-Synchronisierung und -Client diff --git a/fastlane/metadata/android/el/full_description.txt b/fastlane/metadata/android/el/full_description.txt new file mode 100644 index 0000000..a5b5299 --- /dev/null +++ b/fastlane/metadata/android/el/full_description.txt @@ -0,0 +1,5 @@ +Το DAVx⁵ είναι μια εφαρμογή διαχείρισης και συγχρονισμού CalDAV/CardDAV για το Android που ενσωματώνει εγγενώς τις εφαρμογές Android ημερολογίου/επαφών. + +Χρησιμοποιήστε το στον δικό σας διακομιστή ή με έναν έμπιστο διακομιστή για την διατήρηση των επαφών, των συμβάντων και εργασιών υπό τον έλεγχό σας. + +Για περισσότερες πληροφορίες και λίστα των δοκιμασμένων διακομιστών/υπηρεσιών, δείτε στην ιστοσελίδα. diff --git a/fastlane/metadata/android/el/short_description.txt b/fastlane/metadata/android/el/short_description.txt new file mode 100644 index 0000000..428c067 --- /dev/null +++ b/fastlane/metadata/android/el/short_description.txt @@ -0,0 +1 @@ +Συγχρονισμός CalDAV/CardDAV με πελάτη diff --git a/fastlane/metadata/android/en-GB/full_description.txt b/fastlane/metadata/android/en-GB/full_description.txt new file mode 100644 index 0000000..f2ab1bb --- /dev/null +++ b/fastlane/metadata/android/en-GB/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ is a CalDAV/CardDAV management and synchronisation app for Android which natively integrates with Android calendar/contact apps. + +Use it with your own server or with a trusted host to keep your contacts, events and tasks under your control. + +For more information and a list of tested servers/services, have a look at the Web site. \ No newline at end of file diff --git a/fastlane/metadata/android/en-GB/short_description.txt b/fastlane/metadata/android/en-GB/short_description.txt new file mode 100644 index 0000000..df2e92a --- /dev/null +++ b/fastlane/metadata/android/en-GB/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV Synchronisation and Client \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/300000003.txt b/fastlane/metadata/android/en-US/changelogs/300000003.txt new file mode 100644 index 0000000..a9366fb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/300000003.txt @@ -0,0 +1,6 @@ +* use freshly designed AppIntro fragments instead of the previous start-up fragments +* About dialog: show translations, updated library view +* use latest version of okhttp (4.5 instead of 3.12); dropping support for API level <21 (Android 5) +* support for Brotli HTTP compression (if supported by server) +* Nextcloud Login Flow: show progress bar and error messages if something goes wrong +* many small bug fixes, improvements, optimizations diff --git a/fastlane/metadata/android/en-US/changelogs/301000012.txt b/fastlane/metadata/android/en-US/changelogs/301000012.txt new file mode 100644 index 0000000..f4a95aa --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/301000012.txt @@ -0,0 +1,6 @@ +* reworked permissions: permissions are now clearly managed application-wide (no unclear notifications about missing permissions anymore) +* minify VTIMEZONEs (doesn’t include observances for times before DTSTART anymore) of sent events/tasks +* sync algorithm: reworking with improvements in parallelism, cancelling, exception handling (using parallel coroutines) +* account creation/login: suggest all detected email addresses as account name +* internal rewrite: now uses latest ical4j/3.x even on Android 5+ with R8 desugaring +* minor improvements and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/302000003.txt b/fastlane/metadata/android/en-US/changelogs/302000003.txt new file mode 100644 index 0000000..46342b6 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/302000003.txt @@ -0,0 +1,9 @@ +* always set "Sync over WiFi only" when data saver is active +* show CalDAV/CardDAV account settings only when CalDAV/CardDAV is present +* login: show "username/password wrong" when 401 is encountered during resource detection +* various minor improvements and fixes + +CalDAV: + +* improvements for CalDAV Scheduling, including support for Schedule-Tag +* more compatible iCalendar generation diff --git a/fastlane/metadata/android/en-US/changelogs/303000006.txt b/fastlane/metadata/android/en-US/changelogs/303000006.txt new file mode 100644 index 0000000..462cc28 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303000006.txt @@ -0,0 +1,8 @@ +* accounts overview: show sync/refresh status of every account with a progress bar +* accounts overview: new *Sync all accounts* action +* launcher shortcut: *Sync all accounts* (available as DAVx⁵ has been started the first time); can be used as widget +* new debug info/share logs screen + * improved UI (including specific messages for common errors) + * improved debug info (more information, better readability) + * *Share* action now generates a ZIP file with debug info (and logs, if available) + diff --git a/fastlane/metadata/android/en-US/changelogs/303010001.txt b/fastlane/metadata/android/en-US/changelogs/303010001.txt new file mode 100644 index 0000000..237f5be --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303010001.txt @@ -0,0 +1 @@ +Fix for Google Contacts / Android 11: export address book authenticator so that address books are shown in Google Contacts again diff --git a/fastlane/metadata/android/en-US/changelogs/303020005.txt b/fastlane/metadata/android/en-US/changelogs/303020005.txt new file mode 100644 index 0000000..584754c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303020005.txt @@ -0,0 +1,2 @@ +* support for tasks synchronization with Tasks app (tasks.org) [not all fields, see https://tasks.org/docs/sync.html] +* minor improvements, bug fixes, translations diff --git a/fastlane/metadata/android/en-US/changelogs/303030005.txt b/fastlane/metadata/android/en-US/changelogs/303030005.txt new file mode 100644 index 0000000..1fda161 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303030005.txt @@ -0,0 +1,7 @@ +* support for Android 11 + - ask for permission to keep permissions in Permissions fragment + - handle background location permission for WiFi SSID restriction as required for Android 11 + - declare requirements for package visibility +* login over Nextcloud app: use Login Flow v2 and Custom Tabs to support WebAuthn; so for instance 2FA with Yubikey is now supported +* minor improvements, bug fixes (including a big where OpenTasks was not shown in the permissions fragment when tasks.org was not installed), translation updates + diff --git a/fastlane/metadata/android/en-US/changelogs/303060003.txt b/fastlane/metadata/android/en-US/changelogs/303060003.txt new file mode 100644 index 0000000..92317c3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303060003.txt @@ -0,0 +1,6 @@ +* when an account is created, open it immediately after setup +* don't ask for active location services on Android <9 / location permissions on Android 8.0 +* custom certificates: make it possible to trust different certificates with the same CN +* don't sync Webcal-only calendars/tasks lists over CalDAV (may cause duplicate calendars) +* better Android 11 compatibility +* minor improvements and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/303070004.txt b/fastlane/metadata/android/en-US/changelogs/303070004.txt new file mode 100644 index 0000000..1f5a07a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303070004.txt @@ -0,0 +1,5 @@ +* account view: new setting "show only personal collections" (shows only collections from homesets, especially useful with for instance eGroupware) +* account view: show owner in collection properties (if available) +* allow to select client certificates for existing accounts +* save sync intervals and chech/repair them at app start and reboot +* other improvements and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/303100003.txt b/fastlane/metadata/android/en-US/changelogs/303100003.txt new file mode 100644 index 0000000..2024f98 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303100003.txt @@ -0,0 +1,4 @@ +* sync VEVENT URL in a way that calendar apps can use it although it’s not natively supported by Android – currently supported by aCalendar+ (2.5 and newer) +* authentication with client certificates: fix occasional HTTP/400 errors +* debug info: allow sharing only after debug info is generated +* library updates, new translations diff --git a/fastlane/metadata/android/en-US/changelogs/303110004.txt b/fastlane/metadata/android/en-US/changelogs/303110004.txt new file mode 100644 index 0000000..ffd2d0a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303110004.txt @@ -0,0 +1,6 @@ +* support for dark theme; new option to force light/dark theme +* [CalDAV] assume 1 day/1 hour for Android events without dtEnd and duration (should not occur, but occurs) +* [CardDAV] don’t convert TYPEs into explicit custom labels +* [Android 11] fix problems when viewing URLs in some default browsers +* minor improvements and bug fixes; updated translations + diff --git a/fastlane/metadata/android/en-US/changelogs/303120005.txt b/fastlane/metadata/android/en-US/changelogs/303120005.txt new file mode 100644 index 0000000..a2c8b84 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/303120005.txt @@ -0,0 +1,4 @@ +* login: provide auto-completion for some common base URLs +* debug info: better zip sharing +* minor bug fixes and improvements + diff --git a/fastlane/metadata/android/en-US/changelogs/328.txt b/fastlane/metadata/android/en-US/changelogs/328.txt new file mode 100644 index 0000000..7488e23 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/328.txt @@ -0,0 +1,5 @@ +* account settings: new setting "default reminder"; improved re-loading of entries when settings which have impact on parsing/processing are changed +* improved resource detection: detect address books/calendars when they are identical with their home-set +* link to privacy policy added to navigation drawer +* vCard sync: improve compatibility (for instance with Samsung "Edge panel") +* minor improvements and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/329.txt b/fastlane/metadata/android/en-US/changelogs/329.txt new file mode 100644 index 0000000..79798ab --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/329.txt @@ -0,0 +1 @@ +Update libraries, including okhttp, to fix a crash when resource detection with HTTP/2 connections is cancelled by user diff --git a/fastlane/metadata/android/en-US/changelogs/331.txt b/fastlane/metadata/android/en-US/changelogs/331.txt new file mode 100644 index 0000000..20cc92f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/331.txt @@ -0,0 +1,4 @@ +* calendar sync: delete exceptions from events when events are mass-deleted, to +* events/tasks: improve handling of VALARM (REL=END, VALUE=DATE-TIME) +* Webcal calendars: use UrlUtils.equals to find matching calendar +* new translation and libraries diff --git a/fastlane/metadata/android/en-US/changelogs/400000005.txt b/fastlane/metadata/android/en-US/changelogs/400000005.txt new file mode 100644 index 0000000..cb24fd3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/400000005.txt @@ -0,0 +1 @@ +Added WebDAV mounts over Storage Access Framework (see forum and manual for more information) diff --git a/fastlane/metadata/android/en-US/changelogs/401000005.txt b/fastlane/metadata/android/en-US/changelogs/401000005.txt new file mode 100644 index 0000000..c029135 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/401000005.txt @@ -0,0 +1,5 @@ +- improve WebDAV access and cache +- debug info: show available/total storage memory +- improve SRV record resolving (especially on Android ≥10) +- fixed a bug when the FN field was not present in a contacts vcard 4 +- minor optimizations and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/401010002.txt b/fastlane/metadata/android/en-US/changelogs/401010002.txt new file mode 100644 index 0000000..2c9c00e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/401010002.txt @@ -0,0 +1,5 @@ +* add warning to avoid apostrophes in account names +* show a warning when disk space is low, which will stop synchronization +* delete dirty events without instances before syncing +* minor optimizations and bug fixes + diff --git a/fastlane/metadata/android/en-US/changelogs/402000006.txt b/fastlane/metadata/android/en-US/changelogs/402000006.txt new file mode 100644 index 0000000..0960777 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/402000006.txt @@ -0,0 +1,3 @@ +* new integration: jtx Board integration: Sync Journals, Notes and Tasks with "jtx Board" app is now possible. jtx Board is using the open standards VJOURNAL and VTODO +* new function: SOCKS proxy support for .onion URLs with Orbot/Tor +* allows saving high resolution contact photos diff --git a/fastlane/metadata/android/en-US/changelogs/402020000.txt b/fastlane/metadata/android/en-US/changelogs/402020000.txt new file mode 100644 index 0000000..498e49a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/402020000.txt @@ -0,0 +1,2 @@ +- improve compatibility with jtxBoard +- minor improvements and bugfixes diff --git a/fastlane/metadata/android/en-US/changelogs/402030001.txt b/fastlane/metadata/android/en-US/changelogs/402030001.txt new file mode 100644 index 0000000..ac02938 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/402030001.txt @@ -0,0 +1,3 @@ +* CalDAV: refactor and improve event validation and repairing (especially with recurring events) +* WebDAV: connections now have a delete symbol, so that people know that they would delete the connection +* minor bug fixes and improvements diff --git a/fastlane/metadata/android/en-US/changelogs/402040002.txt b/fastlane/metadata/android/en-US/changelogs/402040002.txt new file mode 100644 index 0000000..50bcbd8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/402040002.txt @@ -0,0 +1,13 @@ +- [CardDAV] vCard LABELs are not generated anymore - now there's only the structured address +- [CardDAV] better support for read-only address books (make contacts "really" read-only as good as possible) +- [CalDAV] handle a rare exception when a time zone is known to Android but not to ical4j +- [UI] use Material switches for preferences +- [UI] use switches instead of checkboxes in accounts so that it is more understandable what should be synchronized +- [UI] add monochrome/themed icon +- [UI] added some pre-set base URLs +- [UI] updated Manual URL (the manual is now automatically updated by Github Actions after editing it) +- [jtx Board] remove filesize restriction for attachments +- improved compatibility (especially HUAWEI to display DAVx5 calendars there again) +- fix a bug that caused the account view to be empty after renaming an account +- fix a crash when accounts vanish somehow +- new languages (thanks to our translators!) diff --git a/fastlane/metadata/android/en-US/changelogs/402050001.txt b/fastlane/metadata/android/en-US/changelogs/402050001.txt new file mode 100644 index 0000000..0ea19fc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/402050001.txt @@ -0,0 +1,3 @@ +* marked as compatible with Android 13 (handle notification permission) +* better read-only address book support +* minor improvements and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/403000005.txt b/fastlane/metadata/android/en-US/changelogs/403000005.txt new file mode 100644 index 0000000..92f8c83 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/403000005.txt @@ -0,0 +1,7 @@ +- WorkManager for manual sync +- refactored service detection +- improved compatibility with jtx Board +- [WebDAV] better handling of weak ETags (improves compatibility with SeedVault+Apache) +- [UI] added Mastodon link to navigation drawer +- [UI] support per-app language selection (Android 13) +- minor improvements and bug fixes diff --git a/fastlane/metadata/android/en-US/changelogs/405000000.txt b/fastlane/metadata/android/en-US/changelogs/405000000.txt new file mode 100644 index 0000000..40b21c0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/405000000.txt @@ -0,0 +1,3 @@ +* Push support (beta, server needs to support it) +* OAuth support for Fastmail +* many other bug fixes and improvements diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..8632b2b --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ is a CalDAV/CardDAV management and synchronization app for Android which natively integrates with Android calendar/contact apps. + +Use it with your own server or with a trusted hoster to keep your contacts, events and tasks under your control. + +For more information and a list of tested servers/services, have a look at the Web site. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..987cbed Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..8717fbc Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..77a384a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..5d58330 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..72a43db Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..e4c114d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..925bc81 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png new file mode 100644 index 0000000..4de7fbf Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..0cce563 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV Synchronization and Client diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..8c89845 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +DAVx⁵ diff --git a/fastlane/metadata/android/en-US/video.txt b/fastlane/metadata/android/en-US/video.txt new file mode 100644 index 0000000..7e759f2 --- /dev/null +++ b/fastlane/metadata/android/en-US/video.txt @@ -0,0 +1 @@ +https://www.youtube.com/watch?v=fqsK7VCX_fM diff --git a/fastlane/metadata/android/en-rGB/full_description.txt b/fastlane/metadata/android/en-rGB/full_description.txt new file mode 100644 index 0000000..f4d7c57 --- /dev/null +++ b/fastlane/metadata/android/en-rGB/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ is a CalDAV/CardDAV management and synchronisation app for Android which natively integrates with Android calendar/contact apps. + +Use it with your own server or with a trusted host to keep your contacts, events and tasks under your control. + +For more information and a list of tested servers/services, have a look at the Web site. diff --git a/fastlane/metadata/android/en-rGB/short_description.txt b/fastlane/metadata/android/en-rGB/short_description.txt new file mode 100644 index 0000000..9070bae --- /dev/null +++ b/fastlane/metadata/android/en-rGB/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV Synchronisation and Client diff --git a/fastlane/metadata/android/es/full_description.txt b/fastlane/metadata/android/es/full_description.txt new file mode 100644 index 0000000..7d17ba9 --- /dev/null +++ b/fastlane/metadata/android/es/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ es una aplicación de administración y sincronización CalDAV/CardDAV para Android la cual se integra nativamente con la aplicación de calendario/contactos de Android. + +Úselo con su su servidor o con un anfitrión de confianza para mantener sus contactos, incluso sus tareas bajo su control. + +Para más información y una lista de servicios/servidores probados, revise el sitio web. diff --git a/fastlane/metadata/android/es/short_description.txt b/fastlane/metadata/android/es/short_description.txt new file mode 100644 index 0000000..e78d7b3 --- /dev/null +++ b/fastlane/metadata/android/es/short_description.txt @@ -0,0 +1 @@ +Cliente y Sincronización de CalDAV/CardDAV diff --git a/fastlane/metadata/android/et/full_description.txt b/fastlane/metadata/android/et/full_description.txt new file mode 100644 index 0000000..139deb0 --- /dev/null +++ b/fastlane/metadata/android/et/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ on CalDAVi/CardDAVi haldus- ja sünkroniseerimisrakendus Androidi jaoks, mis sujuvalt liidestub Androidi kalendri- ja kontaktirakendustega. + +Kasuta seda oma kontaktide, sündmuste ja ülesannete privaatseks haldamiseks kas oma serveris või usaldusväärse teenusepakkuja juures. + +Lisateavet ja testitud serverite ning teenuste loendi leiad meie veebisaidist. diff --git a/fastlane/metadata/android/et/short_description.txt b/fastlane/metadata/android/et/short_description.txt new file mode 100644 index 0000000..915d35a --- /dev/null +++ b/fastlane/metadata/android/et/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV sünkroniseerimine ja klient diff --git a/fastlane/metadata/android/eu/full_description.txt b/fastlane/metadata/android/eu/full_description.txt new file mode 100644 index 0000000..e333b14 --- /dev/null +++ b/fastlane/metadata/android/eu/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ Androiderako CalDAV/CardDAV kudeatze eta sinkronizazio aplikazio bat da, Android egutegi/kontaktu aplikazio integrazioarekin. + +Erabili ezazu zure zerbitzarian edo ostalari fidagarri batekin kontaktuak, ekitaldiak eta zereginak zure kontrolpean edukitzeko. + +Informazio gehiago izateko eta zerbitzarien/zerbitzuen zerrenda aztertzeko, joan zaitez webgunera. diff --git a/fastlane/metadata/android/eu/short_description.txt b/fastlane/metadata/android/eu/short_description.txt new file mode 100644 index 0000000..b6fd84f --- /dev/null +++ b/fastlane/metadata/android/eu/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV sinkronizazio eta bezeroa diff --git a/fastlane/metadata/android/fa/full_description.txt b/fastlane/metadata/android/fa/full_description.txt new file mode 100644 index 0000000..2890419 --- /dev/null +++ b/fastlane/metadata/android/fa/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ یک برنامه‌ی CalDAV/CardDAV مدیرتی و همگام سازی برای اندروید است که به صورت بومی با برنامه‌ی تقویم و مخاطبین اندروید ادقام می‌شود. + +با همراهی سرور خودتان یا با یک میزبان قابل اطمینان استفاده کنید تا مخاطبین، وقایع و وظایفت تحت کنترل خودتان باشد. + +برای دریافت اطلاعات بیشتر و یک لیست امتحان شد از سرورها و سرویس‌ها نگاهی به سایت بیاندازید. diff --git a/fastlane/metadata/android/fi/full_description.txt b/fastlane/metadata/android/fi/full_description.txt new file mode 100644 index 0000000..3f37dc0 --- /dev/null +++ b/fastlane/metadata/android/fi/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ on CalDAV/CardDAV- hallinta- ja synkronointisovellus Android-laitteille, jonka avulla kalenterit/yhteystiedot liitetään CalDAV/CardDAV-järjestelmästä Androidin omaan kalenterien ja yhteystietojen sovellukseen. + +Käytä sovellusta omalla palvelimellasi tai luotetun palveluntarjoajan kanssa pitääksesi yhteystietosi, tapahtumasi ja tehtäväsi omassa hallinnassasi. + +Saadaksesi lisätietoja sekä listan testatuista palvelimista/palvelinohjelmista, käy katsomassa Internet-sivustollamme. diff --git a/fastlane/metadata/android/fi/short_description.txt b/fastlane/metadata/android/fi/short_description.txt new file mode 100644 index 0000000..8e56558 --- /dev/null +++ b/fastlane/metadata/android/fi/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV Synkronointi- ja asiakasohjelma diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 0000000..2ab5b83 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ est une application de gestion et synchronisation CalDAV/CardDAV sous Android compatible avec les application de contact et les calendriers Android. + +Utilisez-la avec votre serveur personnel ou un hôte de confiance pour garder vos contacts, événements et tâches sous contrôle. + +Pour plus d'information et pour consulter la liste des serveurs/services testés consultez le site web. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 0000000..b0bc47a --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Synchronisation et Client CalDAV/CardDAV \ No newline at end of file diff --git a/fastlane/metadata/android/fr-rFR/full_description.txt b/fastlane/metadata/android/fr-rFR/full_description.txt new file mode 100644 index 0000000..243239c --- /dev/null +++ b/fastlane/metadata/android/fr-rFR/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ est une application de gestion et synchronisation CalDAV/CardDAV sous Android compatible avec les application de contact et les calendriers Android. + +Utilisez-la avec votre serveur personnel ou un hôte de confiance pour garder vos contacts, événements et tâches sous contrôle. + +Pour plus d'information et pour consulter la liste des serveurs/services testés consultez le site web. diff --git a/fastlane/metadata/android/fr-rFR/short_description.txt b/fastlane/metadata/android/fr-rFR/short_description.txt new file mode 100644 index 0000000..d0023b9 --- /dev/null +++ b/fastlane/metadata/android/fr-rFR/short_description.txt @@ -0,0 +1 @@ +Synchronisation et Client CalDAV/CardDAV diff --git a/fastlane/metadata/android/fr/full_description.txt b/fastlane/metadata/android/fr/full_description.txt new file mode 100644 index 0000000..b574004 --- /dev/null +++ b/fastlane/metadata/android/fr/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ est une application de gestion et de synchronisation CalDAV / CardDAV pour Android qui s'intègre de manière native aux applications de calendrier / contact Android. + +Utilisez-le avec votre propre serveur ou avec un hébergeur de confiance pour garder vos contacts, événements et tâches sous votre contrôle. + +Pour plus d'informations et une liste des serveurs / services testés, consultez le site Web. diff --git a/fastlane/metadata/android/fr/short_description.txt b/fastlane/metadata/android/fr/short_description.txt new file mode 100644 index 0000000..2a3f243 --- /dev/null +++ b/fastlane/metadata/android/fr/short_description.txt @@ -0,0 +1 @@ +Synchronisation et client CalDAV / CardDAV diff --git a/fastlane/metadata/android/gl/full_description.txt b/fastlane/metadata/android/gl/full_description.txt new file mode 100644 index 0000000..1ea1991 --- /dev/null +++ b/fastlane/metadata/android/gl/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ é un xestor CalDAV/CardDAV e aplicación de sincronización para Android que se integra de xeito nativo coas apps de contactos/calendario de Android. + +Utilíceo co seu propio servidor ou con un de confianza para manter os seus contactos, eventos e tarefas baixo o seu control. + +Para máis información e unha lista de servidores/servizos, bote unha ollada no sitio Web. diff --git a/fastlane/metadata/android/gl/short_description.txt b/fastlane/metadata/android/gl/short_description.txt new file mode 100644 index 0000000..6ae2959 --- /dev/null +++ b/fastlane/metadata/android/gl/short_description.txt @@ -0,0 +1 @@ +Sincronización CalDAV/CardDAV e Cliente diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt new file mode 100644 index 0000000..0f291aa --- /dev/null +++ b/fastlane/metadata/android/hr/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ je aplikacija za upravljanje i sinkronizaciju CalDAV / CardDAV za Android koja se izvorno integrira s Android kalendarom / aplikacijama za kontakt. + +Koristite ga s vlastitim poslužiteljem ili s pouzdanom uslugom iznajmljivanja poslužitelja kako biste svoje kontakte, događaje i zadatke držali pod svojom kontrolom. + +Za više informacija i popis testiranih poslužitelja / usluga pogledajte na web lokaciji. diff --git a/fastlane/metadata/android/hr/short_description.txt b/fastlane/metadata/android/hr/short_description.txt new file mode 100644 index 0000000..d5a2cff --- /dev/null +++ b/fastlane/metadata/android/hr/short_description.txt @@ -0,0 +1 @@ +CalDAV / CardDAV sinkronizacija i klijent diff --git a/fastlane/metadata/android/hu/full_description.txt b/fastlane/metadata/android/hu/full_description.txt new file mode 100644 index 0000000..0118546 --- /dev/null +++ b/fastlane/metadata/android/hu/full_description.txt @@ -0,0 +1,5 @@ +A DAVx⁵ egy CalDAV/CardDAV-kezelő és szinkronizáló alkalmazás Androidra, amely rendszerszinten integrálódik a naptár- és névjegy-alkalmazásokkal. + +Használhatja saját szerverével vagy megbízhatónak tartott szolgáltatókkal, hogy a névjegyek, naptárbejegyzések és feladatok feletti kontrollja megmaradjon. + +További információkat, valamint a tesztelt szerverek/szolgáltatások listáját a weboldalunkon találja. diff --git a/fastlane/metadata/android/hu/short_description.txt b/fastlane/metadata/android/hu/short_description.txt new file mode 100644 index 0000000..4cd03cc --- /dev/null +++ b/fastlane/metadata/android/hu/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV szinkronizáció és kliens diff --git a/fastlane/metadata/android/id/full_description.txt b/fastlane/metadata/android/id/full_description.txt new file mode 100644 index 0000000..82350bd --- /dev/null +++ b/fastlane/metadata/android/id/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ adalah aplikasi pengelolaan dan sinkronisasi CalDAV/CardDAV untuk Android yang terintegrasi dengan aplikasi kalender/kontak Android. + +Gunakan dengan server Anda sendiri atau dengan penyedia yang terpercaya untuk menjaga kontak, acara, dan tugas di bawah kendali Anda. + +Untuk lebih banyak informasi dan daftar server/layanan yang telah diuji coba, lihat situs web kami. diff --git a/fastlane/metadata/android/id/short_description.txt b/fastlane/metadata/android/id/short_description.txt new file mode 100644 index 0000000..a4a075d --- /dev/null +++ b/fastlane/metadata/android/id/short_description.txt @@ -0,0 +1 @@ +Sinkronisasi dan Klien CalDAV/CardDAV diff --git a/fastlane/metadata/android/it/full_description.txt b/fastlane/metadata/android/it/full_description.txt new file mode 100644 index 0000000..f020955 --- /dev/null +++ b/fastlane/metadata/android/it/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ è un'applicazione CalDAV/CardDAV di controllo e sincronizzazione per Android che è nativamente integrata con le applicazione Android di calendario/contatti. + +Utilizzalo con il tuo server o con un ospite fidato per mantenere sotto controllo i tuoi contatti, aventi o attività. + +Per maggiori informazioni e per una lista di server/servizi testati, guarda nel sito Web. diff --git a/fastlane/metadata/android/it/short_description.txt b/fastlane/metadata/android/it/short_description.txt new file mode 100644 index 0000000..391a021 --- /dev/null +++ b/fastlane/metadata/android/it/short_description.txt @@ -0,0 +1 @@ +Client e Sincronizzazione CalDAV/CardDAV diff --git a/fastlane/metadata/android/ja/full_description.txt b/fastlane/metadata/android/ja/full_description.txt new file mode 100644 index 0000000..9c9e25a --- /dev/null +++ b/fastlane/metadata/android/ja/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ は、Android のカレンダー/連絡先アプリとネイティブに統合された Android 用 CalDAV/CardDAV 管理および同期アプリです。 + +あなた独自のサーバーや信頼できるホストと一緒に使用して、連絡先、予定、タスクを管理してください。 + +詳細と、テスト済みのサーバー/サービスの一覧については、ウェブサイトを参照してください。 diff --git a/fastlane/metadata/android/ja/short_description.txt b/fastlane/metadata/android/ja/short_description.txt new file mode 100644 index 0000000..ffb33b0 --- /dev/null +++ b/fastlane/metadata/android/ja/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV 同期とクライアント diff --git a/fastlane/metadata/android/ka/full_description.txt b/fastlane/metadata/android/ka/full_description.txt new file mode 100644 index 0000000..12b3530 --- /dev/null +++ b/fastlane/metadata/android/ka/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ არის CalDAV/CardDAV-ს მართვისა და სინქრონიზაციის აპი Android-სთვის რომელიც ადგილობრივად ინტეგრირდება Android-ის კალენდარის/კონტაქტის აპებთან. + +გამოიყენეთ თქვენს სერვერთან ან სანდო ოპერატორთან თქვენი კონტაქტების, ღონისძიებების და დავალებების თქვენი კონტროლის ქვეშ დასატოვებლად. + +მეტი ინფორმაციისთვის და ტესტირებული სერვერების/სერვისების სიისათვის გადახედეთ ვებ საიტს. diff --git a/fastlane/metadata/android/ka/short_description.txt b/fastlane/metadata/android/ka/short_description.txt new file mode 100644 index 0000000..a3283d1 --- /dev/null +++ b/fastlane/metadata/android/ka/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV სინქრონიზაცია და კლიენტი diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt new file mode 100644 index 0000000..ab5b37d --- /dev/null +++ b/fastlane/metadata/android/ko/full_description.txt @@ -0,0 +1,5 @@ +DAVx²는 안드로이드용 CalDAV/CardDAV 관리 및 동기화 앱으로 안드로이드 캘린더/연락처 앱과 기본적으로 통합된다. + +연락처, 이벤트 및 작업을 제어하기 위해 자신의 서버 또는 신뢰할 수 있는 호스트에게 사용합니다. + +자세한 내용과 테스트된 서버/서비스 목록은 웹 사이트를 참조하십시오. diff --git a/fastlane/metadata/android/ko/short_description.txt b/fastlane/metadata/android/ko/short_description.txt new file mode 100644 index 0000000..d755ba4 --- /dev/null +++ b/fastlane/metadata/android/ko/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV 동기화 및 클라이언트 diff --git a/fastlane/metadata/android/nl/full_description.txt b/fastlane/metadata/android/nl/full_description.txt new file mode 100644 index 0000000..bb33e95 --- /dev/null +++ b/fastlane/metadata/android/nl/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ is een CalDAV/CardDAV management en synchronisatie app voor Android die integreert met de standaard Android kalender/contact apps. + +Gebruik het met je eigen server of met een vertrouwde hoster om je contacten, afspraken en taken onder je eigen controle te houden. + +Kijk voor meer informatie en een lijst met geteste servers / services op onze website. diff --git a/fastlane/metadata/android/nl/short_description.txt b/fastlane/metadata/android/nl/short_description.txt new file mode 100644 index 0000000..a05f895 --- /dev/null +++ b/fastlane/metadata/android/nl/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV synchronisatie en client diff --git a/fastlane/metadata/android/pl/full_description.txt b/fastlane/metadata/android/pl/full_description.txt new file mode 100644 index 0000000..0cc95f2 --- /dev/null +++ b/fastlane/metadata/android/pl/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ to aplikacja do zarządzania i synchronizacji CalDAV/CardDAV na Androida, która natywnie integruje się z aplikacjami kalendarza/kontaktów w systemie Android. + +Używaj go z własnym serwerem lub zaufaną firmą hostingową, aby mieć kontrolę nad swoimi kontaktami, wydarzeniami i zadaniami. + +Aby uzyskać więcej informacji i listę przetestowanych serwerów/usług, zajrzyj na stronę internetową. diff --git a/fastlane/metadata/android/pl/short_description.txt b/fastlane/metadata/android/pl/short_description.txt new file mode 100644 index 0000000..dfe51ee --- /dev/null +++ b/fastlane/metadata/android/pl/short_description.txt @@ -0,0 +1 @@ +Synchronizacja i klient CalDAV/CardDAV diff --git a/fastlane/metadata/android/pt-rBR/full_description.txt b/fastlane/metadata/android/pt-rBR/full_description.txt new file mode 100644 index 0000000..c9244cf --- /dev/null +++ b/fastlane/metadata/android/pt-rBR/full_description.txt @@ -0,0 +1,5 @@ +O DAVx⁵ é um aplicativo de gerenciamento e sincronização de CalDAV/CardDAV para Android que se integra nativamente com aplicativos de calendário e de contatos do Android. + +Use-o com o seu próprio servidor ou com um hospedeiro confiado para manter seus contatos, eventos, e tarefas sob seu controle. + +Para mais informações, e uma lista de servidores/serviços testados, dê uma olhada no site. diff --git a/fastlane/metadata/android/pt-rBR/short_description.txt b/fastlane/metadata/android/pt-rBR/short_description.txt new file mode 100644 index 0000000..8dc6c0e --- /dev/null +++ b/fastlane/metadata/android/pt-rBR/short_description.txt @@ -0,0 +1 @@ +Sincronização e cliente de CalDAV/CardDAV diff --git a/fastlane/metadata/android/pt/full_description.txt b/fastlane/metadata/android/pt/full_description.txt new file mode 100644 index 0000000..fcc541f --- /dev/null +++ b/fastlane/metadata/android/pt/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ é um aplicativo de gerenciamento e sincronização CalDAV/CardDAV para Android que se integra nativamente com aplicativos de calendário/contatos do Android. + +Use-o com seu próprio servidor ou com um host confiável para manter seus contatos, eventos e tarefas sob seu controle. + +Para mais informações e uma lista de servidores/serviços testados, dê uma olhada no site. diff --git a/fastlane/metadata/android/pt/short_description.txt b/fastlane/metadata/android/pt/short_description.txt new file mode 100644 index 0000000..7190913 --- /dev/null +++ b/fastlane/metadata/android/pt/short_description.txt @@ -0,0 +1 @@ +Sincronização e Cliente CalDAV/CardDAV diff --git a/fastlane/metadata/android/ro/full_description.txt b/fastlane/metadata/android/ro/full_description.txt new file mode 100644 index 0000000..24d6bcd --- /dev/null +++ b/fastlane/metadata/android/ro/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ este o aplicație de gestionare și sincronizare CalDAV/CardDAV pentru Android care se integrează în mod nativ cu aplicațiile de calendar/contacte Android. + +Utilizează-l cu propriul server sau cu o gazdă de încredere pentru a păstra contactele, evenimentele și sarcinile sub control. + +Pentru mai multe informații și o listă de servere/servicii testate, aruncă o privire pe site-ul Web. diff --git a/fastlane/metadata/android/ro/short_description.txt b/fastlane/metadata/android/ro/short_description.txt new file mode 100644 index 0000000..09235f6 --- /dev/null +++ b/fastlane/metadata/android/ro/short_description.txt @@ -0,0 +1 @@ +Sincronizare și client CalDAV/CardDAV diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt new file mode 100644 index 0000000..2e98709 --- /dev/null +++ b/fastlane/metadata/android/ru/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ - приложение для управления и синхронизации CalDAV/CardDAV для Android, которое изначально интегрируется с приложениями Android для работы с календарем и контактами. + +Используйте его с вашим собственным сервером или доверенным хостером, чтобы держать ваши контакты, события и задачи под контролем. + +Для получения дополнительной информации и списка протестированных серверов/служб, посетите сайт. diff --git a/fastlane/metadata/android/ru/short_description.txt b/fastlane/metadata/android/ru/short_description.txt new file mode 100644 index 0000000..99dc4a4 --- /dev/null +++ b/fastlane/metadata/android/ru/short_description.txt @@ -0,0 +1 @@ +Синхронизация CalDAV/CardDAV и клиент diff --git a/fastlane/metadata/android/si/short_description.txt b/fastlane/metadata/android/si/short_description.txt new file mode 100644 index 0000000..31a9b4a --- /dev/null +++ b/fastlane/metadata/android/si/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV සමමුහූර්තය හා අනුග්‍රාහකය diff --git a/fastlane/metadata/android/sk/full_description.txt b/fastlane/metadata/android/sk/full_description.txt new file mode 100644 index 0000000..d4b10b0 --- /dev/null +++ b/fastlane/metadata/android/sk/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ je aplikácia na správu a synchronizáciu CalDAV/CardDAV pre Android ktorá sa natívne integruje s Android aplikáciami kalendár/kontakt. + +Používajte s vašim vlastným serverom alebo s poskytovateľom server-hostingu ktorému dôverujete a majte svoje kontakty, udalosti a úlohy pod kontrolou. + +Ďalšie informácie a zoznam testovaných serverov/služieb nájdete na webovom sídle. diff --git a/fastlane/metadata/android/sk/short_description.txt b/fastlane/metadata/android/sk/short_description.txt new file mode 100644 index 0000000..885a96c --- /dev/null +++ b/fastlane/metadata/android/sk/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV synchronizácia a klient diff --git a/fastlane/metadata/android/sl/full_description.txt b/fastlane/metadata/android/sl/full_description.txt new file mode 100644 index 0000000..e36ad0e --- /dev/null +++ b/fastlane/metadata/android/sl/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ je CalDAV/CardDAV android aplikacija za urejanje in sinhronizacijo z koledarja/kontaktov. + +Uporabite jo z vašim lastnim strežnikom ali drugimi ponudniki strežnikov, ki jim zaupate, in imejte tako vaše kontakte, dogodke in naloge pod svojim nadzorom. + +Več informacij ter seznam serverjev/storitev lahko dobite na spletni strani diff --git a/fastlane/metadata/android/sl/short_description.txt b/fastlane/metadata/android/sl/short_description.txt new file mode 100644 index 0000000..ad2650a --- /dev/null +++ b/fastlane/metadata/android/sl/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV sinhronizacija in klient diff --git a/fastlane/metadata/android/sr/full_description.txt b/fastlane/metadata/android/sr/full_description.txt new file mode 100644 index 0000000..9a24595 --- /dev/null +++ b/fastlane/metadata/android/sr/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ је апликација за управљање и синхорнизацију КалДАВ/КардДАВ-а за Андроид која се нормално интегрише са Адроид календар/контакт апликацијама. + +Користите је са својим сервером или са добављачем од поверења да би сте држали своје контакте, догађаје и задатке под контролом. + +За више информација и листу проверених сервера/услуга, погледајте веб-сајт. diff --git a/fastlane/metadata/android/sr/short_description.txt b/fastlane/metadata/android/sr/short_description.txt new file mode 100644 index 0000000..3dadb36 --- /dev/null +++ b/fastlane/metadata/android/sr/short_description.txt @@ -0,0 +1 @@ +КалДАВ/КардДАВ синхронизација и клијент diff --git a/fastlane/metadata/android/sv/full_description.txt b/fastlane/metadata/android/sv/full_description.txt new file mode 100644 index 0000000..40fc19d --- /dev/null +++ b/fastlane/metadata/android/sv/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ är en CalDAV/CardDAV-hanterings och synkroniseringsapp för Android som naturligt integreras med Androids kalender/kontakt-appar. + +Använd den med din egen server eller med en betrodd värd för att hålla dina kontakter, händelser och uppgifter under din kontroll. + +För mer information och en lista över testade servrar/tjänster, ta en titt på webbplatsen. diff --git a/fastlane/metadata/android/sv/short_description.txt b/fastlane/metadata/android/sv/short_description.txt new file mode 100644 index 0000000..1b308b1 --- /dev/null +++ b/fastlane/metadata/android/sv/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV Synkronisering och klient diff --git a/fastlane/metadata/android/szl/full_description.txt b/fastlane/metadata/android/szl/full_description.txt new file mode 100644 index 0000000..b4c5d19 --- /dev/null +++ b/fastlane/metadata/android/szl/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ to je aplikacyjo zarzōndzanio i synchrōnizacyje CalDAV/CardDAV dlo Androida, co natywnie integruje sie ze aplikacyjami kalyndorza/kōntaktōw Androida. + +Używej to ze swojim włosnym serwerym abo ze zaufanym hostym, żeby trzimać swoje kōntakty, zdarzynia i zadania pod kōntrolōm. + +Po wiyncyj informacyji i lista przetestowanych serwerōw/serwisōw, wejzdrzij na strōna internetowo. diff --git a/fastlane/metadata/android/szl/short_description.txt b/fastlane/metadata/android/szl/short_description.txt new file mode 100644 index 0000000..65fadc9 --- /dev/null +++ b/fastlane/metadata/android/szl/short_description.txt @@ -0,0 +1 @@ +Synchrōnizacyjo i klijynt CalDAV/CardDAV diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt new file mode 100644 index 0000000..723a0b5 --- /dev/null +++ b/fastlane/metadata/android/uk/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ це Android додаток для керування та синхронізації CalDAV/CardDAV, який органічно інтегрується з Android календарем/контактами. + +Використовуйте його з власним сервером або з довіреним постачальником, аби зберегти контроль над вашими контактами, подіями та задачами. + +Для більш детальної інформації та списку перевірених серверів/сервісів дивіться нашу Web сторінку. diff --git a/fastlane/metadata/android/uk/short_description.txt b/fastlane/metadata/android/uk/short_description.txt new file mode 100644 index 0000000..220ac48 --- /dev/null +++ b/fastlane/metadata/android/uk/short_description.txt @@ -0,0 +1 @@ +Клієнт синхронізації CalDAV/CardDAV diff --git a/fastlane/metadata/android/vi/full_description.txt b/fastlane/metadata/android/vi/full_description.txt new file mode 100644 index 0000000..71c91da --- /dev/null +++ b/fastlane/metadata/android/vi/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ là một ứng dụng quản lý và đồng bộ hoá CalDAV/CardDAV dành cho Android, nó tích hợp với ứng dụng lịch/danh bạ của Android. + +Sử dụng nó với máy chủ của bạn hoặc với một người lưu trữ được tin cậy để giữ các danh bạ, sự kiện và công việc của bạn dưới quyền kiểm soát của bạn. + +Để biết thêm thông tin và xem một danh sách các máy chủ/dịch vụ đã được thử nghiệm, hãy xem trang web. diff --git a/fastlane/metadata/android/vi/short_description.txt b/fastlane/metadata/android/vi/short_description.txt new file mode 100644 index 0000000..89c62c6 --- /dev/null +++ b/fastlane/metadata/android/vi/short_description.txt @@ -0,0 +1 @@ +Ứng dụng khách và đồng bộ hoá CalDAV/CardDAV diff --git a/fastlane/metadata/android/zh-TW/full_description.txt b/fastlane/metadata/android/zh-TW/full_description.txt new file mode 100644 index 0000000..cdcb63d --- /dev/null +++ b/fastlane/metadata/android/zh-TW/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ 是一個 CalDAV/CardDAV 管理與同步工具,在 Android 設備上運行,並集合了 Android 内置的行事曆和聯絡人APP。 + +在您自己的伺服器上,或者在可信任的管理服務上保管你的聯絡人,事項和任務。 + +您可以在網際網絡上獲取更多詳細資料,它含有一個經過測試成功的服務和伺服器清單。 \ No newline at end of file diff --git a/fastlane/metadata/android/zh-TW/short_description.txt b/fastlane/metadata/android/zh-TW/short_description.txt new file mode 100644 index 0000000..dece00d --- /dev/null +++ b/fastlane/metadata/android/zh-TW/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV 同步服務和客戶端 \ No newline at end of file diff --git a/fastlane/metadata/android/zh-rTW/full_description.txt b/fastlane/metadata/android/zh-rTW/full_description.txt new file mode 100644 index 0000000..ecdc6df --- /dev/null +++ b/fastlane/metadata/android/zh-rTW/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ 是一個 CalDAV/CardDAV 管理與同步工具,在 Android 設備上運行,並集合了 Android 内置的行事曆和聯絡人APP。 + +在您自己的伺服器上,或者在可信任的管理服務上保管你的聯絡人,事項和任務。 + +您可以在網際網絡上獲取更多詳細資料,它含有一個經過測試成功的服務和伺服器清單。 diff --git a/fastlane/metadata/android/zh-rTW/short_description.txt b/fastlane/metadata/android/zh-rTW/short_description.txt new file mode 100644 index 0000000..a81d28c --- /dev/null +++ b/fastlane/metadata/android/zh-rTW/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV 同步服務和客戶端 diff --git a/fastlane/metadata/android/zh/full_description.txt b/fastlane/metadata/android/zh/full_description.txt new file mode 100644 index 0000000..a6b1c00 --- /dev/null +++ b/fastlane/metadata/android/zh/full_description.txt @@ -0,0 +1,5 @@ +DAVx⁵ 是一款 Android 的 CalDAV/CardDAV 管理同步应用,与 Android 的日历、通讯录软件原生集成。 + +通过该应用,可在自己的服务器或信任的托管商上保存通讯录、时间和任务数据,保证可控。 + +请浏览网站,了解更多信息,包括测试通过的服务器、网站列表。 diff --git a/fastlane/metadata/android/zh/short_description.txt b/fastlane/metadata/android/zh/short_description.txt new file mode 100644 index 0000000..d2dde99 --- /dev/null +++ b/fastlane/metadata/android/zh/short_description.txt @@ -0,0 +1 @@ +CalDAV/CardDAV 同步客户端 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6a295ee --- /dev/null +++ b/gradle.properties @@ -0,0 +1,22 @@ +# +# Copyright All Contributors. See LICENSE and AUTHORS in the root directory for details. +# + +# https://developer.android.com/build/optimize-your-build +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g +org.gradle.parallel=true + +# Android +android.useAndroidX=true + +# It's recommended to add these settings to your $GRADLE_USER_HOME/gradle.properties: + +# org.gradle.configuration-cache=true +# org.gradle.configuration-cache.problems=warn + +# https://docs.gradle.org/current/userguide/build_cache.html +# org.gradle.caching=true + +# temporary fix for https://github.com/google/ksp/issues/2072 +# ksp.incremental=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..db93a64 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,116 @@ +# Comments apply to next line + +[versions] +android-agp = "8.13.0" +android-desugaring = "2.1.5" +androidx-activityCompose = "1.11.0" +androidx-appcompat = "1.7.1" +androidx-arch = "2.2.0" +androidx-browser = "1.9.0" +androidx-core = "1.17.0" +androidx-hilt = "1.3.0" +androidx-lifecycle = "2.9.4" +androidx-paging = "3.3.6" +androidx-preference = "1.2.1" +androidx-security = "1.1.0" +androidx-test-core = "1.7.0" +androidx-test-runner = "1.7.0" +androidx-test-rules = "1.7.0" +androidx-test-junit = "1.3.0" +androidx-work = "2.11.0" +bitfire-cert4android = "41009d48ed" +bitfire-dav4jvm = "f11523619b" +bitfire-synctools = "1a7f70b1a0" +compose-accompanist = "0.37.3" +compose-bom = "2025.10.01" +dnsjava = "3.6.3" +glance = "1.1.1" +guava = "33.5.0-android" +hilt = "2.57.2" +# keep in sync with ksp version +kotlin = "2.2.21" +kotlinx-coroutines = "1.10.2" +ksp = "2.3.0" +mikepenz-aboutLibraries = "13.1.0" +mockk = "1.14.5" +okhttp = "5.2.1" +openid-appauth = "0.11.1" +room = "2.8.3" +unifiedpush = "3.1.2" +unifiedpush-fcm = "3.0.0" + +# Other libraries, especially ical4j, require Apache Commons. Some recent versions of Apache +# Commons require a newer Java version than our minSdk provides. So we require these strict versions here: +#noinspection NewerVersionAvailable +commons-codec = { strictly = "1.17.1" } +#noinspection NewerVersionAvailable +commons-lang = { strictly = "3.15.0" } + +[libraries] +android-desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "android-desugaring" } +androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidx-arch" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-base = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-paging = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } +androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } +androidx-security = { module = "androidx.security:security-crypto", version.ref = "androidx-security" } +androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +androidx-work-base = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } +bitfire-cert4android = { module = "com.github.bitfireat:cert4android", version.ref = "bitfire-cert4android" } +bitfire-dav4jvm = { module = "com.github.bitfireat:dav4jvm", version.ref = "bitfire-dav4jvm" } +bitfire-synctools = { module = "com.github.bitfireat:synctools", version.ref = "bitfire-synctools" } +commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" } +commons-lang = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang" } +compose-accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "compose-accompanist" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-materialIconsExtended = { module = "androidx.compose.material:material-icons-extended" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +compose-ui-toolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } +dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" } +glance-base = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +glance-material = { module = "androidx.glance:glance-material", version.ref = "glance" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +hilt-android-base = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +junit = { module = "junit:junit", version = "4.13.2" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +okhttp-base = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +openid-appauth = { module = "net.openid:appauth", version.ref = "openid-appauth" } +room-base = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-paging = { module = "androidx.room:room-paging", version.ref = "room" } +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } +unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "unifiedpush" } +unifiedpush-fcm = { module = "org.unifiedpush.android:embedded-fcm-distributor", version.ref = "unifiedpush-fcm" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "android-agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4ae9f65 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,11 @@ +# +# Copyright All Contributors. See LICENSE and AUTHORS in the root directory for details. +# + +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/copy-compiled.sh b/scripts/copy-compiled.sh new file mode 100755 index 0000000..4438a9e --- /dev/null +++ b/scripts/copy-compiled.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +SOURCE_DIR=~/tmp/davx5 +BASE_DIR=`dirname $0`/../app +MAPPING_DIR=$BASE_DIR/build/outputs/mapping +TARGET_DIR=$BASE_DIR/target + +rsync -arvt $SOURCE_DIR/ $TARGET_DIR/ +rsync -arvt $MAPPING_DIR/ $TARGET_DIR/latest-mapping/ diff --git a/scripts/fetch-db.sh b/scripts/fetch-db.sh new file mode 100755 index 0000000..275138d --- /dev/null +++ b/scripts/fetch-db.sh @@ -0,0 +1,5 @@ +#!/bin/sh +cd ~/tmp +adb pull /data/data/com.android.providers.contacts/databases/contacts2.db +adb pull /data/data/com.android.providers.calendar/databases/calendar.db +adb pull /data/data/org.dmfs.tasks/databases/tasks.db diff --git a/scripts/fetch-translations.sh b/scripts/fetch-translations.sh new file mode 100755 index 0000000..a8862a0 --- /dev/null +++ b/scripts/fetch-translations.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +export TX_TOKEN=`awk '/token *=/ { print $3; }' <$HOME/.transifexrc` + +cd `pwd $0`/.. + +tx pull -a -f --use-git-timestamps +if find app/src -type d -name 'values-*_*' -exec false '{}' + +then + echo "No values-XX_RR directory found, good" +else + echo "Found values-XX_RR directory, update .tx/config mappings to values-XX-rRR!" + exit 1 +fi + +curl -H "Authorization: Bearer $TX_TOKEN" 'https://rest.api.transifex.com/team_memberships?filter\[organization\]=o:bitfireAT&filter\[team\]=o:bitfireAT:t:davx5-team' \ + | scripts/rewrite-translators.rb >app/src/main/assets/translators.json diff --git a/scripts/gen-contacts.rb b/scripts/gen-contacts.rb new file mode 100755 index 0000000..9474b13 --- /dev/null +++ b/scripts/gen-contacts.rb @@ -0,0 +1,13 @@ +#!/usr/bin/ruby + +File.open("contacts.vcf", "w") do |f| + for i in 1..600 do + f.puts "BEGIN:VCARD" + f.puts "VERSION:3.0" + f.puts "FN:Kontakt Nr. #{i}" + f.puts "N:Kontakt Nr. #{i}" + f.puts "EMAIL:#{i}@google-god.com" + f.puts "PHONE:#{i}#{i}#{i}" + f.puts "END:VCARD" + end +end diff --git a/scripts/rewrite-translators.rb b/scripts/rewrite-translators.rb new file mode 100755 index 0000000..cca7e2e --- /dev/null +++ b/scripts/rewrite-translators.rb @@ -0,0 +1,24 @@ +#!/usr/bin/ruby + +require 'json' + +contributors = {} + +transifex = JSON.parse(STDIN.read, :symbolize_names => true) +for t in transifex[:data] + raise unless t[:type] == 'team_memberships' + #next unless t[:attributes][:role] == 'translator' + + rel = t[:relationships] + lang = rel[:language][:data][:id].delete_prefix('l:') + user = rel[:user][:data][:id].delete_prefix('u:') + + next if user == 'bitfire' + + contributors[lang] = [] if contributors[lang].nil? + contributors[lang] << user +end + +contributors.transform_values! { |u| u.sort } + +puts contributors.sort.to_h.to_json diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9ff7403 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + google() + mavenCentral() + + // AboutLibraries + maven("https://plugins.gradle.org/m2/") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + + // AppIntro, dav4jvm + maven("https://jitpack.io") + } +} + +include(":app")