From 32b27c53e03d3e1f25230efc48610efeab31f9a2 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 2 Apr 2014 22:00:32 +0200 Subject: [PATCH 01/81] Initial commit --- AndroidManifest.xml | 13 + LICENCE | 4 + LICENCE_APACHE | 178 ++++++++ LICENCE_LGPL2.1 | 503 +++++++++++++++++++++ LICENCE_WTFPL | 13 + README.md | 8 + build.gradle | 34 ++ build.xml | 92 ++++ proguard-project.txt | 0 project.properties | 2 + src/de/measite/minidns/Client.java | 161 +++++++ src/de/measite/minidns/DNSMessage.java | 507 ++++++++++++++++++++++ src/de/measite/minidns/Question.java | 62 +++ src/de/measite/minidns/Record.java | 293 +++++++++++++ src/de/measite/minidns/record/A.java | 37 ++ src/de/measite/minidns/record/AAAA.java | 43 ++ src/de/measite/minidns/record/CNAME.java | 44 ++ src/de/measite/minidns/record/Data.java | 16 + src/de/measite/minidns/record/NS.java | 12 + src/de/measite/minidns/record/SRV.java | 74 ++++ src/de/measite/minidns/util/NameUtil.java | 77 ++++ 21 files changed, 2173 insertions(+) create mode 100644 AndroidManifest.xml create mode 100644 LICENCE create mode 100644 LICENCE_APACHE create mode 100644 LICENCE_LGPL2.1 create mode 100644 LICENCE_WTFPL create mode 100644 README.md create mode 100644 build.gradle create mode 100644 build.xml create mode 100644 proguard-project.txt create mode 100644 project.properties create mode 100644 src/de/measite/minidns/Client.java create mode 100644 src/de/measite/minidns/DNSMessage.java create mode 100644 src/de/measite/minidns/Question.java create mode 100644 src/de/measite/minidns/Record.java create mode 100644 src/de/measite/minidns/record/A.java create mode 100644 src/de/measite/minidns/record/AAAA.java create mode 100644 src/de/measite/minidns/record/CNAME.java create mode 100644 src/de/measite/minidns/record/Data.java create mode 100644 src/de/measite/minidns/record/NS.java create mode 100644 src/de/measite/minidns/record/SRV.java create mode 100644 src/de/measite/minidns/util/NameUtil.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 000000000..6c9477d9c --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/LICENCE b/LICENCE new file mode 100644 index 000000000..4c2ee7c0f --- /dev/null +++ b/LICENCE @@ -0,0 +1,4 @@ +This software may be used under the terms of (at your choice) +- LGPL version 2 (or later) (see LICENCE_LGPL2.1 for details) +- Apache Software licence (see LICENCE_APACHE for details) +- WTFPL (see LICENCE_WTFPL for details) diff --git a/LICENCE_APACHE b/LICENCE_APACHE new file mode 100644 index 000000000..e454a5258 --- /dev/null +++ b/LICENCE_APACHE @@ -0,0 +1,178 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + diff --git a/LICENCE_LGPL2.1 b/LICENCE_LGPL2.1 new file mode 100644 index 000000000..51a70cae7 --- /dev/null +++ b/LICENCE_LGPL2.1 @@ -0,0 +1,503 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +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 this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser 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 Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "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 +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY 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 +LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey 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 library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! + diff --git a/LICENCE_WTFPL b/LICENCE_WTFPL new file mode 100644 index 000000000..652d37834 --- /dev/null +++ b/LICENCE_WTFPL @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2014 Rene Treffer + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 000000000..99ba78b31 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +MiniDNS +------- + +MiniDNS is a minial dns client library for android. It can parse a basic set +of resource records (A, AAAA, NS, SRV) and is easy to use and extend. + +This library is not intended to be used as a DNS server. You might want to +look into dnsjava for such functionality. diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..2a17c6ae6 --- /dev/null +++ b/build.gradle @@ -0,0 +1,34 @@ +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:0.9.0' + } +} + +apply plugin: 'android-library' + +android { + compileSdkVersion 9 + buildToolsVersion '19.0.3' + + // NOTE: We are using the old folder structure to also support Eclipse + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = ['src'] + resources.srcDirs = ['src'] + aidl.srcDirs = ['src'] + renderscript.srcDirs = ['src'] + res.srcDirs = ['res'] + assets.srcDirs = ['assets'] + } + } + + // Do not abort build if lint finds errors + lintOptions { + abortOnError false + } +} diff --git a/build.xml b/build.xml new file mode 100644 index 000000000..ed7657f24 --- /dev/null +++ b/build.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/proguard-project.txt b/proguard-project.txt new file mode 100644 index 000000000..e69de29bb diff --git a/project.properties b/project.properties new file mode 100644 index 000000000..7096c5cef --- /dev/null +++ b/project.properties @@ -0,0 +1,2 @@ +target=android-9 +android.library=true diff --git a/src/de/measite/minidns/Client.java b/src/de/measite/minidns/Client.java new file mode 100644 index 000000000..c32ec3a1f --- /dev/null +++ b/src/de/measite/minidns/Client.java @@ -0,0 +1,161 @@ +package de.measite.minidns; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.HashSet; +import java.util.Random; + +import de.measite.minidns.Record.CLASS; +import de.measite.minidns.Record.TYPE; + +/** + * A minimal DNS client for SRV/A/AAAA/NS and CNAME lookups, with IDN support. + * This circumvents the missing javax.naming package on android. + */ +public class Client { + + /** + * The internal random class for sequence generation. + */ + protected Random random; + + /** + * Create a new DNS client. + */ + public Client() { + try { + random = SecureRandom.getInstance("SHA1PRNG"); + } catch (NoSuchAlgorithmException e1) { + random = new SecureRandom(); + } + } + + /** + * Query a nameserver for a single entry. + * @param name The DNS name to request. + * @param type The DNS type to request (SRV, A, AAAA, ...). + * @param clazz The class of the request (usually IN for Internet). + * @param host The DNS server host. + * @return + * @throws IOException On IO Errors. + */ + public DNSMessage query(String name, TYPE type, CLASS clazz, String host) + throws IOException + { + Question q = new Question(); + q.setClazz(clazz); + q.setType(type); + q.setName(name); + return query(q, host); + } + + /** + * Query the system nameserver for a single entry. + * @param name The DNS name to request. + * @param type The DNS type to request (SRV, A, AAAA, ...). + * @param clazz The class of the request (usually IN for Internet). + * @return The DNSMessage reply or null. + */ + public DNSMessage query(String name, TYPE type, CLASS clazz) + { + Question q = new Question(); + q.setClazz(clazz); + q.setType(type); + q.setName(name); + return query(q); + } + + /** + * Query a specific server for one entry. + * @param q The question section of the DNS query. + * @param host The dns server host. + * @throws IOException On IOErrors. + */ + public DNSMessage query(Question q, String host) throws IOException { + DNSMessage message = new DNSMessage(); + message.setQuestions(new Question[]{q}); + message.setRecursionDesired(true); + message.setId(random.nextInt()); + byte[] buf = message.toArray(); + DatagramSocket socket = new DatagramSocket(); + DatagramPacket packet = new DatagramPacket( + buf, buf.length, InetAddress.getByName(host), 53); + socket.setSoTimeout(5000); + socket.send(packet); + packet = new DatagramPacket(new byte[513], 513); + socket.receive(packet); + DNSMessage dnsMessage = DNSMessage.parse(packet.getData()); + if (dnsMessage.getId() != message.getId()) { + return null; + } + return dnsMessage; + } + + /** + * Query the system DNS server for one entry. + * @param q The question section of the DNS query. + */ + public DNSMessage query(Question q) { + String dnsServer[] = findDNS(); + for (String dns : dnsServer) { + try { + DNSMessage message = query(q, dns); + if (message == null) { + continue; + } + if (message.getResponseCode() != + DNSMessage.RESPONSE_CODE.NO_ERROR) { + continue; + } + for (Record record: message.getAnswers()) { + if (record.isAnswer(q)) { + return message; + } + } + } catch (IOException ioe) { + } + } + return null; + } + + /** + * Retrieve a list of currently configured DNS servers. + * @return The server array. + */ + public String[] findDNS() { + try { + Process process = Runtime.getRuntime().exec("getprop"); + InputStream inputStream = process.getInputStream(); + LineNumberReader lnr = new LineNumberReader( + new InputStreamReader(inputStream)); + String line = null; + HashSet server = new HashSet(6); + while ((line = lnr.readLine()) != null) { + int split = line.indexOf("]: ["); + if (split == -1) { + continue; + } + String property = line.substring(1, split); + String value = line.substring(split + 4, line.length() - 1); + if (property.endsWith(".dns") || property.endsWith(".dns1") || + property.endsWith(".dns2") || property.endsWith(".dns3") || + property.endsWith(".dns4")) { + server.add(value); + } + } + if (server.size() > 0) { + return server.toArray(new String[server.size()]); + } + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/src/de/measite/minidns/DNSMessage.java b/src/de/measite/minidns/DNSMessage.java new file mode 100644 index 000000000..14c8f04b8 --- /dev/null +++ b/src/de/measite/minidns/DNSMessage.java @@ -0,0 +1,507 @@ +package de.measite.minidns; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Arrays; + +/** + * A DNS message as defined by rfc1035. The message consists of a header and + * 4 sections: question, answer, nameserver and addition resource record + * section. + * A message can either be parsed ({@see #parse(byte[])}) or serialized + * ({@see #toArray()}). + */ +public class DNSMessage { + + /** + * Possible DNS reply codes. + */ + public static enum RESPONSE_CODE { + NO_ERROR(0), FORMAT_ERR(1), SERVER_FAIL(2), NX_DOMAIN(3), + NO_IMP(4), REFUSED(5), YXDOMAIN(6), YXRRSET(7), + NXRRSET(8), NOT_AUTH(9),NOT_ZONE(10); + + /** + * Reverse lookup table for response codes. + */ + private final static RESPONSE_CODE INVERSE_LUT[] = new RESPONSE_CODE[]{ + NO_ERROR, FORMAT_ERR, SERVER_FAIL, NX_DOMAIN, NO_IMP, + REFUSED, YXDOMAIN, YXRRSET, NXRRSET, NOT_AUTH, NOT_ZONE, + null, null, null, null, null + }; + + /** + * The response code value. + */ + private final byte value; + + /** + * Create a new response code. + * @param value The response code value. + */ + private RESPONSE_CODE(int value) { + this.value = (byte)value; + } + + /** + * Retrieve the byte value of the response code. + * @return + */ + public byte getValue() { + return (byte) value; + } + + /** + * Retrieve the response code for a byte value. + * @param value The byte value. + * @return The symbolic response code or null. + * @throws IllegalArgumentException if the value is not in the range of + * 0..15. + */ + public static RESPONSE_CODE getResponseCode(int value) { + if (value < 0 || value > 15) { + throw new IllegalArgumentException(); + } + return INVERSE_LUT[value]; + } + + }; + + /** + * Symbolic DNS Opcode values. + */ + public static enum OPCODE { + QUERY(0), + INVERSE_QUERY(1), + STATUS(2), + NOTIFY(4), + UPDATE(5); + + /** + * Lookup table for for obcode reolution. + */ + private final static OPCODE INVERSE_LUT[] = new OPCODE[]{ + QUERY, INVERSE_QUERY, STATUS, null, NOTIFY, UPDATE, null, + null, null, null, null, null, null, null, null + }; + + /** + * The value of this opcode. + */ + private final byte value; + + /** + * Create a new opcode for a given byte value. + * @param value The byte value of the opcode. + */ + private OPCODE(int value) { + this.value = (byte)value; + } + + /** + * Retrieve the byte value of this opcode. + * @return The byte value of this opcode. + */ + public byte getValue() { + return value; + } + + /** + * Retrieve the symbolic name of an opcode byte. + * @param value The byte value of the opcode. + * @return The symbolic opcode or null. + * @throws IllegalArgumentException If the byte value is not in the + * range 0..15. + */ + public static OPCODE getOpcode(int value) { + if (value < 0 || value > 15) { + throw new IllegalArgumentException(); + } + return INVERSE_LUT[value]; + } + + }; + + /** + * The DNS message id. + */ + protected int id; + + /** + * The DNS message opcode. + */ + protected OPCODE opcode; + + /** + * The response code of this dns message. + */ + protected RESPONSE_CODE responseCode; + + /** + * True if this is a query. + */ + protected boolean query; + + /** + * True if this is a authorative response. + */ + protected boolean authoritativeAnswer; + + /** + * True on truncate, tcp should be used. + */ + protected boolean truncated; + + /** + * True if the server should recurse. + */ + protected boolean recursionDesired; + + /** + * True if recursion is possible. + */ + protected boolean recursionAvailable; + + /** + * True if the server regarded the response as authentic. + */ + protected boolean authenticData; + + /** + * True if the server should not check the replies. + */ + protected boolean checkDisabled; + + /** + * The question section content. + */ + protected Question questions[]; + + /** + * The answers section content. + */ + protected Record answers[]; + + /** + * The nameserver records. + */ + protected Record nameserverRecords[]; + + /** + * Additional resousrce records. + */ + protected Record additionalResourceRecords[]; + + /** + * Retrieve the current DNS message id. + * @return The current DNS message id. + */ + public int getId() { + return id; + } + + /** + * Set the current DNS message id. + * @param id The new DNS message id. + */ + public void setId(int id) { + this.id = id & 0xffff; + } + + /** + * Retrieve the query type (true or false; + * @return True if this DNS message is a query. + */ + public boolean isQuery() { + return query; + } + + /** + * Set the query status of this message. + * @param query The new query status. + */ + public void setQuery(boolean query) { + this.query = query; + } + + /** + * True if the DNS message is an authoritative answer. + * @return True if this an authoritative DNS message. + */ + public boolean isAuthoritativeAnswer() { + return authoritativeAnswer; + } + + /** + * Set the authoritative answer flag. + * @param authoritativeAnswer Tge new authoritative answer value. + */ + public void setAuthoritativeAnswer(boolean authoritativeAnswer) { + this.authoritativeAnswer = authoritativeAnswer; + } + + /** + * Retrieve the truncation status of this message. True means that the + * client should try a tcp lookup. + * @return True if this message was truncated. + */ + public boolean isTruncated() { + return truncated; + } + + /** + * Set the truncation bit on this DNS message. + * @param truncated The new truncated bit status. + */ + public void setTruncated(boolean truncated) { + this.truncated = truncated; + } + + /** + * Check if this message preferes recursion. + * @return True if recursion is desired. + */ + public boolean isRecursionDesired() { + return recursionDesired; + } + + /** + * Set the recursion desired flag on this message. + * @param recursionDesired The new recusrion setting. + */ + public void setRecursionDesired(boolean recursionDesired) { + this.recursionDesired = recursionDesired; + } + + /** + * Retrieve the recursion available flag of this DNS message. + * @return The recursion available flag of this message. + */ + public boolean isRecursionAvailable() { + return recursionAvailable; + } + + /** + * Set the recursion available flog from this DNS message. + * @param recursionAvailable The new recursion available status. + */ + public void setRecursionAvailable(boolean recursionAvailable) { + this.recursionAvailable = recursionAvailable; + } + + /** + * Retrieve the authentic data flag of this message. + * @return The authentic data flag. + */ + public boolean isAuthenticData() { + return authenticData; + } + + /** + * Set the authentic data flag on this DNS message. + * @param authenticData The new authentic data flag value. + */ + public void setAuthenticData(boolean authenticData) { + this.authenticData = authenticData; + } + + /** + * Check if checks are disabled. + * @return The status of the CheckDisabled flag. + */ + public boolean isCheckDisabled() { + return checkDisabled; + } + + /** + * Change the check status of this packet. + * @param checkDisabled + */ + public void setCheckDisabled(boolean checkDisabled) { + this.checkDisabled = checkDisabled; + } + + /** + * Generate a binary dns packet out of this message. + * @return byte[] the binary representation. + * @throws IOException Should never happen. + */ + public byte[] toArray() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(512); + DataOutputStream dos = new DataOutputStream(baos); + int header = 0; + if (query) { + header += 1 << 15; + } + if (opcode != null) { + header += opcode.getValue() << 11; + } + if (authoritativeAnswer) { + header += 1 << 10; + } + if (truncated) { + header += 1 << 9; + } + if (recursionDesired) { + header += 1 << 8; + } + if (recursionAvailable) { + header += 1 << 7; + } + if (authenticData) { + header += 1 << 5; + } + if (checkDisabled) { + header += 1 << 4; + } + if (responseCode != null) { + header += responseCode.getValue(); + } + dos.writeShort((short)id); + dos.writeShort((short)header); + if (questions == null) { + dos.writeShort(0); + } else { + dos.writeShort((short)questions.length); + } + if (answers == null) { + dos.writeShort(0); + } else { + dos.writeShort((short)answers.length); + } + if (nameserverRecords == null) { + dos.writeShort(0); + } else { + dos.writeShort((short)nameserverRecords.length); + } + if (additionalResourceRecords == null) { + dos.writeShort(0); + } else { + dos.writeShort((short)additionalResourceRecords.length); + } + for (Question question: questions) { + dos.write(question.toByteArray()); + } + dos.flush(); + return baos.toByteArray(); + } + + /** + * Build a DNS Message based on a binary DNS message. + * @param data The DNS message data. + * @return Parsed DNSMessage message. + * @throws IOException On read errors. + */ + public static DNSMessage parse(byte data[]) throws IOException { + ByteArrayInputStream bis = new ByteArrayInputStream(data); + DataInputStream dis = new DataInputStream(bis); + DNSMessage message = new DNSMessage(); + message.id = dis.readUnsignedShort(); + int header = dis.readUnsignedShort(); + message.query = ((header >> 15) & 1) == 0; + message.opcode = OPCODE.getOpcode((header >> 11) & 0xf); + message.authoritativeAnswer = ((header >> 10) & 1) == 1; + message.truncated = ((header >> 9) & 1) == 1; + message.recursionDesired = ((header >> 8) & 1) == 1; + message.recursionAvailable = ((header >> 7) & 1) == 1; + message.authenticData = ((header >> 5) & 1) == 1; + message.checkDisabled = ((header >> 4) & 1) == 1; + message.responseCode = RESPONSE_CODE.getResponseCode(header & 0xf); + int questionCount = dis.readUnsignedShort(); + int answerCount = dis.readUnsignedShort(); + int nameserverCount = dis.readUnsignedShort(); + int additionalResourceRecordCount = dis.readUnsignedShort(); + message.questions = new Question[questionCount]; + while (questionCount-- > 0) { + Question q = new Question(); + q.parse(dis, data); + message.questions[questionCount] = q; + } + message.answers = new Record[answerCount]; + while (answerCount-- > 0) { + Record rr = new Record(); + rr.parse(dis, data); + message.answers[answerCount] = rr; + } + message.nameserverRecords = new Record[nameserverCount]; + while (nameserverCount-- > 0) { + Record rr = new Record(); + rr.parse(dis, data); + message.nameserverRecords[nameserverCount] = rr; + } + message.additionalResourceRecords = + new Record[additionalResourceRecordCount]; + while (additionalResourceRecordCount-- > 0) { + Record rr = new Record(); + rr.parse(dis, data); + message.additionalResourceRecords[additionalResourceRecordCount] = + rr; + } + return message; + } + + /** + * Set the question part of this message. + * @param questions The questions. + */ + public void setQuestions(Question ... questions) { + this.questions = questions; + } + + /** + * Retrieve the opcode of this message. + * @return The opcode of this message. + */ + public OPCODE getOpcode() { + return opcode; + } + + /** + * Retrieve the response code of this message. + * @return The response code. + */ + public RESPONSE_CODE getResponseCode() { + return responseCode; + } + + /** + * Retrieve the question section of this message. + * @return The DNS question section. + */ + public Question[] getQuestions() { + return questions; + } + + /** + * Retrieve the answer records of this DNS message. + * @return The answer section of this DNS message. + */ + public Record[] getAnswers() { + return answers; + } + + /** + * Retrieve the nameserver records of this DNS message. + * @return The nameserver section of this DNS message. + */ + public Record[] getNameserverRecords() { + return nameserverRecords; + } + + /** + * Retrieve the additional resource records attached to this DNS message. + * @return The additional resource record section of this DNS message. + */ + public Record[] getAdditionalResourceRecords() { + return additionalResourceRecords; + } + + public String toString() { + return "-- DNSMessage " + id + " --\n" + + Arrays.toString(answers); + } + +} diff --git a/src/de/measite/minidns/Question.java b/src/de/measite/minidns/Question.java new file mode 100644 index 000000000..9d1e3f561 --- /dev/null +++ b/src/de/measite/minidns/Question.java @@ -0,0 +1,62 @@ +package de.measite.minidns; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import de.measite.minidns.Record.CLASS; +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +public class Question { + + private String name; + + private TYPE type; + + private CLASS clazz = CLASS.IN; + + public TYPE getType() { + return type; + } + + public void setType(TYPE type) { + this.type = type; + } + + public CLASS getClazz() { + return clazz; + } + + public void setClazz(CLASS clazz) { + this.clazz = clazz; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public void parse(DataInputStream dis, byte[] data) throws IOException { + this.name = NameUtil.parse(dis, data); + this.type = TYPE.getType(dis.readUnsignedShort()); + this.clazz = CLASS.getClass(dis.readUnsignedShort()); + } + + public byte[] toByteArray() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(512); + DataOutputStream dos = new DataOutputStream(baos); + + dos.write(NameUtil.toByteArray(this.name)); + dos.writeShort(type.getValue()); + dos.writeShort(clazz.getValue()); + + dos.flush(); + return baos.toByteArray(); + } + +} diff --git a/src/de/measite/minidns/Record.java b/src/de/measite/minidns/Record.java new file mode 100644 index 000000000..adfa9713b --- /dev/null +++ b/src/de/measite/minidns/Record.java @@ -0,0 +1,293 @@ +package de.measite.minidns; + +import java.io.DataInputStream; +import java.io.IOException; +import java.util.HashMap; + +import de.measite.minidns.record.A; +import de.measite.minidns.record.AAAA; +import de.measite.minidns.record.CNAME; +import de.measite.minidns.record.Data; +import de.measite.minidns.record.NS; +import de.measite.minidns.record.SRV; +import de.measite.minidns.util.NameUtil; + +/** + * A generic DNS record. + */ +public class Record { + + /** + * The record type. + * {@see http://www.iana.org/assignments/dns-parameters} + */ + public static enum TYPE { + A(1), + NS(2), + MD(3), + MF(4), + CNAME(5), + SOA(6), + MB(7), + MG(8), + MR(9), + NULL(10), + WKS(11), + PTR(12), + HINFO(13), + MINFO(14), + MX(15), + TXT(16), + RP(17), + AFSDB(18), + X25(19), + ISDN(20), + RT(21), + NSAP(22), + NSAP_PTR(23), + SIG(24), + KEY(25), + PX(26), + GPOS(27), + AAAA(28), + LOC(29), + NXT(30), + EID(31), + NIMLOC(32), + SRV(33), + ATMA(34), + NAPTR(35), + KX(36), + CERT(37), + A6(38), + DNAME(39), + SINK(40), + OPT(41), + APL(42), + DS(43), + SSHFP(44), + IPSECKEY(45), + RRSIG(46), + NSEC(47), + DNSKEY(48), + DHCID(49), + NSEC3(50), + NSEC3PARAM(51), + HIP(55), + NINFO(56), + RKEY(57), + TALINK(58), + SPF(99), + UINFO(100), + UID(101), + GID(102), + TKEY(249), + TSIG(250), + IXFR(251), + AXFR(252), + MAILB(253), + MAILA(254), + ANY(255), + TA(32768), + DLV(32769); + + /** + * The value of this DNS record type. + */ + private final int value; + + /** + * Internal lookup table to map values to types. + */ + private final static HashMap INVERSE_LUT = + new HashMap(); + + /** + * Initialize the reverse lookup table. + */ + static { + for(TYPE t: TYPE.values()) { + INVERSE_LUT.put(t.getValue(), t); + } + } + + /** + * Create a new record type. + * @param value The binary value of this type. + */ + private TYPE(int value) { + this.value = value; + } + + /** + * Retrieve the binary value of this type. + * @return The binary value. + */ + public int getValue() { + return value; + } + + /** + * Retrieve the symbolic type of the binary value. + * @param value The binary type value. + * @return The symbolic tpye. + */ + public static TYPE getType(int value) { + return INVERSE_LUT.get(value); + } + }; + + /** + * The symbolic class of a DNS record (usually IN for Internet). + */ + public static enum CLASS { + IN(1), + CH(3), + HS(4), + NONE(254), + ANY(255); + + /** + * Internal reverse lookup table to map binary class values to symbolic + * names. + */ + private final static HashMap INVERSE_LUT = + new HashMap(); + + /** + * Initialize the interal reverse lookup table. + */ + static { + for(CLASS c: CLASS.values()) { + INVERSE_LUT.put(c.getValue(), c); + } + } + + /** + * The binary value of this dns class. + */ + private final int value; + + /** + * Create a new DNS class based on a binary value. + * @param value The binary value of this DNS class. + */ + private CLASS(int value) { + this.value = value; + } + + /** + * Retrieve the binary value of this DNS class. + * @return The binary value of this DNS class. + */ + public int getValue() { + return value; + } + + /** + * Retrieve the symbolic DNS class for a binary class value. + * @param value The binary DNS class value. + * @return The symbolic class instance. + */ + public static CLASS getClass(int value) { + return INVERSE_LUT.get(value); + } + + } + + /** + * The generic name of this record. + */ + protected String name; + + /** + * The type (and payload type) of this record. + */ + protected TYPE type; + + /** + * The record class (usually CLASS.IN). + */ + protected CLASS clazz; + + /** + * The ttl of this record. + */ + protected long ttl; + + /** + * The payload object of this record. + */ + protected Data payloadData; + + /** + * Parse a given record based on the full message data and the current + * stream position. + * @param dis The DataInputStream positioned at the first record byte. + * @param data The full message data. + * @throws IOException In case of malformed replies. + */ + public void parse(DataInputStream dis, byte[] data) throws IOException { + this.name = NameUtil.parse(dis, data); + this.type = TYPE.getType(dis.readUnsignedShort()); + this.clazz = CLASS.getClass(dis.readUnsignedShort()); + this.ttl = (((long)dis.readUnsignedShort()) << 32) + + dis.readUnsignedShort(); + int payloadLength = dis.readUnsignedShort(); + switch (this.type) { + case SRV: + this.payloadData = new SRV(); + break; + case AAAA: + this.payloadData = new AAAA(); + break; + case A: + this.payloadData = new A(); + break; + case NS: + this.payloadData = new NS(); + break; + case CNAME: + this.payloadData = new CNAME(); + break; + default: + System.out.println("Unparsed type " + type); + this.payloadData = null; + for (int i = 0; i < payloadLength; i++) { + dis.readByte(); + } + break; + } + if (this.payloadData != null) { + this.payloadData.parse(dis, data, payloadLength); + } + } + + /** + * Retrieve a textual representation of this resource record. + * @return String + */ + @Override + public String toString() { + if (payloadData == null) { + return "RR " + type + "/" + clazz; + } + return "RR " + type + "/" + clazz + ": " + payloadData.toString(); + }; + + /** + * Check if this record answers a given query. + * @param q The query. + * @return True if this record is a valid answer. + */ + public boolean isAnswer(Question q) { + return ((q.getType() == type) || (q.getType() == TYPE.ANY)) && + ((q.getClazz() == clazz) || (q.getClazz() == CLASS.ANY)) && + (q.getName().equals(name)); + } + + public Data getPayload() { + return payloadData; + } + +} diff --git a/src/de/measite/minidns/record/A.java b/src/de/measite/minidns/record/A.java new file mode 100644 index 000000000..a85a7af0d --- /dev/null +++ b/src/de/measite/minidns/record/A.java @@ -0,0 +1,37 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; + +public class A implements Data { + + private byte[] ip; + + @Override + public TYPE getType() { + return TYPE.A; + } + + @Override + public byte[] toByteArray() { + return ip; + } + + @Override + public void parse(DataInputStream dis, byte[] data, int length) + throws IOException { + ip = new byte[4]; + dis.readFully(ip); + } + + @Override + public String toString() { + return Integer.toString(ip[0] & 0xff) + "." + + Integer.toString(ip[1] & 0xff) + "." + + Integer.toString(ip[2] & 0xff) + "." + + Integer.toString(ip[3] & 0xff); + } + +} diff --git a/src/de/measite/minidns/record/AAAA.java b/src/de/measite/minidns/record/AAAA.java new file mode 100644 index 000000000..d89147b2c --- /dev/null +++ b/src/de/measite/minidns/record/AAAA.java @@ -0,0 +1,43 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; + +public class AAAA implements Data { + + private byte[] ip; + + @Override + public TYPE getType() { + return TYPE.AAAA; + } + + @Override + public byte[] toByteArray() { + return ip; + } + + @Override + public void parse(DataInputStream dis, byte[] data, int length) + throws IOException { + ip = new byte[16]; + dis.readFully(ip); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ip.length; i += 2) { + if (i != 0) { + sb.append(':'); + } + sb.append(Integer.toHexString( + ((ip[i] & 0xff) << 8) + (ip[i + 1] & 0xff) + )); + } + return sb.toString(); + } + +} diff --git a/src/de/measite/minidns/record/CNAME.java b/src/de/measite/minidns/record/CNAME.java new file mode 100644 index 000000000..4657b4a51 --- /dev/null +++ b/src/de/measite/minidns/record/CNAME.java @@ -0,0 +1,44 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +public class CNAME implements Data { + + protected String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public byte[] toByteArray() { + // TODO Auto-generated method stub + return null; + } + + @Override + public void parse(DataInputStream dis, byte[] data, int length) + throws IOException + { + this.name = NameUtil.parse(dis, data); + } + + @Override + public TYPE getType() { + return TYPE.CNAME; + } + + @Override + public String toString() { + return "to \"" + name + "\""; + } + +} diff --git a/src/de/measite/minidns/record/Data.java b/src/de/measite/minidns/record/Data.java new file mode 100644 index 000000000..9cb803742 --- /dev/null +++ b/src/de/measite/minidns/record/Data.java @@ -0,0 +1,16 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; + +public interface Data { + + TYPE getType(); + + byte[] toByteArray(); + + void parse(DataInputStream dis, byte data[], int length) throws IOException; + +} diff --git a/src/de/measite/minidns/record/NS.java b/src/de/measite/minidns/record/NS.java new file mode 100644 index 000000000..bf07e8c41 --- /dev/null +++ b/src/de/measite/minidns/record/NS.java @@ -0,0 +1,12 @@ +package de.measite.minidns.record; + +import de.measite.minidns.Record.TYPE; + +public class NS extends CNAME { + + @Override + public TYPE getType() { + return TYPE.NS; + } + +} diff --git a/src/de/measite/minidns/record/SRV.java b/src/de/measite/minidns/record/SRV.java new file mode 100644 index 000000000..32b70c4d3 --- /dev/null +++ b/src/de/measite/minidns/record/SRV.java @@ -0,0 +1,74 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +public class SRV implements Data { + + protected int priority; + protected int weight; + protected int port; + protected String name; + + public int getPriority() { + return priority; + } + + public void setPriority(int priority) { + this.priority = priority; + } + + public int getWeight() { + return weight; + } + + public void setWeight(int weight) { + this.weight = weight; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public byte[] toByteArray() { + // TODO Auto-generated method stub + return null; + } + + @Override + public void parse(DataInputStream dis, byte[] data, int length) + throws IOException + { + this.priority = dis.readUnsignedShort(); + this.weight = dis.readUnsignedShort(); + this.port = dis.readUnsignedShort(); + this.name = NameUtil.parse(dis, data); + } + + @Override + public String toString() { + return "SRV " + name + ":" + port + " p:" + priority + " w:" + weight; + } + + @Override + public TYPE getType() { + return TYPE.SRV; + } + +} diff --git a/src/de/measite/minidns/util/NameUtil.java b/src/de/measite/minidns/util/NameUtil.java new file mode 100644 index 000000000..c42ac3ebe --- /dev/null +++ b/src/de/measite/minidns/util/NameUtil.java @@ -0,0 +1,77 @@ +package de.measite.minidns.util; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.IDN; +import java.util.HashSet; + +public class NameUtil { + + public static int size(String name) { + return name.length() + 2; + } + + public static byte[] toByteArray(String name) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(64); + DataOutputStream dos = new DataOutputStream(baos); + for (String s: name.split("[.\u3002\uFF0E\uFF61]")) { + byte[] buffer = IDN.toASCII(s).getBytes(); + dos.writeByte(buffer.length); + dos.write(buffer); + } + dos.writeByte(0); + dos.flush(); + return baos.toByteArray(); + } + + public static String parse(DataInputStream dis, byte data[]) + throws IOException + { + int c = dis.readUnsignedByte(); + if ((c & 0xc0) == 0xc0) { + c = ((c & 0x3f) << 8) + dis.readUnsignedByte(); + HashSet jumps = new HashSet(); + jumps.add(c); + return parse(data, c, jumps); + } + if (c == 0) { + return ""; + } + byte b[] = new byte[c]; + dis.readFully(b); + String s = IDN.toUnicode(new String(b)); + String t = parse(dis, data); + if (t.length() > 0) { + s = s + "." + t; + } + return s; + } + + public static String parse( + byte data[], + int offset, + HashSet jumps + ) { + int c = data[offset] & 0xff; + if ((c & 0xc0) == 0xc0) { + c = ((c & 0x3f) << 8) + data[offset + 1]; + if (jumps.contains(c)) { + throw new IllegalStateException("Cyclic offsets detected."); + } + jumps.add(c); + return parse(data, c, jumps); + } + if (c == 0) { + return ""; + } + String s = new String(data,offset + 1, c); + String t = parse(data, offset + 1 + c, jumps); + if (t.length() > 0) { + s = s + "." + t; + } + return s; + } + +} From d5cca3b3a48cfaed3008050fcab9574d97d771cc Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 2 Apr 2014 22:45:57 +0200 Subject: [PATCH 02/81] Try various ways to get the dns settings --- src/de/measite/minidns/Client.java | 91 +++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/de/measite/minidns/Client.java b/src/de/measite/minidns/Client.java index c32ec3a1f..beb461bec 100644 --- a/src/de/measite/minidns/Client.java +++ b/src/de/measite/minidns/Client.java @@ -4,14 +4,18 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.LineNumberReader; +import java.lang.reflect.Method; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.Random; +import android.util.Log; import de.measite.minidns.Record.CLASS; import de.measite.minidns.Record.TYPE; @@ -130,6 +134,33 @@ public class Client { * @return The server array. */ public String[] findDNS() { + String[] result = findDNSByReflection(); + if (result != null) { + Log.d("minidns/client", + "Got DNS servers via reflection: " + Arrays.toString(result)); + return result; + } + + result = findDNSByExec(); + if (result != null) { + Log.d("minidns/client", + "Got DNS servers via exec: " + Arrays.toString(result)); + return result; + } + + // fallback for ipv4 and ipv6 connectivity + // see https://developers.google.com/speed/public-dns/docs/using + Log.d("minidns/client", + "No DNS found? Using fallback [8.8.8.8, [2001:4860:4860::8888]]"); + + return new String[]{"8.8.8.8", "[2001:4860:4860::8888]"}; + } + + /** + * Try to retrieve the list of dns server by executing getprop. + * @return Array of servers, or null on failure. + */ + protected String[] findDNSByExec() { try { Process process = Runtime.getRuntime().exec("getprop"); InputStream inputStream = process.getInputStream(); @@ -146,7 +177,19 @@ public class Client { String value = line.substring(split + 4, line.length() - 1); if (property.endsWith(".dns") || property.endsWith(".dns1") || property.endsWith(".dns2") || property.endsWith(".dns3") || - property.endsWith(".dns4")) { + property.endsWith(".dns4")) { + + // normalize the address + + InetAddress ip = InetAddress.getByName(value); + + if (ip == null) continue; + + value = ip.getHostAddress(); + + if (value == null) continue; + if (value.length() == 0) continue; + server.add(value); } } @@ -158,4 +201,50 @@ public class Client { } return null; } + + /** + * Try to retrieve the list of dns server by calling SystemProperties. + * @return Array of servers, or null on failure. + */ + protected String[] findDNSByReflection() { + try { + Class SystemProperties = + Class.forName("android.os.SystemProperties"); + Method method = SystemProperties.getMethod("get", + new Class[] { String.class }); + + ArrayList servers = new ArrayList(5); + + for (String propKey : new String[] { + "net.dns1", "net.dns2", "net.dns3", "net.dns4"}) { + + String value = (String)method.invoke(null, propKey); + + if (value == null) continue; + if (value.length() == 0) continue; + if (servers.contains(value)) continue; + + InetAddress ip = InetAddress.getByName(value); + + if (ip == null) continue; + + value = ip.getHostAddress(); + + if (value == null) continue; + if (value.length() == 0) continue; + if (servers.contains(value)) continue; + + servers.add(value); + } + + if (servers.size() > 0) { + return servers.toArray(new String[servers.size()]); + } + } catch (Exception e) { + // we might trigger some problems this way + e.printStackTrace(); + } + return null; + } + } From 7d086eaf1a6a0b1d45e857eaf65ac53cd7a753e5 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Thu, 3 Apr 2014 10:05:32 +0200 Subject: [PATCH 03/81] Export the record name --- src/de/measite/minidns/Record.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/de/measite/minidns/Record.java b/src/de/measite/minidns/Record.java index adfa9713b..fb0b5d5f6 100644 --- a/src/de/measite/minidns/Record.java +++ b/src/de/measite/minidns/Record.java @@ -286,6 +286,10 @@ public class Record { (q.getName().equals(name)); } + public String getName() { + return name; + } + public Data getPayload() { return payloadData; } From 8a8a7f3b0dd7928802a97e346b307bb06fdb3322 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Thu, 3 Apr 2014 10:28:24 +0200 Subject: [PATCH 04/81] Add an IDN equals method to check names --- src/de/measite/minidns/util/NameUtil.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/de/measite/minidns/util/NameUtil.java b/src/de/measite/minidns/util/NameUtil.java index c42ac3ebe..a7edc22ad 100644 --- a/src/de/measite/minidns/util/NameUtil.java +++ b/src/de/measite/minidns/util/NameUtil.java @@ -6,6 +6,7 @@ import java.io.DataOutputStream; import java.io.IOException; import java.net.IDN; import java.util.HashSet; +import java.util.Arrays; public class NameUtil { @@ -13,6 +14,19 @@ public class NameUtil { return name.length() + 2; } + public static boolean idnEquals(String name1, String name2) { + if (name1 == name2) return true; // catches null, null + if (name1 == null) return false; + if (name2 == null) return false; + if (name1.equals(name2)) return true; + + try { + return Arrays.equals(toByteArray(name1),toByteArray(name2)); + } catch (IOException e) { + return false; // impossible + } + } + public static byte[] toByteArray(String name) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(64); DataOutputStream dos = new DataOutputStream(baos); From 921674b3f1585dde76d13f181abcfc6d49bd737d Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Thu, 3 Apr 2014 10:51:05 +0200 Subject: [PATCH 05/81] Fix negative jump targets --- src/de/measite/minidns/util/NameUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/de/measite/minidns/util/NameUtil.java b/src/de/measite/minidns/util/NameUtil.java index a7edc22ad..91a6649d8 100644 --- a/src/de/measite/minidns/util/NameUtil.java +++ b/src/de/measite/minidns/util/NameUtil.java @@ -70,7 +70,7 @@ public class NameUtil { ) { int c = data[offset] & 0xff; if ((c & 0xc0) == 0xc0) { - c = ((c & 0x3f) << 8) + data[offset + 1]; + c = ((c & 0x3f) << 8) + (data[offset + 1] & 0xff); if (jumps.contains(c)) { throw new IllegalStateException("Cyclic offsets detected."); } From f1d50a5380f355a64913ba19f717debd8c40ab30 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Thu, 3 Apr 2014 16:12:08 +0200 Subject: [PATCH 06/81] AndroidManifest.xml defines minSDK 9, set target sdk to the current one (19) --- build.gradle | 2 +- project.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 2a17c6ae6..ae9c7693a 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ buildscript { apply plugin: 'android-library' android { - compileSdkVersion 9 + compileSdkVersion 19 buildToolsVersion '19.0.3' // NOTE: We are using the old folder structure to also support Eclipse diff --git a/project.properties b/project.properties index 7096c5cef..9e86aa644 100644 --- a/project.properties +++ b/project.properties @@ -1,2 +1,2 @@ -target=android-9 +target=android-19 android.library=true From 114bda4d3fde90d5c824884ce6b3f8ada846b435 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Thu, 3 Apr 2014 16:13:30 +0200 Subject: [PATCH 07/81] Fix name in build.xml --- build.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.xml b/build.xml index ed7657f24..e10480a05 100644 --- a/build.xml +++ b/build.xml @@ -1,5 +1,5 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/proguard-project.txt b/proguard-project.txt new file mode 100644 index 000000000..f2fe1559a --- /dev/null +++ b/proguard-project.txt @@ -0,0 +1,20 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/project.properties b/project.properties new file mode 100644 index 000000000..91d2b0246 --- /dev/null +++ b/project.properties @@ -0,0 +1,15 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-19 +android.library=true diff --git a/res/drawable-hdpi/ic_action_cancel_launchersize.png b/res/drawable-hdpi/ic_action_cancel_launchersize.png new file mode 100644 index 0000000000000000000000000000000000000000..71b9118dc0f992fc88cf91375090f1c04fc76e84 GIT binary patch literal 1520 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 literal 0 HcmV?d00001 diff --git a/res/drawable-mdpi/ic_action_cancel_launchersize.png b/res/drawable-mdpi/ic_action_cancel_launchersize.png new file mode 100644 index 0000000000000000000000000000000000000000..270abf45fdd55510fc885046717a66bd73cc9379 GIT binary patch literal 1032 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 literal 0 HcmV?d00001 diff --git a/res/drawable-xhdpi/ic_action_cancel_launchersize_light.png b/res/drawable-xhdpi/ic_action_cancel_launchersize_light.png new file mode 100644 index 0000000000000000000000000000000000000000..d505046b4d59b4c26511f68b404cdbc4f933edff GIT binary patch literal 2039 zcmaJ?eKZq}8lGV*`N+44_=#G+lFu@f^0lU+D3hYlSY||%)@D>ftk5MNW0_w{WMd{I zE1`(Rtu{8yRHL?pntmu(_y2pJ_dM@&&U4=L{PUjoyg7kLUo{m&6#xLB2KVzivaXST zxB;}@#dlKotPAK2!q*G1_NQo_wMFaB#za3 z7ChKBzV2LwdwB+59FtYZa5_BQ-t?Q3jNQr)%MRS{)71G1n6s0#|o?!OViK<)d*KnqVtZSlk7(XWU#|9amsQA(I=lekDK31>}r{%^$L z-_9+~F0Fh>BZ-!-6DO37-+fwkN{AC0LvySDX(j9KXQyURo2=JI{4PzU*SddW?;L2_ z`oSVnlObzpvL=R_q(9UP`zo+Z9F=F*5ZG%QRn0FX$X&)9H7-Z4xWl;_yE1CA)YQ8@ zs2{~L4GW@KT#vkpobSd>x6D1$nm_veM5i-%(Obmj?1iT*u*o~pDkq`2Vo$3q+}(pf zB>#8tNY5R3U&b0>5-!bD?bwVZMiamnkB?XgBD^MDZ<>yt z%0qQo&T4*hz!iptGn`1~qu=K|zAO#-Q zLOd{XnU=7*v4|ajuxE^Z`9XSAms?jiv$PV8@(BrPP`lj7+tM=x5jnuCfVE^BIDzvd z%XUHMv6}F9a7c*LjAN#`3^1t}ai|^S*4i0Q7?N*g*u*h=3ujhz|pKL@b4BNO=!#$Ub zrTmGOPW5mm@u7MNwl(wRdm7;-P@=^+yOJ_Ke@AJ6)1Afa)iO5Ll7*{CD=4wG@0|Sy z0d#{LeL89ab?<4COAK*eyCSsF3gf8BhD@#)u^B zeKXIj33whU;8e;keZnb?oqA02*M?CS{+qWK-bYsXd0h1aY(H1t%w9y`>J>=dDUSM~ zW*&_Z)V`s&bQ&**63-hRR!_9vo*%^k7lP&|=ErvnU~C>Xb^BQ9MvXm6z6jNaR=?F( z)h=2+5t$AuY8ZT-BB=>+UWcg9kPX-$fqa#g01;WVQ5RJoLfMAcM!_72a*V7d;Q2(Yk-9W) zRnv}pA{<(yRx)K40`BM79eQDlY{@nred@1O)zCs#6)g9b^Ix%fX?K5J3te3`rh(nc zudQ;S4z(WNP{sLYB>s^S{g0VUtqD+q7jlMV@ImH-iHRhb&PE$>3rRts^(zRox>8XZ z9+;f;HvAV59anSv3(EM_<8$n;f)p1aOLAP=ej2TpRO6xsYtEuOZ+)L3OM}n(o=r6n(l&>?|8)3wk28}=}-N9{+Tl~`IgxB_wuiV zN=YiP+)lV5Eb?cKj)PD%nP)!5cW=pu#tz7j7e2BR+J*y>qZ1n|kw&n_Tzag)wfp+{ zil$B_;mxO)d)BP%+jD3jAN-jT46M59FuWBTL3PBFEGS63%dvgk*>4hbyyqvKkGzla z4s@7C>GWJ7b)}yiF%i2hkxgz?8~7{28Z+o7kgwZT%crI>kMzCgztcSMLjZ8_Rvch0 zJ2BP8vK02fM*!v=&U$yDnDwL31CN+})&T!MpboDAUI&I8C@jCZu>PU}aBrm7qXUtd F{{iyG+f)Dm literal 0 HcmV?d00001 diff --git a/res/drawable-xxhdpi/ic_action_cancel_launchersize.png b/res/drawable-xxhdpi/ic_action_cancel_launchersize.png new file mode 100644 index 0000000000000000000000000000000000000000..52044601e4036ee1da50f18550af7cdf9dc458b0 GIT binary patch literal 2345 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 + + + None + Install OpenKeychain via %s + + \ No newline at end of file diff --git a/src/org/openintents/openpgp/IOpenPgpService.aidl b/src/org/openintents/openpgp/IOpenPgpService.aidl new file mode 100644 index 000000000..7ee79d6ab --- /dev/null +++ b/src/org/openintents/openpgp/IOpenPgpService.aidl @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp; + +interface IOpenPgpService { + + // see OpenPgpApi for documentation + Intent execute(in Intent data, in ParcelFileDescriptor input, in ParcelFileDescriptor output); + +} \ No newline at end of file diff --git a/src/org/openintents/openpgp/OpenPgpError.java b/src/org/openintents/openpgp/OpenPgpError.java new file mode 100644 index 000000000..b894a4609 --- /dev/null +++ b/src/org/openintents/openpgp/OpenPgpError.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Parcelable versioning has been copied from Dashclock Widget + * https://code.google.com/p/dashclock/source/browse/api/src/main/java/com/google/android/apps/dashclock/api/ExtensionData.java + */ +public class OpenPgpError implements Parcelable { + /** + * Since there might be a case where new versions of the client using the library getting + * old versions of the protocol (and thus old versions of this class), we need a versioning + * system for the parcels sent between the clients and the providers. + */ + public static final int PARCELABLE_VERSION = 1; + + // possible values for errorId + public static final int CLIENT_SIDE_ERROR = -1; + public static final int GENERIC_ERROR = 0; + public static final int INCOMPATIBLE_API_VERSIONS = 1; + public static final int NO_OR_WRONG_PASSPHRASE = 2; + public static final int NO_USER_IDS = 3; + + int errorId; + String message; + + public OpenPgpError() { + } + + public OpenPgpError(int errorId, String message) { + this.errorId = errorId; + this.message = message; + } + + public OpenPgpError(OpenPgpError b) { + this.errorId = b.errorId; + this.message = b.message; + } + + public int getErrorId() { + return errorId; + } + + public void setErrorId(int errorId) { + this.errorId = errorId; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + /** + * NOTE: When adding fields in the process of updating this API, make sure to bump + * {@link #PARCELABLE_VERSION}. + */ + dest.writeInt(PARCELABLE_VERSION); + // Inject a placeholder that will store the parcel size from this point on + // (not including the size itself). + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeInt(errorId); + dest.writeString(message); + // Go back and write the size + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + public OpenPgpError createFromParcel(final Parcel source) { + int parcelableVersion = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + OpenPgpError error = new OpenPgpError(); + error.errorId = source.readInt(); + error.message = source.readString(); + + // skip over all fields added in future versions of this parcel + source.setDataPosition(startPosition + parcelableSize); + + return error; + } + + public OpenPgpError[] newArray(final int size) { + return new OpenPgpError[size]; + } + }; +} diff --git a/src/org/openintents/openpgp/OpenPgpSignatureResult.java b/src/org/openintents/openpgp/OpenPgpSignatureResult.java new file mode 100644 index 000000000..7a4d799dc --- /dev/null +++ b/src/org/openintents/openpgp/OpenPgpSignatureResult.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp; + +import android.os.Parcel; +import android.os.Parcelable; + +import org.openintents.openpgp.util.OpenPgpUtils; + +import java.util.Locale; + +/** + * Parcelable versioning has been copied from Dashclock Widget + * https://code.google.com/p/dashclock/source/browse/api/src/main/java/com/google/android/apps/dashclock/api/ExtensionData.java + */ +public class OpenPgpSignatureResult implements Parcelable { + /** + * Since there might be a case where new versions of the client using the library getting + * old versions of the protocol (and thus old versions of this class), we need a versioning + * system for the parcels sent between the clients and the providers. + */ + public static final int PARCELABLE_VERSION = 1; + + // generic error on signature verification + public static final int SIGNATURE_ERROR = 0; + // successfully verified signature, with certified public key + public static final int SIGNATURE_SUCCESS_CERTIFIED = 1; + // no public key was found for this signature verification + public static final int SIGNATURE_UNKNOWN_PUB_KEY = 2; + // successfully verified signature, but with uncertified public key + public static final int SIGNATURE_SUCCESS_UNCERTIFIED = 3; + + int status; + boolean signatureOnly; + String userId; + long keyId; + + public int getStatus() { + return status; + } + + public void setStatus(int status) { + this.status = status; + } + + public boolean isSignatureOnly() { + return signatureOnly; + } + + public void setSignatureOnly(boolean signatureOnly) { + this.signatureOnly = signatureOnly; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public long getKeyId() { + return keyId; + } + + public void setKeyId(long keyId) { + this.keyId = keyId; + } + + public OpenPgpSignatureResult() { + + } + + public OpenPgpSignatureResult(int signatureStatus, String signatureUserId, + boolean signatureOnly, long keyId) { + this.status = signatureStatus; + this.signatureOnly = signatureOnly; + this.userId = signatureUserId; + this.keyId = keyId; + } + + public OpenPgpSignatureResult(OpenPgpSignatureResult b) { + this.status = b.status; + this.userId = b.userId; + this.signatureOnly = b.signatureOnly; + this.keyId = b.keyId; + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + /** + * NOTE: When adding fields in the process of updating this API, make sure to bump + * {@link #PARCELABLE_VERSION}. + */ + dest.writeInt(PARCELABLE_VERSION); + // Inject a placeholder that will store the parcel size from this point on + // (not including the size itself). + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeInt(status); + dest.writeByte((byte) (signatureOnly ? 1 : 0)); + dest.writeString(userId); + dest.writeLong(keyId); + // Go back and write the size + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + public OpenPgpSignatureResult createFromParcel(final Parcel source) { + int parcelableVersion = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + OpenPgpSignatureResult vr = new OpenPgpSignatureResult(); + vr.status = source.readInt(); + vr.signatureOnly = source.readByte() == 1; + vr.userId = source.readString(); + vr.keyId = source.readLong(); + + // skip over all fields added in future versions of this parcel + source.setDataPosition(startPosition + parcelableSize); + + return vr; + } + + public OpenPgpSignatureResult[] newArray(final int size) { + return new OpenPgpSignatureResult[size]; + } + }; + + @Override + public String toString() { + String out = new String(); + out += "\nstatus: " + status; + out += "\nuserId: " + userId; + out += "\nsignatureOnly: " + signatureOnly; + out += "\nkeyId: " + OpenPgpUtils.convertKeyIdToHex(keyId); + return out; + } + +} diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java new file mode 100644 index 000000000..f6a78d0ef --- /dev/null +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import org.openintents.openpgp.IOpenPgpService; +import org.openintents.openpgp.OpenPgpError; + +import java.io.InputStream; +import java.io.OutputStream; + +public class OpenPgpApi { + + public static final String TAG = "OpenPgp API"; + + public static final int API_VERSION = 3; + public static final String SERVICE_INTENT = "org.openintents.openpgp.IOpenPgpService"; + + /** + * General extras + * -------------- + * + * required extras: + * int EXTRA_API_VERSION (always required) + * + * returned extras: + * int RESULT_CODE (RESULT_CODE_ERROR, RESULT_CODE_SUCCESS or RESULT_CODE_USER_INTERACTION_REQUIRED) + * OpenPgpError RESULT_ERROR (if RESULT_CODE == RESULT_CODE_ERROR) + * PendingIntent RESULT_INTENT (if RESULT_CODE == RESULT_CODE_USER_INTERACTION_REQUIRED) + */ + + /** + * Sign only + *

+ * optional extras: + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * String EXTRA_PASSPHRASE (key passphrase) + */ + public static final String ACTION_SIGN = "org.openintents.openpgp.action.SIGN"; + + /** + * Encrypt + *

+ * required extras: + * String[] EXTRA_USER_IDS (=emails of recipients, if more than one key has a user_id, a PendingIntent is returned via RESULT_INTENT) + * or + * long[] EXTRA_KEY_IDS + *

+ * optional extras: + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * String EXTRA_PASSPHRASE (key passphrase) + */ + public static final String ACTION_ENCRYPT = "org.openintents.openpgp.action.ENCRYPT"; + + /** + * Sign and encrypt + *

+ * required extras: + * String[] EXTRA_USER_IDS (=emails of recipients, if more than one key has a user_id, a PendingIntent is returned via RESULT_INTENT) + * or + * long[] EXTRA_KEY_IDS + *

+ * optional extras: + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * String EXTRA_PASSPHRASE (key passphrase) + */ + public static final String ACTION_SIGN_AND_ENCRYPT = "org.openintents.openpgp.action.SIGN_AND_ENCRYPT"; + + /** + * Decrypts and verifies given input stream. This methods handles encrypted-only, signed-and-encrypted, + * and also signed-only input. + *

+ * If OpenPgpSignatureResult.getStatus() == OpenPgpSignatureResult.SIGNATURE_UNKNOWN_PUB_KEY + * in addition a PendingIntent is returned via RESULT_INTENT to download missing keys. + *

+ * optional extras: + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + *

+ * returned extras: + * OpenPgpSignatureResult RESULT_SIGNATURE + */ + public static final String ACTION_DECRYPT_VERIFY = "org.openintents.openpgp.action.DECRYPT_VERIFY"; + + /** + * Get key ids based on given user ids (=emails) + *

+ * required extras: + * String[] EXTRA_USER_IDS + *

+ * returned extras: + * long[] RESULT_KEY_IDS + */ + public static final String ACTION_GET_KEY_IDS = "org.openintents.openpgp.action.GET_KEY_IDS"; + + /** + * This action returns RESULT_CODE_SUCCESS if the OpenPGP Provider already has the key + * corresponding to the given key id in its database. + *

+ * It returns RESULT_CODE_USER_INTERACTION_REQUIRED if the Provider does not have the key. + * The PendingIntent from RESULT_INTENT can be used to retrieve those from a keyserver. + *

+ * required extras: + * long EXTRA_KEY_ID + */ + public static final String ACTION_GET_KEY = "org.openintents.openpgp.action.GET_KEY"; + + /* Intent extras */ + public static final String EXTRA_API_VERSION = "api_version"; + + public static final String EXTRA_ACCOUNT_NAME = "account_name"; + + // SIGN, ENCRYPT, SIGN_AND_ENCRYPT, DECRYPT_VERIFY + // request ASCII Armor for output + // OpenPGP Radix-64, 33 percent overhead compared to binary, see http://tools.ietf.org/html/rfc4880#page-53) + public static final String EXTRA_REQUEST_ASCII_ARMOR = "ascii_armor"; + + // ENCRYPT, SIGN_AND_ENCRYPT + public static final String EXTRA_USER_IDS = "user_ids"; + public static final String EXTRA_KEY_IDS = "key_ids"; + // optional extras: + public static final String EXTRA_PASSPHRASE = "passphrase"; + + // GET_KEY + public static final String EXTRA_KEY_ID = "key_id"; + public static final String RESULT_KEY_IDS = "key_ids"; + + /* Service Intent returns */ + public static final String RESULT_CODE = "result_code"; + + // get actual error object from RESULT_ERROR + public static final int RESULT_CODE_ERROR = 0; + // success! + public static final int RESULT_CODE_SUCCESS = 1; + // get PendingIntent from RESULT_INTENT, start PendingIntent with startIntentSenderForResult, + // and execute service method again in onActivityResult + public static final int RESULT_CODE_USER_INTERACTION_REQUIRED = 2; + + public static final String RESULT_ERROR = "error"; + public static final String RESULT_INTENT = "intent"; + + // DECRYPT_VERIFY + public static final String RESULT_SIGNATURE = "signature"; + + IOpenPgpService mService; + Context mContext; + + public OpenPgpApi(Context context, IOpenPgpService service) { + this.mContext = context; + this.mService = service; + } + + public interface IOpenPgpCallback { + void onReturn(final Intent result); + } + + private class OpenPgpAsyncTask extends AsyncTask { + Intent data; + InputStream is; + OutputStream os; + IOpenPgpCallback callback; + + private OpenPgpAsyncTask(Intent data, InputStream is, OutputStream os, IOpenPgpCallback callback) { + this.data = data; + this.is = is; + this.os = os; + this.callback = callback; + } + + @Override + protected Intent doInBackground(Void... unused) { + return executeApi(data, is, os); + } + + protected void onPostExecute(Intent result) { + callback.onReturn(result); + } + + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public void executeApiAsync(Intent data, InputStream is, OutputStream os, IOpenPgpCallback callback) { + OpenPgpAsyncTask task = new OpenPgpAsyncTask(data, is, os, callback); + + // don't serialize async tasks! + // http://commonsware.com/blog/2012/04/20/asynctask-threading-regression-confirmed.html + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); + } else { + task.execute((Void[]) null); + } + } + + public Intent executeApi(Intent data, InputStream is, OutputStream os) { + try { + data.putExtra(EXTRA_API_VERSION, OpenPgpApi.API_VERSION); + + Intent result; + + // pipe the input and output + ParcelFileDescriptor input = null; + if (is != null) { + input = ParcelFileDescriptorUtil.pipeFrom(is, + new ParcelFileDescriptorUtil.IThreadListener() { + + @Override + public void onThreadFinished(Thread thread) { + //Log.d(OpenPgpApi.TAG, "Copy to service finished"); + } + } + ); + } + ParcelFileDescriptor output = null; + if (os != null) { + output = ParcelFileDescriptorUtil.pipeTo(os, + new ParcelFileDescriptorUtil.IThreadListener() { + + @Override + public void onThreadFinished(Thread thread) { + //Log.d(OpenPgpApi.TAG, "Service finished writing!"); + } + } + ); + } + + // blocks until result is ready + result = mService.execute(data, input, output); + // close() is required to halt the TransferThread + if (output != null) { + output.close(); + } + // TODO: close input? + + // set class loader to current context to allow unparcelling + // of OpenPgpError and OpenPgpSignatureResult + // http://stackoverflow.com/a/3806769 + result.setExtrasClassLoader(mContext.getClassLoader()); + + return result; + } catch (Exception e) { + Log.e(OpenPgpApi.TAG, "Exception in executeApi call", e); + Intent result = new Intent(); + result.putExtra(RESULT_CODE, RESULT_CODE_ERROR); + result.putExtra(RESULT_ERROR, + new OpenPgpError(OpenPgpError.CLIENT_SIDE_ERROR, e.getMessage())); + return result; + } + } + +} diff --git a/src/org/openintents/openpgp/util/OpenPgpListPreference.java b/src/org/openintents/openpgp/util/OpenPgpListPreference.java new file mode 100644 index 000000000..cf5864620 --- /dev/null +++ b/src/org/openintents/openpgp/util/OpenPgpListPreference.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp.util; + +import android.app.AlertDialog.Builder; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListAdapter; +import android.widget.TextView; +import org.openintents.openpgp.R; + +import java.util.ArrayList; +import java.util.List; + +/** + * Does not extend ListPreference, but is very similar to it! + * http://grepcode.com/file_/repository.grepcode.com/java/ext/com.google.android/android/4.4_r1/android/preference/ListPreference.java/?v=source + */ +public class OpenPgpListPreference extends DialogPreference { + private static final String OPENKEYCHAIN_PACKAGE = "org.sufficientlysecure.keychain"; + private static final String MARKET_INTENT_URI_BASE = "market://details?id=%s"; + private static final Intent MARKET_INTENT = new Intent(Intent.ACTION_VIEW, Uri.parse( + String.format(MARKET_INTENT_URI_BASE, OPENKEYCHAIN_PACKAGE))); + + private ArrayList mLegacyList = new ArrayList(); + private ArrayList mList = new ArrayList(); + + private String mSelectedPackage; + + public OpenPgpListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public OpenPgpListPreference(Context context) { + this(context, null); + } + + /** + * Public method to add new entries for legacy applications + * + * @param packageName + * @param simpleName + * @param icon + */ + public void addLegacyProvider(int position, String packageName, String simpleName, Drawable icon) { + mLegacyList.add(position, new OpenPgpProviderEntry(packageName, simpleName, icon)); + } + + @Override + protected void onPrepareDialogBuilder(Builder builder) { + mList.clear(); + + // add "none"-entry + mList.add(0, new OpenPgpProviderEntry("", + getContext().getString(R.string.openpgp_list_preference_none), + getContext().getResources().getDrawable(R.drawable.ic_action_cancel_launchersize))); + + // add all additional (legacy) providers + mList.addAll(mLegacyList); + + // search for OpenPGP providers... + ArrayList providerList = new ArrayList(); + Intent intent = new Intent(OpenPgpApi.SERVICE_INTENT); + List resInfo = getContext().getPackageManager().queryIntentServices(intent, 0); + if (!resInfo.isEmpty()) { + for (ResolveInfo resolveInfo : resInfo) { + if (resolveInfo.serviceInfo == null) + continue; + + String packageName = resolveInfo.serviceInfo.packageName; + String simpleName = String.valueOf(resolveInfo.serviceInfo.loadLabel(getContext() + .getPackageManager())); + Drawable icon = resolveInfo.serviceInfo.loadIcon(getContext().getPackageManager()); + + providerList.add(new OpenPgpProviderEntry(packageName, simpleName, icon)); + } + } + + if (providerList.isEmpty()) { + // add install links if provider list is empty + resInfo = getContext().getPackageManager().queryIntentActivities + (MARKET_INTENT, 0); + for (ResolveInfo resolveInfo : resInfo) { + Intent marketIntent = new Intent(MARKET_INTENT); + marketIntent.setPackage(resolveInfo.activityInfo.packageName); + Drawable icon = resolveInfo.activityInfo.loadIcon(getContext().getPackageManager()); + String marketName = String.valueOf(resolveInfo.activityInfo.applicationInfo + .loadLabel(getContext().getPackageManager())); + String simpleName = String.format(getContext().getString(R.string + .openpgp_install_openkeychain_via), marketName); + mList.add(new OpenPgpProviderEntry(OPENKEYCHAIN_PACKAGE, simpleName, + icon, marketIntent)); + } + } else { + // add provider + mList.addAll(providerList); + } + + // Init ArrayAdapter with OpenPGP Providers + ListAdapter adapter = new ArrayAdapter(getContext(), + android.R.layout.select_dialog_singlechoice, android.R.id.text1, mList) { + public View getView(int position, View convertView, ViewGroup parent) { + // User super class to create the View + View v = super.getView(position, convertView, parent); + TextView tv = (TextView) v.findViewById(android.R.id.text1); + + // Put the image on the TextView + tv.setCompoundDrawablesWithIntrinsicBounds(mList.get(position).icon, null, + null, null); + + // Add margin between image and text (support various screen densities) + int dp10 = (int) (10 * getContext().getResources().getDisplayMetrics().density + 0.5f); + tv.setCompoundDrawablePadding(dp10); + + return v; + } + }; + + builder.setSingleChoiceItems(adapter, getIndexOfProviderList(getValue()), + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + OpenPgpProviderEntry entry = mList.get(which); + + if (entry.intent != null) { + /* + * Intents are called as activity + * + * Current approach is to assume the user installed the app. + * If he does not, the selected package is not valid. + * + * However applications should always consider this could happen, + * as the user might remove the currently used OpenPGP app. + */ + getContext().startActivity(entry.intent); + } + + mSelectedPackage = entry.packageName; + + /* + * Clicking on an item simulates the positive button click, and dismisses + * the dialog. + */ + OpenPgpListPreference.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE); + dialog.dismiss(); + } + }); + + /* + * The typical interaction for list-based dialogs is to have click-on-an-item dismiss the + * dialog instead of the user having to press 'Ok'. + */ + builder.setPositiveButton(null, null); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (positiveResult && (mSelectedPackage != null)) { + if (callChangeListener(mSelectedPackage)) { + setValue(mSelectedPackage); + } + } + } + + private int getIndexOfProviderList(String packageName) { + for (OpenPgpProviderEntry app : mList) { + if (app.packageName.equals(packageName)) { + return mList.indexOf(app); + } + } + + return -1; + } + + public void setValue(String packageName) { + mSelectedPackage = packageName; + persistString(packageName); + } + + public String getValue() { + return mSelectedPackage; + } + + public String getEntry() { + return getEntryByValue(mSelectedPackage); + } + + @Override + protected Object onGetDefaultValue(TypedArray a, int index) { + return a.getString(index); + } + + @Override + protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { + setValue(restoreValue ? getPersistedString(mSelectedPackage) : (String) defaultValue); + } + + public String getEntryByValue(String packageName) { + for (OpenPgpProviderEntry app : mList) { + if (app.packageName.equals(packageName)) { + return app.simpleName; + } + } + + return null; + } + + private static class OpenPgpProviderEntry { + private String packageName; + private String simpleName; + private Drawable icon; + private Intent intent; + + public OpenPgpProviderEntry(String packageName, String simpleName, Drawable icon) { + this.packageName = packageName; + this.simpleName = simpleName; + this.icon = icon; + } + + public OpenPgpProviderEntry(String packageName, String simpleName, Drawable icon, Intent intent) { + this(packageName, simpleName, icon); + this.intent = intent; + } + + @Override + public String toString() { + return simpleName; + } + } +} diff --git a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java new file mode 100644 index 000000000..0395a7bc5 --- /dev/null +++ b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp.util; + +import org.openintents.openpgp.IOpenPgpService; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; + +public class OpenPgpServiceConnection { + + // interface to create callbacks for onServiceConnected + public interface OnBound { + public void onBound(IOpenPgpService service); + } + + private Context mApplicationContext; + + private IOpenPgpService mService; + private String mProviderPackageName; + + private OnBound mOnBoundListener; + + /** + * Create new OpenPgpServiceConnection + * + * @param context + * @param providerPackageName specify package name of OpenPGP provider, + * e.g., "org.sufficientlysecure.keychain" + */ + public OpenPgpServiceConnection(Context context, String providerPackageName) { + this.mApplicationContext = context.getApplicationContext(); + this.mProviderPackageName = providerPackageName; + } + + /** + * Create new OpenPgpServiceConnection + * + * @param context + * @param providerPackageName specify package name of OpenPGP provider, + * e.g., "org.sufficientlysecure.keychain" + * @param onBoundListener callback, executed when connection to service has been established + */ + public OpenPgpServiceConnection(Context context, String providerPackageName, + OnBound onBoundListener) { + this.mApplicationContext = context.getApplicationContext(); + this.mProviderPackageName = providerPackageName; + this.mOnBoundListener = onBoundListener; + } + + public IOpenPgpService getService() { + return mService; + } + + public boolean isBound() { + return (mService != null); + } + + private ServiceConnection mServiceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName name, IBinder service) { + mService = IOpenPgpService.Stub.asInterface(service); + if (mOnBoundListener != null) { + mOnBoundListener.onBound(mService); + } + } + + public void onServiceDisconnected(ComponentName name) { + mService = null; + } + }; + + /** + * If not already bound, bind to service! + * + * @return + */ + public boolean bindToService() { + // if not already bound... + if (mService == null) { + try { + Intent serviceIntent = new Intent(); + serviceIntent.setAction(IOpenPgpService.class.getName()); + // NOTE: setPackage is very important to restrict the intent to this provider only! + serviceIntent.setPackage(mProviderPackageName); + mApplicationContext.bindService(serviceIntent, mServiceConnection, + Context.BIND_AUTO_CREATE); + + return true; + } catch (Exception e) { + return false; + } + } else { + return true; + } + } + + public void unbindFromService() { + mApplicationContext.unbindService(mServiceConnection); + } + +} diff --git a/src/org/openintents/openpgp/util/OpenPgpUtils.java b/src/org/openintents/openpgp/util/OpenPgpUtils.java new file mode 100644 index 000000000..e24c937aa --- /dev/null +++ b/src/org/openintents/openpgp/util/OpenPgpUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp.util; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; + +public class OpenPgpUtils { + + public static final Pattern PGP_MESSAGE = Pattern.compile( + ".*?(-----BEGIN PGP MESSAGE-----.*?-----END PGP MESSAGE-----).*", + Pattern.DOTALL); + + public static final Pattern PGP_SIGNED_MESSAGE = Pattern.compile( + ".*?(-----BEGIN PGP SIGNED MESSAGE-----.*?-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----).*", + Pattern.DOTALL); + + public static final int PARSE_RESULT_NO_PGP = -1; + public static final int PARSE_RESULT_MESSAGE = 0; + public static final int PARSE_RESULT_SIGNED_MESSAGE = 1; + + public static int parseMessage(String message) { + Matcher matcherSigned = PGP_SIGNED_MESSAGE.matcher(message); + Matcher matcherMessage = PGP_MESSAGE.matcher(message); + + if (matcherMessage.matches()) { + return PARSE_RESULT_MESSAGE; + } else if (matcherSigned.matches()) { + return PARSE_RESULT_SIGNED_MESSAGE; + } else { + return PARSE_RESULT_NO_PGP; + } + } + + public static boolean isAvailable(Context context) { + Intent intent = new Intent(OpenPgpApi.SERVICE_INTENT); + List resInfo = context.getPackageManager().queryIntentServices(intent, 0); + if (!resInfo.isEmpty()) { + return true; + } else { + return false; + } + } + + public static String convertKeyIdToHex(long keyId) { + return "0x" + convertKeyIdToHex32bit(keyId >> 32) + convertKeyIdToHex32bit(keyId); + } + + private static String convertKeyIdToHex32bit(long keyId) { + String hexString = Long.toHexString(keyId & 0xffffffffL).toLowerCase(Locale.US); + while (hexString.length() < 8) { + hexString = "0" + hexString; + } + return hexString; + } +} diff --git a/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java b/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java new file mode 100644 index 000000000..58c62110d --- /dev/null +++ b/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * 2013 Flow (http://stackoverflow.com/questions/18212152/transfer-inputstream-to-another-service-across-process-boundaries-with-parcelf) + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp.util; + +import android.os.ParcelFileDescriptor; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class ParcelFileDescriptorUtil { + + public interface IThreadListener { + void onThreadFinished(final Thread thread); + } + + public static ParcelFileDescriptor pipeFrom(InputStream inputStream, IThreadListener listener) + throws IOException { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readSide = pipe[0]; + ParcelFileDescriptor writeSide = pipe[1]; + + // start the transfer thread + new TransferThread(inputStream, new ParcelFileDescriptor.AutoCloseOutputStream(writeSide), + listener) + .start(); + + return readSide; + } + + public static ParcelFileDescriptor pipeTo(OutputStream outputStream, IThreadListener listener) + throws IOException { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readSide = pipe[0]; + ParcelFileDescriptor writeSide = pipe[1]; + + // start the transfer thread + new TransferThread(new ParcelFileDescriptor.AutoCloseInputStream(readSide), outputStream, + listener) + .start(); + + return writeSide; + } + + static class TransferThread extends Thread { + final InputStream mIn; + final OutputStream mOut; + final IThreadListener mListener; + + TransferThread(InputStream in, OutputStream out, IThreadListener listener) { + super("ParcelFileDescriptor Transfer Thread"); + mIn = in; + mOut = out; + mListener = listener; + setDaemon(true); + } + + @Override + public void run() { + byte[] buf = new byte[1024]; + int len; + + try { + while ((len = mIn.read(buf)) > 0) { + mOut.write(buf, 0, len); + } + mOut.flush(); // just to be safe + } catch (IOException e) { + //Log.e(OpenPgpApi.TAG, "TransferThread" + getId() + ": writing failed", e); + } finally { + try { + mIn.close(); + } catch (IOException e) { + //Log.e(OpenPgpApi.TAG, "TransferThread" + getId(), e); + } + try { + mOut.close(); + } catch (IOException e) { + //Log.e(OpenPgpApi.TAG, "TransferThread" + getId(), e); + } + } + if (mListener != null) { + //Log.d(OpenPgpApi.TAG, "TransferThread " + getId() + " finished!"); + mListener.onThreadFinished(this); + } + } + } +} \ No newline at end of file From 650e1ebda82596cd4fbfaae406e6eccf189f4f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Wed, 7 May 2014 17:01:17 +0200 Subject: [PATCH 11/81] Add readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..da8361e88 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# OpenPGP Remote API library + +For usage instructions, please consult our Wiki page about the [OpenPGP Remote API](https://github.com/open-keychain/open-keychain/wiki/OpenPGP-API). \ No newline at end of file From 3236432c39f1d5a1bbbe362c5cfdb088756fd04f Mon Sep 17 00:00:00 2001 From: Florian Schmaus Date: Sun, 8 Jun 2014 13:18:46 +0200 Subject: [PATCH 12/81] Make minidns Android agnostic there is really no need for minidns to be Android exclusive. Replacing the Android log API with JUL make minidns available for Android and Java SE. --- AndroidManifest.xml | 13 -- README.md | 2 +- build.gradle | 152 ++++++++++++++---- build.xml | 92 ----------- proguard-project.txt | 0 project.properties | 2 - .../java}/de/measite/minidns/Client.java | 13 +- .../java}/de/measite/minidns/DNSMessage.java | 0 .../java}/de/measite/minidns/Question.java | 0 .../java}/de/measite/minidns/Record.java | 0 .../java}/de/measite/minidns/record/A.java | 0 .../java}/de/measite/minidns/record/AAAA.java | 0 .../de/measite/minidns/record/CNAME.java | 0 .../java}/de/measite/minidns/record/Data.java | 0 .../java}/de/measite/minidns/record/NS.java | 0 .../java}/de/measite/minidns/record/SRV.java | 0 .../de/measite/minidns/util/NameUtil.java | 0 17 files changed, 129 insertions(+), 145 deletions(-) delete mode 100644 AndroidManifest.xml delete mode 100644 build.xml delete mode 100644 proguard-project.txt delete mode 100644 project.properties rename src/{ => main/java}/de/measite/minidns/Client.java (95%) rename src/{ => main/java}/de/measite/minidns/DNSMessage.java (100%) rename src/{ => main/java}/de/measite/minidns/Question.java (100%) rename src/{ => main/java}/de/measite/minidns/Record.java (100%) rename src/{ => main/java}/de/measite/minidns/record/A.java (100%) rename src/{ => main/java}/de/measite/minidns/record/AAAA.java (100%) rename src/{ => main/java}/de/measite/minidns/record/CNAME.java (100%) rename src/{ => main/java}/de/measite/minidns/record/Data.java (100%) rename src/{ => main/java}/de/measite/minidns/record/NS.java (100%) rename src/{ => main/java}/de/measite/minidns/record/SRV.java (100%) rename src/{ => main/java}/de/measite/minidns/util/NameUtil.java (100%) diff --git a/AndroidManifest.xml b/AndroidManifest.xml deleted file mode 100644 index 6c9477d9c..000000000 --- a/AndroidManifest.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - diff --git a/README.md b/README.md index 99ba78b31..3c1417a18 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ MiniDNS ------- -MiniDNS is a minial dns client library for android. It can parse a basic set +MiniDNS is a minimal dns client library for android. It can parse a basic set of resource records (A, AAAA, NS, SRV) and is easy to use and extend. This library is not intended to be used as a DNS server. You might want to diff --git a/build.gradle b/build.gradle index ae9c7693a..7cf467180 100644 --- a/build.gradle +++ b/build.gradle @@ -1,34 +1,126 @@ -buildscript { - repositories { - mavenCentral() - } +apply plugin: 'java' +apply plugin: 'eclipse' +apply plugin: 'maven' +apply plugin: 'osgi' +apply plugin: 'signing' - dependencies { - classpath 'com.android.tools.build:gradle:0.9.0' - } +ext { + shortVersion = '0.1' + isSnapshot = true + gitCommit = getGitCommit() + isReleaseVersion = !shortVersion + sonatypeCredentialsAvailable = project.hasProperty('sonatypeUsername') && project.hasProperty('sonatypePassword') + signingRequired = isReleaseVersion + sonatypeSnapshotUrl = 'https://oss.sonatype.org/content/repositories/snapshots' + sonatypeStagingUrl = 'https://oss.sonatype.org/service/local/staging/deploy/maven2' + buildDate = (new java.text.SimpleDateFormat("yyyy-MM-dd")).format(new Date()) } -apply plugin: 'android-library' - -android { - compileSdkVersion 19 - buildToolsVersion '19.0.3' - - // NOTE: We are using the old folder structure to also support Eclipse - sourceSets { - main { - manifest.srcFile 'AndroidManifest.xml' - java.srcDirs = ['src'] - resources.srcDirs = ['src'] - aidl.srcDirs = ['src'] - renderscript.srcDirs = ['src'] - res.srcDirs = ['res'] - assets.srcDirs = ['assets'] - } - } - - // Do not abort build if lint finds errors - lintOptions { - abortOnError false - } +group = 'de.measite.minidns' +description = "A minimal DNS client library with support for A, AAAA, NS and SRV records" +sourceCompatibility = 1.7 +version = shortVersion +if (isSnapshot) { + version += '-SNAPSHOT' +} + +jar { + manifest { + instruction 'Implementation-GitRevision:', project.ext.gitCommit + } +} + +gradle.taskGraph.whenReady { taskGraph -> + if (signingRequired + && taskGraph.allTasks.any { it instanceof Sign }) { + // Use Java 6's console to read from the console (no good for a CI environment) + Console console = System.console() + console.printf '\n\nWe have to sign some things in this build.\n\nPlease enter your signing details.\n\n' + def password = console.readPassword('GnuPG Private Key Password: ') + + allprojects { ext.'signing.password' = password } + + console.printf '\nThanks.\n\n' + } +} + +uploadArchives { + repositories { + mavenDeployer { + if (signingRequired) { + beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } + } + repository(url: project.sonatypeStagingUrl) { + if (sonatypeCredentialsAvailable) { + authentication(userName: sonatypeUsername, password: sonatypePassword) + } + } + snapshotRepository(url: project.sonatypeSnapshotUrl) { + if (sonatypeCredentialsAvailable) { + authentication(userName: sonatypeUsername, password: sonatypePassword) + } + } + + pom.project { + name 'minidns' + packaging 'jar' + inceptionYear '2014' + url 'https://github.com/rtreffer/minidns' + description project.description + + issueManagement { + system 'GitHub' + url 'https://github.com/rtreffer/minidns/issues' + } + + distributionManagement { + snapshotRepository { + id 'minidns.snapshot' + url project.sonatypeSnapshotUrl + } + } + + scm { + url 'https://github.com/rtreffer/minidns' + connection 'scm:git:https://github.com/rtreffer/minidns.git' + developerConnection 'scm:git:https://github.com/rtreffer/minidns.git' + } + + licenses { + license { + name 'The Apache Software License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution 'repo' + } + } + + developers { + developer { + id 'rtreffer' + name 'Rene Treffer' + } + developer { + id 'flow' + name 'Florian Schmaus' + email 'flow@geekplace.eu' + } + } + } + } + } + signing { + required { signingRequired } + sign configurations.archives + } +} + +def getGitCommit() { + def dotGit = new File("$projectDir/.git") + if (!dotGit.isDirectory()) return 'non-git build' + + def cmd = 'git describe --all --dirty=+' + def proc = cmd.execute() + def gitCommit = proc.text.trim() + assert !gitCommit.isEmpty() + gitCommit } diff --git a/build.xml b/build.xml deleted file mode 100644 index e10480a05..000000000 --- a/build.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/proguard-project.txt b/proguard-project.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/project.properties b/project.properties deleted file mode 100644 index 9e86aa644..000000000 --- a/project.properties +++ /dev/null @@ -1,2 +0,0 @@ -target=android-19 -android.library=true diff --git a/src/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java similarity index 95% rename from src/de/measite/minidns/Client.java rename to src/main/java/de/measite/minidns/Client.java index 8bc75d015..fb42cd25a 100644 --- a/src/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -14,8 +14,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.Random; +import java.util.logging.Logger; -import android.util.Log; import de.measite.minidns.Record.CLASS; import de.measite.minidns.Record.TYPE; @@ -25,6 +25,8 @@ import de.measite.minidns.Record.TYPE; */ public class Client { + private static final Logger LOGGER = Logger.getLogger(Client.class.getName()); + /** * The internal random class for sequence generation. */ @@ -177,22 +179,19 @@ public class Client { public String[] findDNS() { String[] result = findDNSByReflection(); if (result != null) { - Log.d("minidns/client", - "Got DNS servers via reflection: " + Arrays.toString(result)); + LOGGER.fine("Got DNS servers via reflection: " + Arrays.toString(result)); return result; } result = findDNSByExec(); if (result != null) { - Log.d("minidns/client", - "Got DNS servers via exec: " + Arrays.toString(result)); + LOGGER.fine("Got DNS servers via exec: " + Arrays.toString(result)); return result; } // fallback for ipv4 and ipv6 connectivity // see https://developers.google.com/speed/public-dns/docs/using - Log.d("minidns/client", - "No DNS found? Using fallback [8.8.8.8, [2001:4860:4860::8888]]"); + LOGGER.fine("No DNS found? Using fallback [8.8.8.8, [2001:4860:4860::8888]]"); return new String[]{"8.8.8.8", "[2001:4860:4860::8888]"}; } diff --git a/src/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java similarity index 100% rename from src/de/measite/minidns/DNSMessage.java rename to src/main/java/de/measite/minidns/DNSMessage.java diff --git a/src/de/measite/minidns/Question.java b/src/main/java/de/measite/minidns/Question.java similarity index 100% rename from src/de/measite/minidns/Question.java rename to src/main/java/de/measite/minidns/Question.java diff --git a/src/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java similarity index 100% rename from src/de/measite/minidns/Record.java rename to src/main/java/de/measite/minidns/Record.java diff --git a/src/de/measite/minidns/record/A.java b/src/main/java/de/measite/minidns/record/A.java similarity index 100% rename from src/de/measite/minidns/record/A.java rename to src/main/java/de/measite/minidns/record/A.java diff --git a/src/de/measite/minidns/record/AAAA.java b/src/main/java/de/measite/minidns/record/AAAA.java similarity index 100% rename from src/de/measite/minidns/record/AAAA.java rename to src/main/java/de/measite/minidns/record/AAAA.java diff --git a/src/de/measite/minidns/record/CNAME.java b/src/main/java/de/measite/minidns/record/CNAME.java similarity index 100% rename from src/de/measite/minidns/record/CNAME.java rename to src/main/java/de/measite/minidns/record/CNAME.java diff --git a/src/de/measite/minidns/record/Data.java b/src/main/java/de/measite/minidns/record/Data.java similarity index 100% rename from src/de/measite/minidns/record/Data.java rename to src/main/java/de/measite/minidns/record/Data.java diff --git a/src/de/measite/minidns/record/NS.java b/src/main/java/de/measite/minidns/record/NS.java similarity index 100% rename from src/de/measite/minidns/record/NS.java rename to src/main/java/de/measite/minidns/record/NS.java diff --git a/src/de/measite/minidns/record/SRV.java b/src/main/java/de/measite/minidns/record/SRV.java similarity index 100% rename from src/de/measite/minidns/record/SRV.java rename to src/main/java/de/measite/minidns/record/SRV.java diff --git a/src/de/measite/minidns/util/NameUtil.java b/src/main/java/de/measite/minidns/util/NameUtil.java similarity index 100% rename from src/de/measite/minidns/util/NameUtil.java rename to src/main/java/de/measite/minidns/util/NameUtil.java From defe51910153ed6a9aaf6becae9e6484bed609bd Mon Sep 17 00:00:00 2001 From: Florian Schmaus Date: Sun, 8 Jun 2014 13:33:14 +0200 Subject: [PATCH 13/81] Close DatagramSocket using try-with-resources --- src/main/java/de/measite/minidns/Client.java | 23 ++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index fb42cd25a..e20c007e8 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -131,18 +131,19 @@ public class Client { message.setRecursionDesired(true); message.setId(random.nextInt()); byte[] buf = message.toArray(); - DatagramSocket socket = new DatagramSocket(); - DatagramPacket packet = new DatagramPacket( - buf, buf.length, InetAddress.getByName(host), port); - socket.setSoTimeout(timeout); - socket.send(packet); - packet = new DatagramPacket(new byte[bufferSize], bufferSize); - socket.receive(packet); - DNSMessage dnsMessage = DNSMessage.parse(packet.getData()); - if (dnsMessage.getId() != message.getId()) { - return null; + try (DatagramSocket socket = new DatagramSocket()) { + DatagramPacket packet = new DatagramPacket(buf, buf.length, + InetAddress.getByName(host), port); + socket.setSoTimeout(timeout); + socket.send(packet); + packet = new DatagramPacket(new byte[bufferSize], bufferSize); + socket.receive(packet); + DNSMessage dnsMessage = DNSMessage.parse(packet.getData()); + if (dnsMessage.getId() != message.getId()) { + return null; + } + return dnsMessage; } - return dnsMessage; } /** From 315648382ec2b1fd59c36e388e0fae5d23b3b9a6 Mon Sep 17 00:00:00 2001 From: Florian Schmaus Date: Sun, 8 Jun 2014 13:54:51 +0200 Subject: [PATCH 14/81] Remove printStackTrace() calls it's hard to assign those an app in the Android log. Better to use JUL. --- src/main/java/de/measite/minidns/Client.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index e20c007e8..b5712d241 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.Random; +import java.util.logging.Level; import java.util.logging.Logger; import de.measite.minidns.Record.CLASS; @@ -238,7 +239,7 @@ public class Client { return server.toArray(new String[server.size()]); } } catch (IOException e) { - e.printStackTrace(); + LOGGER.log(Level.WARNING, "Exception in findDNSByExec", e); } return null; } @@ -283,7 +284,7 @@ public class Client { } } catch (Exception e) { // we might trigger some problems this way - e.printStackTrace(); + LOGGER.log(Level.WARNING, "Exception in findDNSByReflection", e); } return null; } From 52807fe7c415e9977cd2d7191b7e1251c9216256 Mon Sep 17 00:00:00 2001 From: Florian Schmaus Date: Sun, 8 Jun 2014 13:57:50 +0200 Subject: [PATCH 15/81] Log IOException --- src/main/java/de/measite/minidns/Client.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index b5712d241..cad3902a6 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -169,6 +169,7 @@ public class Client { } } } catch (IOException ioe) { + LOGGER.log(Level.FINE, "IOException in query", ioe); } } return null; From a77887d32fae68171fcd0d2989bf537c0c11f0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 9 Jun 2014 21:54:19 +0200 Subject: [PATCH 16/81] Update buildTools to 19.1 --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 69b937c1a..096f358e9 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:0.10.0' + classpath 'com.android.tools.build:gradle:0.11.1' } } @@ -13,7 +13,7 @@ apply plugin: 'android-library' android { compileSdkVersion 19 - buildToolsVersion '19.0.3' + buildToolsVersion '19.1' // NOTE: We are using the old folder structure to also support Eclipse sourceSets { From 7dd8cfc6e6cd9bafb2cb3a6fdc9866de7d7495a6 Mon Sep 17 00:00:00 2001 From: Florian Schmaus Date: Thu, 12 Jun 2014 09:29:35 +0200 Subject: [PATCH 17/81] Add Cache to minidns --- build.gradle | 12 +++ src/main/java/de/measite/minidns/Client.java | 43 +++++++---- .../java/de/measite/minidns/DNSMessage.java | 3 +- .../java/de/measite/minidns/Question.java | 73 ++++++++++++------- 4 files changed, 88 insertions(+), 43 deletions(-) diff --git a/build.gradle b/build.gradle index 7cf467180..01eafc656 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,18 @@ if (isSnapshot) { version += '-SNAPSHOT' } +repositories { + mavenLocal() + mavenCentral() + maven { + url 'https://oss.sonatype.org/content/repositories/snapshots/' + } +} + +dependencies { + compile 'org.igniterealtime.jxmpp:jxmpp-util-cache:0.1.0-alpha1-SNAPSHOT' +} + jar { manifest { instruction 'Implementation-GitRevision:', project.ext.gitCommit diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index cad3902a6..26e3ba01f 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -17,6 +17,8 @@ import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; +import org.jxmpp.util.cache.ExpirationCache; + import de.measite.minidns.Record.CLASS; import de.measite.minidns.Record.TYPE; @@ -28,6 +30,9 @@ public class Client { private static final Logger LOGGER = Logger.getLogger(Client.class.getName()); + protected static final ExpirationCache cache = new ExpirationCache( + 10, 1000 * 60 * 60 * 24); + /** * The internal random class for sequence generation. */ @@ -67,10 +72,7 @@ public class Client { public DNSMessage query(String name, TYPE type, CLASS clazz, String host, int port) throws IOException { - Question q = new Question(); - q.setClazz(clazz); - q.setType(type); - q.setName(name); + Question q = new Question(name, type, clazz); return query(q, host, port); } @@ -86,10 +88,7 @@ public class Client { public DNSMessage query(String name, TYPE type, CLASS clazz, String host) throws IOException { - Question q = new Question(); - q.setClazz(clazz); - q.setType(type); - q.setName(name); + Question q = new Question(name, type, clazz); return query(q, host); } @@ -102,10 +101,7 @@ public class Client { */ public DNSMessage query(String name, TYPE type, CLASS clazz) { - Question q = new Question(); - q.setClazz(clazz); - q.setType(type); - q.setName(name); + Question q = new Question(name, type, clazz); return query(q); } @@ -127,6 +123,10 @@ public class Client { * @throws IOException On IOErrors. */ public DNSMessage query(Question q, String host, int port) throws IOException { + DNSMessage dnsMessage = cache.get(q); + if (dnsMessage != null) { + return dnsMessage; + } DNSMessage message = new DNSMessage(); message.setQuestions(new Question[]{q}); message.setRecursionDesired(true); @@ -139,10 +139,16 @@ public class Client { socket.send(packet); packet = new DatagramPacket(new byte[bufferSize], bufferSize); socket.receive(packet); - DNSMessage dnsMessage = DNSMessage.parse(packet.getData()); + dnsMessage = DNSMessage.parse(packet.getData()); if (dnsMessage.getId() != message.getId()) { return null; } + for (Record record : dnsMessage.getAnswers()) { + if (record.isAnswer(q)) { + cache.put(q, dnsMessage, record.ttl); + break; + } + } return dnsMessage; } } @@ -152,10 +158,19 @@ public class Client { * @param q The question section of the DNS query. */ public DNSMessage query(Question q) { + // While this query method does in fact re-use query(Question, String) + // we still do a cache lookup here in order to avoid unnecessary + // findDNS()calls, which are expensive on Android. Note that we do not + // put the results back into the Cache, as this is already done by + // query(Question, String). + DNSMessage message = cache.get(q); + if (message != null) { + return message; + } String dnsServer[] = findDNS(); for (String dns : dnsServer) { try { - DNSMessage message = query(q, dns); + message = query(q, dns); if (message == null) { continue; } diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index 14c8f04b8..ea1b25c82 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -416,8 +416,7 @@ public class DNSMessage { int additionalResourceRecordCount = dis.readUnsignedShort(); message.questions = new Question[questionCount]; while (questionCount-- > 0) { - Question q = new Question(); - q.parse(dis, data); + Question q = Question.parse(dis, data); message.questions[questionCount] = q; } message.answers = new Record[answerCount]; diff --git a/src/main/java/de/measite/minidns/Question.java b/src/main/java/de/measite/minidns/Question.java index 9d1e3f561..8efe19f11 100644 --- a/src/main/java/de/measite/minidns/Question.java +++ b/src/main/java/de/measite/minidns/Question.java @@ -11,52 +11,71 @@ import de.measite.minidns.util.NameUtil; public class Question { - private String name; + private final String name; - private TYPE type; + private final TYPE type; - private CLASS clazz = CLASS.IN; + private final CLASS clazz; + + private byte[] byteArray; + + public Question(String name, TYPE type, CLASS clazz) { + this.name = name; + this.type = type; + this.clazz = clazz; + } public TYPE getType() { return type; } - public void setType(TYPE type) { - this.type = type; - } - public CLASS getClazz() { return clazz; } - public void setClazz(CLASS clazz) { - this.clazz = clazz; - } - public String getName() { return name; } - public void setName(String name) { - this.name = name; + public static Question parse(DataInputStream dis, byte[] data) throws IOException { + String name = NameUtil.parse(dis, data); + TYPE type = TYPE.getType(dis.readUnsignedShort()); + CLASS clazz = CLASS.getClass(dis.readUnsignedShort()); + return new Question (name, type, clazz); } - public void parse(DataInputStream dis, byte[] data) throws IOException { - this.name = NameUtil.parse(dis, data); - this.type = TYPE.getType(dis.readUnsignedShort()); - this.clazz = CLASS.getClass(dis.readUnsignedShort()); + public byte[] toByteArray() { + if (byteArray == null) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(512); + DataOutputStream dos = new DataOutputStream(baos); + + try { + dos.write(NameUtil.toByteArray(this.name)); + dos.writeShort(type.getValue()); + dos.writeShort(clazz.getValue()); + dos.flush(); + } catch (IOException e) { + // Should never happen + throw new IllegalStateException(e); + } + byteArray = baos.toByteArray(); + } + return byteArray; } - public byte[] toByteArray() throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(512); - DataOutputStream dos = new DataOutputStream(baos); - - dos.write(NameUtil.toByteArray(this.name)); - dos.writeShort(type.getValue()); - dos.writeShort(clazz.getValue()); - - dos.flush(); - return baos.toByteArray(); + @Override + public int hashCode() { + return toByteArray().hashCode(); } + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Question)) { + return false; + } + return this.hashCode() == other.hashCode(); + } } From 4da60e7e2071f41cb35f9eacf048c30f5dbf6ca6 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Thu, 12 Jun 2014 22:39:51 +0200 Subject: [PATCH 18/81] Simplify cache and extend cache operations. Remove the external cache dependency and use a simple LRU based on LinkedHashMap. Make it possible to get the parse time of DNSMessage, which means we can evaluate the TTL later on :-) --- build.gradle | 7 -- src/main/java/de/measite/minidns/Client.java | 87 +++++++++++++++++-- .../java/de/measite/minidns/DNSMessage.java | 14 +++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/build.gradle b/build.gradle index 01eafc656..9de89b7ca 100644 --- a/build.gradle +++ b/build.gradle @@ -27,13 +27,6 @@ if (isSnapshot) { repositories { mavenLocal() mavenCentral() - maven { - url 'https://oss.sonatype.org/content/repositories/snapshots/' - } -} - -dependencies { - compile 'org.igniterealtime.jxmpp:jxmpp-util-cache:0.1.0-alpha1-SNAPSHOT' } jar { diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index 26e3ba01f..3f1523468 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -13,12 +13,12 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map.Entry; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; -import org.jxmpp.util.cache.ExpirationCache; - import de.measite.minidns.Record.CLASS; import de.measite.minidns.Record.TYPE; @@ -30,9 +30,6 @@ public class Client { private static final Logger LOGGER = Logger.getLogger(Client.class.getName()); - protected static final ExpirationCache cache = new ExpirationCache( - 10, 1000 * 60 * 60 * 24); - /** * The internal random class for sequence generation. */ @@ -48,6 +45,16 @@ public class Client { */ protected int timeout = 5000; + /** + * The internal DNS cache. + */ + protected LinkedHashMap cache; + + /** + * Maximum acceptable ttl. + */ + protected long maxTTL = 60 * 60 * 1000; + /** * Create a new DNS client. */ @@ -57,6 +64,7 @@ public class Client { } catch (NoSuchAlgorithmException e1) { random = new SecureRandom(); } + setCacheSize(10); } /** @@ -123,9 +131,20 @@ public class Client { * @throws IOException On IOErrors. */ public DNSMessage query(Question q, String host, int port) throws IOException { - DNSMessage dnsMessage = cache.get(q); - if (dnsMessage != null) { - return dnsMessage; + DNSMessage dnsMessage = (cache == null) ? null : cache.get(q); + if (dnsMessage != null && dnsMessage.getReceiveTimestamp() > 0l) { + // check the ttl + long ttl = maxTTL; + for (Record r : dnsMessage.getAnswers()) { + ttl = Math.min(ttl, r.ttl); + } + for (Record r : dnsMessage.getAdditionalResourceRecords()) { + ttl = Math.min(ttl, r.ttl); + } + if (dnsMessage.getReceiveTimestamp() + ttl < + System.currentTimeMillis()) { + return dnsMessage; + } } DNSMessage message = new DNSMessage(); message.setQuestions(new Question[]{q}); @@ -145,7 +164,9 @@ public class Client { } for (Record record : dnsMessage.getAnswers()) { if (record.isAnswer(q)) { - cache.put(q, dnsMessage, record.ttl); + if (cache != null) { + cache.put(q, dnsMessage); + } break; } } @@ -305,4 +326,52 @@ public class Client { return null; } + /** + * Configure the cache size (default 10). + * @param maximumSize The new cache size or 0 to disable. + */ + @SuppressWarnings("serial") + public void setCacheSize(final int maximumSize) { + if (maximumSize == 0) { + this.cache = null; + } else { + LinkedHashMap old = cache; + cache = new LinkedHashMap() { + @Override + protected boolean removeEldestEntry( + Entry eldest) { + return size() > maximumSize; + } + }; + if (old != null) { + cache.putAll(old); + } + } + } + + /** + * Flush the DNS cache. + */ + public void flushCache() { + if (cache != null) { + cache.clear(); + } + } + + /** + * Get the current maximum record ttl. + * @return The maximum record ttl. + */ + public long getMaxTTL() { + return maxTTL; + } + + /** + * Set the maximum record ttl. + * @param maxTTL The new maximum ttl. + */ + public void setMaxTTL(long maxTTL) { + this.maxTTL = maxTTL; + } + } diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index ea1b25c82..977c08d0e 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -195,6 +195,11 @@ public class DNSMessage { */ protected Record additionalResourceRecords[]; + /** + * The receive timestamp of this message. + */ + protected long receiveTimestamp; + /** * Retrieve the current DNS message id. * @return The current DNS message id. @@ -211,6 +216,14 @@ public class DNSMessage { this.id = id & 0xffff; } + /** + * Get the receive timestamp if this message was created via parse. + * This should be used to evaluate TTLs. + */ + public long getReceiveTimestamp() { + return receiveTimestamp; + } + /** * Retrieve the query type (true or false; * @return True if this DNS message is a query. @@ -410,6 +423,7 @@ public class DNSMessage { message.authenticData = ((header >> 5) & 1) == 1; message.checkDisabled = ((header >> 4) & 1) == 1; message.responseCode = RESPONSE_CODE.getResponseCode(header & 0xf); + message.receiveTimestamp = System.currentTimeMillis(); int questionCount = dis.readUnsignedShort(); int answerCount = dis.readUnsignedShort(); int nameserverCount = dis.readUnsignedShort(); From 8e848f7afdb1f4ac8ac5a56d20e7ea833c5c840c Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Fri, 13 Jun 2014 17:51:45 +0200 Subject: [PATCH 19/81] Switch to deticated DNSCache interface + reference LRU implementation. --- src/main/java/de/measite/minidns/Client.java | 86 ++--------- .../java/de/measite/minidns/DNSCache.java | 12 ++ .../java/de/measite/minidns/LRUCache.java | 139 ++++++++++++++++++ 3 files changed, 165 insertions(+), 72 deletions(-) create mode 100644 src/main/java/de/measite/minidns/DNSCache.java create mode 100644 src/main/java/de/measite/minidns/LRUCache.java diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index 3f1523468..462d52ff2 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -13,8 +13,6 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.Map.Entry; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; @@ -48,23 +46,26 @@ public class Client { /** * The internal DNS cache. */ - protected LinkedHashMap cache; + protected DNSCache cache; /** - * Maximum acceptable ttl. + * Create a new DNS client with the given DNS cache. + * @param cache The backend DNS cache. */ - protected long maxTTL = 60 * 60 * 1000; - - /** - * Create a new DNS client. - */ - public Client() { + public Client(DNSCache cache) { try { random = SecureRandom.getInstance("SHA1PRNG"); } catch (NoSuchAlgorithmException e1) { random = new SecureRandom(); } - setCacheSize(10); + this.cache = cache; + } + + /** + * Create a new DNS client. + */ + public Client() { + this(null); } /** @@ -132,19 +133,8 @@ public class Client { */ public DNSMessage query(Question q, String host, int port) throws IOException { DNSMessage dnsMessage = (cache == null) ? null : cache.get(q); - if (dnsMessage != null && dnsMessage.getReceiveTimestamp() > 0l) { - // check the ttl - long ttl = maxTTL; - for (Record r : dnsMessage.getAnswers()) { - ttl = Math.min(ttl, r.ttl); - } - for (Record r : dnsMessage.getAdditionalResourceRecords()) { - ttl = Math.min(ttl, r.ttl); - } - if (dnsMessage.getReceiveTimestamp() + ttl < - System.currentTimeMillis()) { - return dnsMessage; - } + if (dnsMessage != null) { + return dnsMessage; } DNSMessage message = new DNSMessage(); message.setQuestions(new Question[]{q}); @@ -326,52 +316,4 @@ public class Client { return null; } - /** - * Configure the cache size (default 10). - * @param maximumSize The new cache size or 0 to disable. - */ - @SuppressWarnings("serial") - public void setCacheSize(final int maximumSize) { - if (maximumSize == 0) { - this.cache = null; - } else { - LinkedHashMap old = cache; - cache = new LinkedHashMap() { - @Override - protected boolean removeEldestEntry( - Entry eldest) { - return size() > maximumSize; - } - }; - if (old != null) { - cache.putAll(old); - } - } - } - - /** - * Flush the DNS cache. - */ - public void flushCache() { - if (cache != null) { - cache.clear(); - } - } - - /** - * Get the current maximum record ttl. - * @return The maximum record ttl. - */ - public long getMaxTTL() { - return maxTTL; - } - - /** - * Set the maximum record ttl. - * @param maxTTL The new maximum ttl. - */ - public void setMaxTTL(long maxTTL) { - this.maxTTL = maxTTL; - } - } diff --git a/src/main/java/de/measite/minidns/DNSCache.java b/src/main/java/de/measite/minidns/DNSCache.java new file mode 100644 index 000000000..78e6c041f --- /dev/null +++ b/src/main/java/de/measite/minidns/DNSCache.java @@ -0,0 +1,12 @@ +package de.measite.minidns; + +/** + * Cache for DNS Entries. Implementations must be thread safe. + */ +public interface DNSCache { + + void put(Question q, DNSMessage message); + + DNSMessage get(Question q); + +} diff --git a/src/main/java/de/measite/minidns/LRUCache.java b/src/main/java/de/measite/minidns/LRUCache.java new file mode 100644 index 000000000..132bf7946 --- /dev/null +++ b/src/main/java/de/measite/minidns/LRUCache.java @@ -0,0 +1,139 @@ +package de.measite.minidns; + +import java.util.LinkedHashMap; +import java.util.Map.Entry; + +/** + * LRU based DNSCache backed by a LinkedHashMap. + */ +public class LRUCache implements DNSCache { + + /** + * Internal miss count. + */ + protected long missCount = 0l; + + /** + * Internal expire count (subset of misses that was caused by expire). + */ + protected long expireCount = 0l; + + /** + * Internal hit count. + */ + protected long hitCount = 0l; + + /** + * The internal capacity of the backend cache. + */ + protected int capacity; + + /** + * The upper bound of the ttl. All longer TTLs will be capped by this ttl. + */ + protected long maxTTL; + + /** + * The backend cache. + */ + protected LinkedHashMap backend; + + /** + * Create a new LRUCache with given capacity and upper bound ttl. + * @param capacity The internal capacity. + * @param maxTTL The upper bound for any ttl. + */ + @SuppressWarnings("serial") + public LRUCache(final int capacity, final long maxTTL) { + this.capacity = capacity; + this.maxTTL = maxTTL; + backend = new LinkedHashMap( + Math.min(capacity, 11), 0.75f, true) + { + @Override + protected boolean removeEldestEntry( + Entry eldest) { + return size() > capacity; + } + }; + } + + /** + * Create a new LRUCache with given capacity. + * @param capacity + */ + public LRUCache(final int capacity) { + this(capacity, Long.MAX_VALUE); + } + + @Override + public synchronized void put(Question q, DNSMessage message) { + if (message.getReceiveTimestamp() <= 0l) { + return; + } + backend.put(q, message); + } + + @Override + public synchronized DNSMessage get(Question q) { + DNSMessage message = backend.get(q); + if (message == null) { + missCount++; + return null; + } + + long ttl = maxTTL; + for (Record r : message.getAnswers()) { + ttl = Math.min(ttl, r.ttl); + } + for (Record r : message.getAdditionalResourceRecords()) { + ttl = Math.min(ttl, r.ttl); + } + if (message.getReceiveTimestamp() + ttl > System.currentTimeMillis()) { + missCount++; + expireCount++; + backend.remove(q); + return null; + } else { + hitCount++; + return message; + } + } + + /** + * Clear all entries in this cache. + */ + public synchronized void clear() { + backend.clear(); + missCount = 0l; + hitCount = 0l; + expireCount = 0l; + } + + /** + * Get the miss count of this cache which is the number of fruitless + * get calls since this cache was last resetted. + * @return The number of cache misses. + */ + public long getMissCount() { + return missCount; + } + + /** + * The number of expires (cache hits that have had a ttl to low to be + * retrieved). + * @return The expire count. + */ + public long getExpireCount() { + return expireCount; + } + + /** + * The cache hit count (all sucessful calls to get). + * @return The hit count. + */ + public long getHitCount() { + return hitCount; + } + +} From 028700efe0345e764f598378cb64c5e57f980e33 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Fri, 13 Jun 2014 17:54:54 +0200 Subject: [PATCH 20/81] Make sure we initialize big enough if s.o. chooses small values --- src/main/java/de/measite/minidns/LRUCache.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/measite/minidns/LRUCache.java b/src/main/java/de/measite/minidns/LRUCache.java index 132bf7946..0262e4c5f 100644 --- a/src/main/java/de/measite/minidns/LRUCache.java +++ b/src/main/java/de/measite/minidns/LRUCache.java @@ -48,7 +48,7 @@ public class LRUCache implements DNSCache { this.capacity = capacity; this.maxTTL = maxTTL; backend = new LinkedHashMap( - Math.min(capacity, 11), 0.75f, true) + Math.min(capacity + (capacity + 3) / 4 + 2, 11), 0.75f, true) { @Override protected boolean removeEldestEntry( From 289d48b633b7493f2a760583c88670b9fc4ef96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 22 Jun 2014 12:01:13 +0200 Subject: [PATCH 21/81] Add transifex config --- .tx/config | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .tx/config diff --git a/.tx/config b/.tx/config new file mode 100644 index 000000000..9e6de6164 --- /dev/null +++ b/.tx/config @@ -0,0 +1,8 @@ +[main] +host = https://www.transifex.com +lang_map = af_ZA: af-rZA, am_ET: am-rET, ar_AE: ar-rAE, ar_BH: ar-rBH, ar_DZ: ar-rDZ, ar_EG: ar-rEG, ar_IQ: ar-rIQ, ar_JO: ar-rJO, ar_KW: ar-rKW, ar_LB: ar-rLB, ar_LY: ar-rLY, ar_MA: ar-rMA, ar_OM: ar-rOM, ar_QA: ar-rQA, ar_SA: ar-rSA, ar_SY: ar-rSY, ar_TN: ar-rTN, ar_YE: ar-rYE, arn_CL: arn-rCL, as_IN: as-rIN, az_AZ: az-rAZ, ba_RU: ba-rRU, be_BY: be-rBY, bg_BG: bg-rBG, bn_BD: bn-rBD, bn_IN: bn-rIN, bo_CN: bo-rCN, br_FR: br-rFR, bs_BA: bs-rBA, ca_ES: ca-rES, co_FR: co-rFR, cs_CZ: cs-rCZ, cy_GB: cy-rGB, da_DK: da-rDK, de_AT: de-rAT, de_CH: de-rCH, de_DE: de-rDE, de_LI: de-rLI, de_LU: de-rLU, dsb_DE: dsb-rDE, dv_MV: dv-rMV, el_GR: el-rGR, en_AU: en-rAU, en_BZ: en-rBZ, en_CA: en-rCA, en_GB: en-rGB, en_IE: en-rIE, en_IN: en-rIN, en_JM: en-rJM, en_MY: en-rMY, en_NZ: en-rNZ, en_PH: en-rPH, en_SG: en-rSG, en_TT: en-rTT, en_US: en-rUS, en_ZA: en-rZA, en_ZW: en-rZW, es_AR: es-rAR, es_BO: es-rBO, es_CL: es-rCL, es_CO: es-rCO, es_CR: es-rCR, es_DO: es-rDO, es_EC: es-rEC, es_ES: es-rES, es_GT: es-rGT, es_HN: es-rHN, es_MX: es-rMX, es_NI: es-rNI, es_PA: es-rPA, es_PE: es-rPE, es_PR: es-rPR, es_PY: es-rPY, es_SV: es-rSV, es_US: es-rUS, es_UY: es-rUY, es_VE: es-rVE, et_EE: et-rEE, eu_ES: eu-rES, fa_IR: fa-rIR, fi_FI: fi-rFI, fil_PH: fil-rPH, fo_FO: fo-rFO, fr_BE: fr-rBE, fr_CA: fr-rCA, fr_CH: fr-rCH, fr_FR: fr-rFR, fr_LU: fr-rLU, fr_MC: fr-rMC, fy_NL: fy-rNL, ga_IE: ga-rIE, gd_GB: gd-rGB, gl_ES: gl-rES, gsw_FR: gsw-rFR, gu_IN: gu-rIN, ha_NG: ha-rNG, hi_IN: hi-rIN, hr_BA: hr-rBA, hr_HR: hr-rHR, hsb_DE: hsb-rDE, hu_HU: hu-rHU, hy_AM: hy-rAM, id_ID: id-rID, ig_NG: ig-rNG, ii_CN: ii-rCN, is_IS: is-rIS, it_CH: it-rCH, it_IT: it-rIT, iu_CA: iu-rCA, ja_JP: ja-rJP, ka_GE: ka-rGE, kk_KZ: kk-rKZ, kl_GL: kl-rGL, km_KH: km-rKH, kn_IN: kn-rIN, ko_KR: ko-rKR, kok_IN: kok-rIN, ky_KG: ky-rKG, lb_LU: lb-rLU, lo_LA: lo-rLA, lt_LT: lt-rLT, lv_LV: lv-rLV, mi_NZ: mi-rNZ, mk_MK: mk-rMK, ml_IN: ml-rIN, mn_CN: mn-rCN, mn_MN: mn-rMN, moh_CA: moh-rCA, mr_IN: mr-rIN, ms_BN: ms-rBN, ms_MY: ms-rMY, mt_MT: mt-rMT, nb_NO: nb-rNO, ne_NP: ne-rNP, nl_BE: nl-rBE, nl_NL: nl-rNL, nn_NO: nn-rNO, nso_ZA: nso-rZA, oc_FR: oc-rFR, or_IN: or-rIN, pa_IN: pa-rIN, pl_PL: pl-rPL, prs_AF: prs-rAF, ps_AF: ps-rAF, pt_BR: pt-rBR, pt_PT: pt-rPT, qut_GT: qut-rGT, quz_BO: quz-rBO, quz_EC: quz-rEC, quz_PE: quz-rPE, rm_CH: rm-rCH, ro_RO: ro-rRO, ru_RU: ru-rRU, rw_RW: rw-rRW, sa_IN: sa-rIN, sah_RU: sah-rRU, se_FI: se-rFI, se_NO: se-rNO, se_SE: se-rSE, si_LK: si-rLK, sk_SK: sk-rSK, sl_SI: sl-rSI, sma_NO: sma-rNO, sma_SE: sma-rSE, smj_NO: smj-rNO, smj_SE: smj-rSE, smn_FI: smn-rFI, sms_FI: sms-rFI, sq_AL: sq-rAL, sr_BA: sr-rBA, sr_CS: sr-rCS, sr_ME: sr-rME, sr_RS: sr-rRS, sv_FI: sv-rFI, sv_SE: sv-rSE, sw_KE: sw-rKE, syr_SY: syr-rSY, ta_IN: ta-rIN, te_IN: te-rIN, tg_TJ: tg-rTJ, th_TH: th-rTH, tk_TM: tk-rTM, tn_ZA: tn-rZA, tr_TR: tr-rTR, tt_RU: tt-rRU, tzm_DZ: tzm-rDZ, ug_CN: ug-rCN, uk_UA: uk-rUA, ur_PK: ur-rPK, uz_UZ: uz-rUZ, vi_VN: vi-rVN, wo_SN: wo-rSN, xh_ZA: xh-rZA, yo_NG: yo-rNG, zh_CN: zh-rCN, zh_HK: zh-rHK, zh_MO: zh-rMO, zh_SG: zh-rSG, zh_TW: zh-rTW, zu_ZA: zu-rZA, no_NO: no-rNO, he_IL: iw-rIL, he: iw + +[open-keychain.api-strings] +file_filter = res/values-/strings.xml +source_file = res/values/strings.xml +source_lang = en From 0e484dd17f85d923891460ff606b13294e8985ba Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Sun, 22 Jun 2014 15:29:51 +0200 Subject: [PATCH 22/81] Add missing javadoc and review new code --- .../java/de/measite/minidns/DNSCache.java | 11 ++++ .../java/de/measite/minidns/Question.java | 60 ++++++++++++++++++- src/main/java/de/measite/minidns/Record.java | 8 +++ .../java/de/measite/minidns/record/A.java | 6 ++ .../java/de/measite/minidns/record/AAAA.java | 6 ++ .../java/de/measite/minidns/record/CNAME.java | 6 +- .../java/de/measite/minidns/record/Data.java | 18 ++++++ .../java/de/measite/minidns/record/NS.java | 3 + .../java/de/measite/minidns/record/SRV.java | 54 ++++++++++++++++- .../de/measite/minidns/util/NameUtil.java | 40 ++++++++++++- 10 files changed, 205 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/measite/minidns/DNSCache.java b/src/main/java/de/measite/minidns/DNSCache.java index 78e6c041f..14a3a7769 100644 --- a/src/main/java/de/measite/minidns/DNSCache.java +++ b/src/main/java/de/measite/minidns/DNSCache.java @@ -5,8 +5,19 @@ package de.measite.minidns; */ public interface DNSCache { + /** + * Add an an dns answer/response for a given dns question. Implementations + * should honor the ttl / receive timestamp. + * @param q The question. + * @param message The dns message. + */ void put(Question q, DNSMessage message); + /** + * Request a cached dns response. + * @param q The dns question. + * @return The dns message. + */ DNSMessage get(Question q); } diff --git a/src/main/java/de/measite/minidns/Question.java b/src/main/java/de/measite/minidns/Question.java index 8efe19f11..628622510 100644 --- a/src/main/java/de/measite/minidns/Question.java +++ b/src/main/java/de/measite/minidns/Question.java @@ -4,39 +4,89 @@ import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.Arrays; import de.measite.minidns.Record.CLASS; import de.measite.minidns.Record.TYPE; import de.measite.minidns.util.NameUtil; +/** + * A DNS question (request). + */ public class Question { + /** + * The question string (e.g. "measite.de"). + */ private final String name; + /** + * The question type (e.g. A). + */ private final TYPE type; + /** + * The question class (usually IN / internet). + */ private final CLASS clazz; + /** + * Cache for the serialized object. + */ private byte[] byteArray; + /** + * Create a dns question for the given name/type/class. + * @param name The name e.g. "measite.de". + * @param type The type, e.g. A. + * @param clazz The class, usually IN (internet). + */ public Question(String name, TYPE type, CLASS clazz) { this.name = name; this.type = type; this.clazz = clazz; } + /** + * Create a dns question for the given name/type/IN (internet class). + * @param name The name e.g. "measite.de". + * @param type The type, e.g. A. + */ + public Question(String name, TYPE type) { + this(name, type, CLASS.IN); + } + + /** + * Retrieve the type of this question. + * @return The type. + */ public TYPE getType() { return type; } + /** + * Retrieve the class of this dns question (usually internet). + * @return The class of this dns question. + */ public CLASS getClazz() { return clazz; } + /** + * Retrieve the name of this dns question (e.g. "measite.de"). + * @return The name of this dns question. + */ public String getName() { return name; } + /** + * Parse a byte array and rebuild the dns question from it. + * @param dis The input stream. + * @param data The plain data (for dns name references). + * @return The parsed dns question. + * @throws IOException On errors (read outside of packet). + */ public static Question parse(DataInputStream dis, byte[] data) throws IOException { String name = NameUtil.parse(dis, data); TYPE type = TYPE.getType(dis.readUnsignedShort()); @@ -44,6 +94,10 @@ public class Question { return new Question (name, type, clazz); } + /** + * Generate a binary paket for this dns question. + * @return The dns question. + */ public byte[] toByteArray() { if (byteArray == null) { ByteArrayOutputStream baos = new ByteArrayOutputStream(512); @@ -65,7 +119,7 @@ public class Question { @Override public int hashCode() { - return toByteArray().hashCode(); + return Arrays.hashCode(toByteArray()); } @Override @@ -76,6 +130,8 @@ public class Question { if (!(other instanceof Question)) { return false; } - return this.hashCode() == other.hashCode(); + byte t[] = toByteArray(); + byte o[] = ((Question)other).toByteArray(); + return Arrays.equals(t, o); } } diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index fb0b5d5f6..36d3db1a3 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -286,10 +286,18 @@ public class Record { (q.getName().equals(name)); } + /** + * The generic record name, e.g. "measite.de". + * @return The record name. + */ public String getName() { return name; } + /** + * The payload data, usually a subclass of data (A, AAAA, CNAME, ...). + * @return The payload data. + */ public Data getPayload() { return payloadData; } diff --git a/src/main/java/de/measite/minidns/record/A.java b/src/main/java/de/measite/minidns/record/A.java index a85a7af0d..4311c651e 100644 --- a/src/main/java/de/measite/minidns/record/A.java +++ b/src/main/java/de/measite/minidns/record/A.java @@ -5,8 +5,14 @@ import java.io.IOException; import de.measite.minidns.Record.TYPE; +/** + * A record payload (ip pointer). + */ public class A implements Data { + /** + * Target IP. + */ private byte[] ip; @Override diff --git a/src/main/java/de/measite/minidns/record/AAAA.java b/src/main/java/de/measite/minidns/record/AAAA.java index d89147b2c..e4fd5ecf8 100644 --- a/src/main/java/de/measite/minidns/record/AAAA.java +++ b/src/main/java/de/measite/minidns/record/AAAA.java @@ -5,8 +5,14 @@ import java.io.IOException; import de.measite.minidns.Record.TYPE; +/** + * AAAA payload (an ipv6 pointer). + */ public class AAAA implements Data { + /** + * The ipv6 address. + */ private byte[] ip; @Override diff --git a/src/main/java/de/measite/minidns/record/CNAME.java b/src/main/java/de/measite/minidns/record/CNAME.java index 4657b4a51..1ac278141 100644 --- a/src/main/java/de/measite/minidns/record/CNAME.java +++ b/src/main/java/de/measite/minidns/record/CNAME.java @@ -6,6 +6,9 @@ import java.io.IOException; import de.measite.minidns.Record.TYPE; import de.measite.minidns.util.NameUtil; +/** + * CNAME payload (pointer to another domain / address). + */ public class CNAME implements Data { protected String name; @@ -20,8 +23,7 @@ public class CNAME implements Data { @Override public byte[] toByteArray() { - // TODO Auto-generated method stub - return null; + throw new UnsupportedOperationException("Not implemented yet"); } @Override diff --git a/src/main/java/de/measite/minidns/record/Data.java b/src/main/java/de/measite/minidns/record/Data.java index 9cb803742..7f2db03a1 100644 --- a/src/main/java/de/measite/minidns/record/Data.java +++ b/src/main/java/de/measite/minidns/record/Data.java @@ -5,12 +5,30 @@ import java.io.IOException; import de.measite.minidns.Record.TYPE; +/** + * Generic payload class. + */ public interface Data { + /** + * The payload type. + * @return The payload type. + */ TYPE getType(); + /** + * Binary representation of this payload. + * @return The binary representation of this payload. + */ byte[] toByteArray(); + /** + * Parse this payload. + * @param dis The input stream. + * @param data The plain data (needed for name cross references). + * @param length The payload length. + * @throws IOException on io error (read past paket boundary). + */ void parse(DataInputStream dis, byte data[], int length) throws IOException; } diff --git a/src/main/java/de/measite/minidns/record/NS.java b/src/main/java/de/measite/minidns/record/NS.java index bf07e8c41..8ac2d4c34 100644 --- a/src/main/java/de/measite/minidns/record/NS.java +++ b/src/main/java/de/measite/minidns/record/NS.java @@ -2,6 +2,9 @@ package de.measite.minidns.record; import de.measite.minidns.Record.TYPE; +/** + * Nameserver record. + */ public class NS extends CNAME { @Override diff --git a/src/main/java/de/measite/minidns/record/SRV.java b/src/main/java/de/measite/minidns/record/SRV.java index 32b70c4d3..707bf3f58 100644 --- a/src/main/java/de/measite/minidns/record/SRV.java +++ b/src/main/java/de/measite/minidns/record/SRV.java @@ -6,49 +6,99 @@ import java.io.IOException; import de.measite.minidns.Record.TYPE; import de.measite.minidns.util.NameUtil; +/** + * SRV record payload (service pointer). + */ public class SRV implements Data { + /** + * The priority of this service. + */ protected int priority; + + /** + * The weight of this service. + */ protected int weight; + + /** + * The target port. + */ protected int port; + + /** + * The target server. + */ protected String name; + /** + * The priority of this service. Lower values mean higher priority. + * @return The priority. + */ public int getPriority() { return priority; } + /** + * Set the priority of this service entry. Lower values have higher priority. + * @param priority The new priority. + */ public void setPriority(int priority) { this.priority = priority; } + /** + * The weight of this service. Services with the same priority should be + * balanced based on weight. + * @return The weight of this service. + */ public int getWeight() { return weight; } + /** + * Set the weight of this service. + * @param weight The new weight of this service. + */ public void setWeight(int weight) { this.weight = weight; } + /** + * The target port of this service. + * @return The target port of this service. + */ public int getPort() { return port; } + /** + * Set the target port of this service. + * @param port The new target port. + */ public void setPort(int port) { this.port = port; } + /** + * The name of the target server. + * @return The target servers name. + */ public String getName() { return name; } + /** + * Set the name of the target server. + * @param name The new target servers name. + */ public void setName(String name) { this.name = name; } @Override public byte[] toByteArray() { - // TODO Auto-generated method stub - return null; + throw new UnsupportedOperationException("Not implemented yet"); } @Override diff --git a/src/main/java/de/measite/minidns/util/NameUtil.java b/src/main/java/de/measite/minidns/util/NameUtil.java index 91a6649d8..7ae373bcd 100644 --- a/src/main/java/de/measite/minidns/util/NameUtil.java +++ b/src/main/java/de/measite/minidns/util/NameUtil.java @@ -8,12 +8,28 @@ import java.net.IDN; import java.util.HashSet; import java.util.Arrays; +/** + * Utilities related to internationalized domain names and dns name handling. + */ public class NameUtil { + /** + * Retrieve the rough binary length of a string + * (length + 2 bytes length prefix). + * @param name The name string. + * @return The binary size of the string (length + 2). + */ public static int size(String name) { return name.length() + 2; } + /** + * Check if two internationalized domain names are equal, possibly causing + * a serialization of both domain names. + * @param name1 The first domain name. + * @param name2 The second domain name. + * @return True if both domain names are the same. + */ public static boolean idnEquals(String name1, String name2) { if (name1 == name2) return true; // catches null, null if (name1 == null) return false; @@ -27,6 +43,12 @@ public class NameUtil { } } + /** + * Serialize a domain name under IDN rules. + * @param name The domain name. + * @return The binary domain name representation. + * @throws IOException Should never happen. + */ public static byte[] toByteArray(String name) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(64); DataOutputStream dos = new DataOutputStream(baos); @@ -38,8 +60,16 @@ public class NameUtil { dos.writeByte(0); dos.flush(); return baos.toByteArray(); - } + } + /** + * Parse a domain name starting at the current offset and moving the input + * stream pointer past this domain name (even if cross references occure). + * @param dis The input stream. + * @param data The raw data (for cross references). + * @return The domain name string. + * @throws IOException Should never happen. + */ public static String parse(DataInputStream dis, byte data[]) throws IOException { @@ -63,6 +93,14 @@ public class NameUtil { return s; } + /** + * Parse a domain name starting at the given offset. + * @param data The raw data. + * @param offset The offset. + * @param jumps The list of jumps (by now). + * @return The parsed domain name. + * @throws IllegalStateException on cycles. + */ public static String parse( byte data[], int offset, From dcf62a8ac59d84072e66e71ec8a5d137784e760d Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Sun, 22 Jun 2014 16:36:19 +0200 Subject: [PATCH 23/81] Prepare 0.1.0 snapshot --- build.gradle | 162 ++++++------------ .../java/de/measite/minidns/DNSMessage.java | 4 +- src/main/java/de/measite/minidns/Record.java | 2 +- 3 files changed, 56 insertions(+), 112 deletions(-) diff --git a/build.gradle b/build.gradle index 9de89b7ca..3345e5001 100644 --- a/build.gradle +++ b/build.gradle @@ -1,27 +1,28 @@ apply plugin: 'java' apply plugin: 'eclipse' -apply plugin: 'maven' apply plugin: 'osgi' -apply plugin: 'signing' +apply plugin: 'nexus' -ext { - shortVersion = '0.1' - isSnapshot = true - gitCommit = getGitCommit() - isReleaseVersion = !shortVersion - sonatypeCredentialsAvailable = project.hasProperty('sonatypeUsername') && project.hasProperty('sonatypePassword') - signingRequired = isReleaseVersion - sonatypeSnapshotUrl = 'https://oss.sonatype.org/content/repositories/snapshots' - sonatypeStagingUrl = 'https://oss.sonatype.org/service/local/staging/deploy/maven2' - buildDate = (new java.text.SimpleDateFormat("yyyy-MM-dd")).format(new Date()) +buildscript { + repositories { + jcenter() + mavenLocal() + mavenCentral() + } + + dependencies { + classpath 'org.gradle.api.plugins:gradle-nexus-plugin:0.7.1' + } } group = 'de.measite.minidns' description = "A minimal DNS client library with support for A, AAAA, NS and SRV records" sourceCompatibility = 1.7 -version = shortVersion -if (isSnapshot) { - version += '-SNAPSHOT' +version = 'git tag --points-at HEAD'.execute().text.trim() +isSNAPSHOT = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim() == 'master' + +if (isSNAPSHOT) { + version = version + '-SNAPSHOT' } repositories { @@ -29,103 +30,46 @@ repositories { mavenCentral() } -jar { - manifest { - instruction 'Implementation-GitRevision:', project.ext.gitCommit - } +nexus { + attachSources = false + attachTests = false + attachJavadoc = false + sign = true } -gradle.taskGraph.whenReady { taskGraph -> - if (signingRequired - && taskGraph.allTasks.any { it instanceof Sign }) { - // Use Java 6's console to read from the console (no good for a CI environment) - Console console = System.console() - console.printf '\n\nWe have to sign some things in this build.\n\nPlease enter your signing details.\n\n' - def password = console.readPassword('GnuPG Private Key Password: ') +modifyPom { + project { + name 'minidns' + description 'Minimal DNS library for java and android systems' + url 'https://github.com/rtreffer/minidns' + inceptionYear '2014' - allprojects { ext.'signing.password' = password } + scm { + url 'https://github.com/rtreffer/minidns' + connection 'scm:https://github.com/rtreffer/minidns' + developerConnection 'scm:git://github.com/rtreffer/minidns.git' + } - console.printf '\nThanks.\n\n' - } + licenses { + license { + name 'The Apache Software License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution 'repo' + } + } + + developers { + developer { + id 'rtreffer' + name 'Rene Treffer' + email 'treffer@measite.de' + } + developer { + id 'flow' + name 'Florian Schmaus' + email 'flow@geekplace.eu' + } + } + } } -uploadArchives { - repositories { - mavenDeployer { - if (signingRequired) { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - } - repository(url: project.sonatypeStagingUrl) { - if (sonatypeCredentialsAvailable) { - authentication(userName: sonatypeUsername, password: sonatypePassword) - } - } - snapshotRepository(url: project.sonatypeSnapshotUrl) { - if (sonatypeCredentialsAvailable) { - authentication(userName: sonatypeUsername, password: sonatypePassword) - } - } - - pom.project { - name 'minidns' - packaging 'jar' - inceptionYear '2014' - url 'https://github.com/rtreffer/minidns' - description project.description - - issueManagement { - system 'GitHub' - url 'https://github.com/rtreffer/minidns/issues' - } - - distributionManagement { - snapshotRepository { - id 'minidns.snapshot' - url project.sonatypeSnapshotUrl - } - } - - scm { - url 'https://github.com/rtreffer/minidns' - connection 'scm:git:https://github.com/rtreffer/minidns.git' - developerConnection 'scm:git:https://github.com/rtreffer/minidns.git' - } - - licenses { - license { - name 'The Apache Software License, Version 2.0' - url 'http://www.apache.org/licenses/LICENSE-2.0.txt' - distribution 'repo' - } - } - - developers { - developer { - id 'rtreffer' - name 'Rene Treffer' - } - developer { - id 'flow' - name 'Florian Schmaus' - email 'flow@geekplace.eu' - } - } - } - } - } - signing { - required { signingRequired } - sign configurations.archives - } -} - -def getGitCommit() { - def dotGit = new File("$projectDir/.git") - if (!dotGit.isDirectory()) return 'non-git build' - - def cmd = 'git describe --all --dirty=+' - def proc = cmd.execute() - def gitCommit = proc.text.trim() - assert !gitCommit.isEmpty() - gitCommit -} diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index 977c08d0e..6e0ae6c85 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -11,8 +11,8 @@ import java.util.Arrays; * A DNS message as defined by rfc1035. The message consists of a header and * 4 sections: question, answer, nameserver and addition resource record * section. - * A message can either be parsed ({@see #parse(byte[])}) or serialized - * ({@see #toArray()}). + * A message can either be parsed ({@link DNSMessage#parse(byte[])}) or serialized + * ({@link DNSMessage#toArray()}). */ public class DNSMessage { diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index 36d3db1a3..58740b7df 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -19,7 +19,7 @@ public class Record { /** * The record type. - * {@see http://www.iana.org/assignments/dns-parameters} + * @see IANA DNS Parameters */ public static enum TYPE { A(1), From c57b0d3ef4939c60ec670dd4ff39c4c247334ce5 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Mon, 23 Jun 2014 20:56:38 +0200 Subject: [PATCH 24/81] Fix missing source/javadoc archives --- .gitignore | 1 + build.gradle | 4 ++-- gradle.properties.example | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 gradle.properties.example diff --git a/.gitignore b/.gitignore index 8bd3bab3c..2362cd055 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ build/ # Local configuration file (sdk path, etc) local.properties +gradle.properties # Proguard folder generated by Eclipse proguard/ diff --git a/build.gradle b/build.gradle index 3345e5001..bed210d01 100644 --- a/build.gradle +++ b/build.gradle @@ -31,9 +31,9 @@ repositories { } nexus { - attachSources = false + attachSources = true attachTests = false - attachJavadoc = false + attachJavadoc = true sign = true } diff --git a/gradle.properties.example b/gradle.properties.example new file mode 100644 index 000000000..609aedaa6 --- /dev/null +++ b/gradle.properties.example @@ -0,0 +1,19 @@ +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# +# GPG settings +# + +# gpg key id +#signing.keyId=DEADBEEF +# gpg keyring (this is the default gnupg keyring containing private keys) +#signing.secretKeyRingFile=/home/ubuntu/.gnupg/secring.gpg + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# +# nexus settings +# + +# the nexus username used for log in +#nexusUsername=ubuntu +# the nexus password +#nexusPassword=correcthorsebatterystaple From f9f5069e52c1964e0f2b838a8dc79f246d164a29 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Mon, 23 Jun 2014 21:02:25 +0200 Subject: [PATCH 25/81] Add missing javadoc / missing getter (getTTL) --- src/main/java/de/measite/minidns/Client.java | 8 ++++++-- src/main/java/de/measite/minidns/DNSMessage.java | 5 +++-- src/main/java/de/measite/minidns/LRUCache.java | 2 +- src/main/java/de/measite/minidns/Record.java | 8 ++++++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/de/measite/minidns/Client.java b/src/main/java/de/measite/minidns/Client.java index 462d52ff2..827aa7725 100644 --- a/src/main/java/de/measite/minidns/Client.java +++ b/src/main/java/de/measite/minidns/Client.java @@ -75,7 +75,7 @@ public class Client { * @param clazz The class of the request (usually IN for Internet). * @param host The DNS server host. * @param port The DNS server port. - * @return + * @return The response (or null on timeout / failure). * @throws IOException On IO Errors. */ public DNSMessage query(String name, TYPE type, CLASS clazz, String host, int port) @@ -91,7 +91,7 @@ public class Client { * @param type The DNS type to request (SRV, A, AAAA, ...). * @param clazz The class of the request (usually IN for Internet). * @param host The DNS server host. - * @return + * @return The response (or null on timeout / failure). * @throws IOException On IO Errors. */ public DNSMessage query(String name, TYPE type, CLASS clazz, String host) @@ -106,6 +106,7 @@ public class Client { * @param name The DNS name to request. * @param type The DNS type to request (SRV, A, AAAA, ...). * @param clazz The class of the request (usually IN for Internet). + * @return The response (or null on timeout/error). * @return The DNSMessage reply or null. */ public DNSMessage query(String name, TYPE type, CLASS clazz) @@ -118,6 +119,7 @@ public class Client { * Query a specific server for one entry. * @param q The question section of the DNS query. * @param host The dns server host. + * @return The response (or null on timeout/error). * @throws IOException On IOErrors. */ public DNSMessage query(Question q, String host) throws IOException { @@ -129,6 +131,7 @@ public class Client { * @param q The question section of the DNS query. * @param host The dns server host. * @param port the dns port. + * @return The response (or null on timeout/error). * @throws IOException On IOErrors. */ public DNSMessage query(Question q, String host, int port) throws IOException { @@ -167,6 +170,7 @@ public class Client { /** * Query the system DNS server for one entry. * @param q The question section of the DNS query. + * @return The response (or null on timeout/error). */ public DNSMessage query(Question q) { // While this query method does in fact re-use query(Question, String) diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index 6e0ae6c85..fbee3b19c 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -48,7 +48,7 @@ public class DNSMessage { /** * Retrieve the byte value of the response code. - * @return + * @return the response code. */ public byte getValue() { return (byte) value; @@ -219,6 +219,7 @@ public class DNSMessage { /** * Get the receive timestamp if this message was created via parse. * This should be used to evaluate TTLs. + * @return The receive timestamp in milliseconds. */ public long getReceiveTimestamp() { return receiveTimestamp; @@ -331,7 +332,7 @@ public class DNSMessage { /** * Change the check status of this packet. - * @param checkDisabled + * @param checkDisabled The new check disabled value. */ public void setCheckDisabled(boolean checkDisabled) { this.checkDisabled = checkDisabled; diff --git a/src/main/java/de/measite/minidns/LRUCache.java b/src/main/java/de/measite/minidns/LRUCache.java index 0262e4c5f..6b9bbdc1f 100644 --- a/src/main/java/de/measite/minidns/LRUCache.java +++ b/src/main/java/de/measite/minidns/LRUCache.java @@ -60,7 +60,7 @@ public class LRUCache implements DNSCache { /** * Create a new LRUCache with given capacity. - * @param capacity + * @param capacity The capacity of this cache. */ public LRUCache(final int capacity) { this(capacity, Long.MAX_VALUE); diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index 58740b7df..8f8d0d0e4 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -302,4 +302,12 @@ public class Record { return payloadData; } + /** + * Retrieve the record ttl. + * @return The record ttl. + */ + public long getTtl() { + return ttl; + } + } From bc621bfcfbede74251a29c7ef7513498baa4062d Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Mon, 23 Jun 2014 21:20:39 +0200 Subject: [PATCH 26/81] Add missing property --- gradle.properties.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gradle.properties.example b/gradle.properties.example index 609aedaa6..68ffc418c 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -5,6 +5,8 @@ # gpg key id #signing.keyId=DEADBEEF +# the gpg key passphrase +#signing.password=correcthorsebatterystaple # gpg keyring (this is the default gnupg keyring containing private keys) #signing.secretKeyRingFile=/home/ubuntu/.gnupg/secring.gpg From aa9ecf871ce4db462034662675b258f9839d3f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sat, 28 Jun 2014 20:16:10 +0200 Subject: [PATCH 27/81] Update android gradle plugin to 0.12.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 096f358e9..184a4b94c 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:0.11.1' + classpath 'com.android.tools.build:gradle:0.12.0' } } From cd1a6748dd7a4c7a251a4f75eb289955dfbb1058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Thu, 17 Jul 2014 20:29:59 +0200 Subject: [PATCH 28/81] Extend for NFC --- src/org/openintents/openpgp/util/OpenPgpApi.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index f6a78d0ef..c88f9ceaf 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -141,6 +141,9 @@ public class OpenPgpApi { // optional extras: public static final String EXTRA_PASSPHRASE = "passphrase"; + // internal + public static final String EXTRA_NFC_DATA = "nfc_data"; + // GET_KEY public static final String EXTRA_KEY_ID = "key_id"; public static final String RESULT_KEY_IDS = "key_ids"; From 869ab96e6dcd4821fd5360248429e49dae6fbaca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 22 Jul 2014 18:10:06 +0200 Subject: [PATCH 29/81] Add values for NFC support --- src/org/openintents/openpgp/util/OpenPgpApi.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index c88f9ceaf..029584423 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -141,8 +141,9 @@ public class OpenPgpApi { // optional extras: public static final String EXTRA_PASSPHRASE = "passphrase"; - // internal - public static final String EXTRA_NFC_DATA = "nfc_data"; + // internal NFC states + public static final String EXTRA_NFC_SIGNED_HASH = "nfc_signed_hash"; + public static final String EXTRA_NFC_SIG_CREATION_TIMESTAMP = "nfc_sig_creation_timestamp"; // GET_KEY public static final String EXTRA_KEY_ID = "key_id"; From 6eda21b4162d8a57c26da5b38595581490fbe2d2 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Tue, 22 Jul 2014 23:22:35 +0200 Subject: [PATCH 30/81] Add PTR record parsing --- .../java/de/measite/minidns/DNSMessage.java | 2 +- src/main/java/de/measite/minidns/Record.java | 4 ++++ .../java/de/measite/minidns/record/PTR.java | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/measite/minidns/record/PTR.java diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index fbee3b19c..01c096450 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -424,7 +424,7 @@ public class DNSMessage { message.authenticData = ((header >> 5) & 1) == 1; message.checkDisabled = ((header >> 4) & 1) == 1; message.responseCode = RESPONSE_CODE.getResponseCode(header & 0xf); - message.receiveTimestamp = System.currentTimeMillis(); + message.receiveTimestamp = System.currentTimeMillis(); int questionCount = dis.readUnsignedShort(); int answerCount = dis.readUnsignedShort(); int nameserverCount = dis.readUnsignedShort(); diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index 8f8d0d0e4..f14aa0b8f 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -9,6 +9,7 @@ import de.measite.minidns.record.AAAA; import de.measite.minidns.record.CNAME; import de.measite.minidns.record.Data; import de.measite.minidns.record.NS; +import de.measite.minidns.record.PTR; import de.measite.minidns.record.SRV; import de.measite.minidns.util.NameUtil; @@ -250,6 +251,9 @@ public class Record { case CNAME: this.payloadData = new CNAME(); break; + case PTR: + this.payloadData = new PTR(); + break; default: System.out.println("Unparsed type " + type); this.payloadData = null; diff --git a/src/main/java/de/measite/minidns/record/PTR.java b/src/main/java/de/measite/minidns/record/PTR.java new file mode 100644 index 000000000..6e2006554 --- /dev/null +++ b/src/main/java/de/measite/minidns/record/PTR.java @@ -0,0 +1,19 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +/** + * A PTR record is handled like a CNAME + */ +public class PTR extends CNAME { + + @Override + public TYPE getType() { + return TYPE.PTR; + } + +} From f13813186f7d106ff32e525b2517227108651dd9 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Tue, 22 Jul 2014 23:22:35 +0200 Subject: [PATCH 31/81] Add PTR record parsing --- .../java/de/measite/minidns/DNSMessage.java | 2 +- src/main/java/de/measite/minidns/Record.java | 4 ++++ .../java/de/measite/minidns/record/PTR.java | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/measite/minidns/record/PTR.java diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index fbee3b19c..01c096450 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -424,7 +424,7 @@ public class DNSMessage { message.authenticData = ((header >> 5) & 1) == 1; message.checkDisabled = ((header >> 4) & 1) == 1; message.responseCode = RESPONSE_CODE.getResponseCode(header & 0xf); - message.receiveTimestamp = System.currentTimeMillis(); + message.receiveTimestamp = System.currentTimeMillis(); int questionCount = dis.readUnsignedShort(); int answerCount = dis.readUnsignedShort(); int nameserverCount = dis.readUnsignedShort(); diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index 8f8d0d0e4..f14aa0b8f 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -9,6 +9,7 @@ import de.measite.minidns.record.AAAA; import de.measite.minidns.record.CNAME; import de.measite.minidns.record.Data; import de.measite.minidns.record.NS; +import de.measite.minidns.record.PTR; import de.measite.minidns.record.SRV; import de.measite.minidns.util.NameUtil; @@ -250,6 +251,9 @@ public class Record { case CNAME: this.payloadData = new CNAME(); break; + case PTR: + this.payloadData = new PTR(); + break; default: System.out.println("Unparsed type " + type); this.payloadData = null; diff --git a/src/main/java/de/measite/minidns/record/PTR.java b/src/main/java/de/measite/minidns/record/PTR.java new file mode 100644 index 000000000..6e2006554 --- /dev/null +++ b/src/main/java/de/measite/minidns/record/PTR.java @@ -0,0 +1,19 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +/** + * A PTR record is handled like a CNAME + */ +public class PTR extends CNAME { + + @Override + public TYPE getType() { + return TYPE.PTR; + } + +} From bfcba9169d106fb082857e0ba4eaef7ee3df0782 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 23 Jul 2014 00:00:55 +0200 Subject: [PATCH 32/81] Add TXT record --- src/main/java/de/measite/minidns/Record.java | 10 ++- .../java/de/measite/minidns/record/TXT.java | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/measite/minidns/record/TXT.java diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index f14aa0b8f..6aaf4f7d0 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -11,6 +11,7 @@ import de.measite.minidns.record.Data; import de.measite.minidns.record.NS; import de.measite.minidns.record.PTR; import de.measite.minidns.record.SRV; +import de.measite.minidns.record.TXT; import de.measite.minidns.util.NameUtil; /** @@ -231,7 +232,11 @@ public class Record { public void parse(DataInputStream dis, byte[] data) throws IOException { this.name = NameUtil.parse(dis, data); this.type = TYPE.getType(dis.readUnsignedShort()); - this.clazz = CLASS.getClass(dis.readUnsignedShort()); + int clazzValue = dis.readUnsignedShort(); + this.clazz = CLASS.getClass(clazzValue); + if (this.clazz == null) { + System.out.println("Unknown class " + clazzValue); + } this.ttl = (((long)dis.readUnsignedShort()) << 32) + dis.readUnsignedShort(); int payloadLength = dis.readUnsignedShort(); @@ -254,6 +259,9 @@ public class Record { case PTR: this.payloadData = new PTR(); break; + case TXT: + this.payloadData = new TXT(); + break; default: System.out.println("Unparsed type " + type); this.payloadData = null; diff --git a/src/main/java/de/measite/minidns/record/TXT.java b/src/main/java/de/measite/minidns/record/TXT.java new file mode 100644 index 000000000..03e730401 --- /dev/null +++ b/src/main/java/de/measite/minidns/record/TXT.java @@ -0,0 +1,65 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +/** + * TXT record (actually a binary blob with wrappers for text content). + */ +public class TXT implements Data { + + protected byte[] blob; + + public byte[] getBlob() { + return blob; + } + + public void setBlob(byte[] blob) { + this.blob = blob; + } + + public String getText() { + try { + return (new String(blob, "UTF-8")).intern(); + } catch (Exception e) { + /* Can't happen for UTF-8 unless it's really a blob */ + return null; + } + } + + public void setText(String text) { + try { + this.blob = text.getBytes("UTF-8"); + } catch (Exception e) { + /* Can't happen, UTF-8 IS supported */ + throw new RuntimeException("UTF-8 not supported", e); + } + } + + @Override + public byte[] toByteArray() { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public void parse(DataInputStream dis, byte[] data, int length) + throws IOException + { + blob = new byte[length]; + dis.readFully(blob); + } + + @Override + public TYPE getType() { + return TYPE.TXT; + } + + @Override + public String toString() { + return "\"" + getText() + "\""; + } + +} From b0f8bec1cbfa6d21e0873c63d5213bd12d429bd8 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 23 Jul 2014 00:00:55 +0200 Subject: [PATCH 33/81] Add TXT record --- src/main/java/de/measite/minidns/Record.java | 10 ++- .../java/de/measite/minidns/record/TXT.java | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/measite/minidns/record/TXT.java diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index f14aa0b8f..6aaf4f7d0 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -11,6 +11,7 @@ import de.measite.minidns.record.Data; import de.measite.minidns.record.NS; import de.measite.minidns.record.PTR; import de.measite.minidns.record.SRV; +import de.measite.minidns.record.TXT; import de.measite.minidns.util.NameUtil; /** @@ -231,7 +232,11 @@ public class Record { public void parse(DataInputStream dis, byte[] data) throws IOException { this.name = NameUtil.parse(dis, data); this.type = TYPE.getType(dis.readUnsignedShort()); - this.clazz = CLASS.getClass(dis.readUnsignedShort()); + int clazzValue = dis.readUnsignedShort(); + this.clazz = CLASS.getClass(clazzValue); + if (this.clazz == null) { + System.out.println("Unknown class " + clazzValue); + } this.ttl = (((long)dis.readUnsignedShort()) << 32) + dis.readUnsignedShort(); int payloadLength = dis.readUnsignedShort(); @@ -254,6 +259,9 @@ public class Record { case PTR: this.payloadData = new PTR(); break; + case TXT: + this.payloadData = new TXT(); + break; default: System.out.println("Unparsed type " + type); this.payloadData = null; diff --git a/src/main/java/de/measite/minidns/record/TXT.java b/src/main/java/de/measite/minidns/record/TXT.java new file mode 100644 index 000000000..03e730401 --- /dev/null +++ b/src/main/java/de/measite/minidns/record/TXT.java @@ -0,0 +1,65 @@ +package de.measite.minidns.record; + +import java.io.DataInputStream; +import java.io.IOException; + +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.util.NameUtil; + +/** + * TXT record (actually a binary blob with wrappers for text content). + */ +public class TXT implements Data { + + protected byte[] blob; + + public byte[] getBlob() { + return blob; + } + + public void setBlob(byte[] blob) { + this.blob = blob; + } + + public String getText() { + try { + return (new String(blob, "UTF-8")).intern(); + } catch (Exception e) { + /* Can't happen for UTF-8 unless it's really a blob */ + return null; + } + } + + public void setText(String text) { + try { + this.blob = text.getBytes("UTF-8"); + } catch (Exception e) { + /* Can't happen, UTF-8 IS supported */ + throw new RuntimeException("UTF-8 not supported", e); + } + } + + @Override + public byte[] toByteArray() { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public void parse(DataInputStream dis, byte[] data, int length) + throws IOException + { + blob = new byte[length]; + dis.readFully(blob); + } + + @Override + public TYPE getType() { + return TYPE.TXT; + } + + @Override + public String toString() { + return "\"" + getText() + "\""; + } + +} From 70d403f0b612f6cb26b8fc194e35c622c5383167 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 23 Jul 2014 21:53:38 +0200 Subject: [PATCH 34/81] Better / more verbose toString --- src/main/java/de/measite/minidns/DNSMessage.java | 5 ++++- src/main/java/de/measite/minidns/Question.java | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/measite/minidns/DNSMessage.java b/src/main/java/de/measite/minidns/DNSMessage.java index 01c096450..ab2535ce1 100644 --- a/src/main/java/de/measite/minidns/DNSMessage.java +++ b/src/main/java/de/measite/minidns/DNSMessage.java @@ -515,7 +515,10 @@ public class DNSMessage { public String toString() { return "-- DNSMessage " + id + " --\n" + - Arrays.toString(answers); + "Q" + Arrays.toString(questions) + + "NS" + Arrays.toString(nameserverRecords) + + "A" + Arrays.toString(answers) + + "ARR" + Arrays.toString(additionalResourceRecords); } } diff --git a/src/main/java/de/measite/minidns/Question.java b/src/main/java/de/measite/minidns/Question.java index 628622510..883003561 100644 --- a/src/main/java/de/measite/minidns/Question.java +++ b/src/main/java/de/measite/minidns/Question.java @@ -134,4 +134,9 @@ public class Question { byte o[] = ((Question)other).toByteArray(); return Arrays.equals(t, o); } + + @Override + public String toString() { + return "Question/" + clazz + "/" + type + ": " + name; + } } From 2bddcc9f997e5b1e565478e99c55488c86a85e76 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 23 Jul 2014 22:43:12 +0200 Subject: [PATCH 35/81] Add parsing for mdns QU records (RFC6762) --- src/main/java/de/measite/minidns/Record.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index 6aaf4f7d0..26af47115 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -222,6 +222,11 @@ public class Record { */ protected Data payloadData; + /** + * MDNS defines the highest bit of the class as the unicast query bit. + */ + protected boolean unicastQuery; + /** * Parse a given record based on the full message data and the current * stream position. @@ -233,7 +238,8 @@ public class Record { this.name = NameUtil.parse(dis, data); this.type = TYPE.getType(dis.readUnsignedShort()); int clazzValue = dis.readUnsignedShort(); - this.clazz = CLASS.getClass(clazzValue); + this.clazz = CLASS.getClass(clazzValue & 0x7fff); + this.unicastQuery = (clazzValue & 0x8000) > 0; if (this.clazz == null) { System.out.println("Unknown class " + clazzValue); } @@ -298,6 +304,14 @@ public class Record { (q.getName().equals(name)); } + /** + * See if this query/response was a unicast query (highest class bit set). + * @return True if it is a unicast query/response record. + */ + public boolean isUnicastQuery() { + return unicastQuery; + } + /** * The generic record name, e.g. "measite.de". * @return The record name. From e515a49027fc5de36b8977cf8b096afc9838a9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 27 Jul 2014 18:28:20 +0200 Subject: [PATCH 36/81] Update gradle plugin to 0.12.2 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 184a4b94c..6e8fdfb5d 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:0.12.0' + classpath 'com.android.tools.build:gradle:0.12.2' } } From 64f25e1783143fbedcf3a3b9fce484449077e7af Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 30 Jul 2014 22:29:16 +0200 Subject: [PATCH 37/81] Fix style on build.gradle --- build.gradle | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index bed210d01..5941beaf7 100644 --- a/build.gradle +++ b/build.gradle @@ -6,8 +6,8 @@ apply plugin: 'nexus' buildscript { repositories { jcenter() - mavenLocal() - mavenCentral() + mavenLocal() + mavenCentral() } dependencies { @@ -73,3 +73,5 @@ modifyPom { } } +dependencies { +} \ No newline at end of file From 858251d52c2ab04b3026573fa3e14b19579cac19 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 30 Jul 2014 22:37:09 +0200 Subject: [PATCH 38/81] Remove System.out code --- src/main/java/de/measite/minidns/Record.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/measite/minidns/Record.java b/src/main/java/de/measite/minidns/Record.java index 26af47115..ab0814266 100644 --- a/src/main/java/de/measite/minidns/Record.java +++ b/src/main/java/de/measite/minidns/Record.java @@ -3,6 +3,8 @@ package de.measite.minidns; import java.io.DataInputStream; import java.io.IOException; import java.util.HashMap; +import java.util.logging.Level; +import java.util.logging.Logger; import de.measite.minidns.record.A; import de.measite.minidns.record.AAAA; @@ -19,6 +21,8 @@ import de.measite.minidns.util.NameUtil; */ public class Record { + private static final Logger LOGGER = Logger.getLogger(Client.class.getName()); + /** * The record type. * @see IANA DNS Parameters @@ -241,7 +245,7 @@ public class Record { this.clazz = CLASS.getClass(clazzValue & 0x7fff); this.unicastQuery = (clazzValue & 0x8000) > 0; if (this.clazz == null) { - System.out.println("Unknown class " + clazzValue); + LOGGER.log(Level.FINE, "Unknown class " + clazzValue); } this.ttl = (((long)dis.readUnsignedShort()) << 32) + dis.readUnsignedShort(); @@ -269,7 +273,7 @@ public class Record { this.payloadData = new TXT(); break; default: - System.out.println("Unparsed type " + type); + LOGGER.log(Level.FINE, "Unparsed type " + type); this.payloadData = null; for (int i = 0; i < payloadLength; i++) { dis.readByte(); From 9e42bff01440c1351946a432126d5a1b87fb7c78 Mon Sep 17 00:00:00 2001 From: Rene Treffer Date: Wed, 30 Jul 2014 22:38:02 +0200 Subject: [PATCH 39/81] Allow mdns multicast / unicast reply queries --- .../java/de/measite/minidns/Question.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/measite/minidns/Question.java b/src/main/java/de/measite/minidns/Question.java index 883003561..3b2fa1a13 100644 --- a/src/main/java/de/measite/minidns/Question.java +++ b/src/main/java/de/measite/minidns/Question.java @@ -30,6 +30,11 @@ public class Question { */ private final CLASS clazz; + /** + * UnicastQueries have the highest bit of the CLASS field set to 1. + */ + private final boolean unicastQuery; + /** * Cache for the serialized object. */ @@ -41,10 +46,21 @@ public class Question { * @param type The type, e.g. A. * @param clazz The class, usually IN (internet). */ - public Question(String name, TYPE type, CLASS clazz) { + public Question(String name, TYPE type, CLASS clazz, boolean unicastQuery) { this.name = name; this.type = type; this.clazz = clazz; + this.unicastQuery = unicastQuery; + } + + /** + * Create a dns question for the given name/type/class. + * @param name The name e.g. "measite.de". + * @param type The type, e.g. A. + * @param clazz The class, usually IN (internet). + */ + public Question(String name, TYPE type, CLASS clazz) { + this(name, type, clazz, false); } /** @@ -106,7 +122,7 @@ public class Question { try { dos.write(NameUtil.toByteArray(this.name)); dos.writeShort(type.getValue()); - dos.writeShort(clazz.getValue()); + dos.writeShort(clazz.getValue() | (unicastQuery ? (1 << 15) : 0)); dos.flush(); } catch (IOException e) { // Should never happen From fab79b605f0317891fe4260dcec0dea1a76fa04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 4 Aug 2014 17:09:59 +0200 Subject: [PATCH 40/81] Lint fixes --- src/org/openintents/openpgp/util/OpenPgpUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpUtils.java b/src/org/openintents/openpgp/util/OpenPgpUtils.java index e24c937aa..416b2841b 100644 --- a/src/org/openintents/openpgp/util/OpenPgpUtils.java +++ b/src/org/openintents/openpgp/util/OpenPgpUtils.java @@ -67,7 +67,7 @@ public class OpenPgpUtils { } private static String convertKeyIdToHex32bit(long keyId) { - String hexString = Long.toHexString(keyId & 0xffffffffL).toLowerCase(Locale.US); + String hexString = Long.toHexString(keyId & 0xffffffffL).toLowerCase(Locale.ENGLISH); while (hexString.length() < 8) { hexString = "0" + hexString; } From 4f6475f11bb8b268775b6633d9f23e3a269aff96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 11 Aug 2014 17:12:02 +0200 Subject: [PATCH 41/81] Add metadata parcel --- .../openpgp/OpenPgpDecryptMetadata.java | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/org/openintents/openpgp/OpenPgpDecryptMetadata.java diff --git a/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java b/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java new file mode 100644 index 000000000..cfb507458 --- /dev/null +++ b/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014 Dominik Schürmann + * + * 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 + * + * http://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. + */ + +package org.openintents.openpgp; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Parcelable versioning has been copied from Dashclock Widget + * https://code.google.com/p/dashclock/source/browse/api/src/main/java/com/google/android/apps/dashclock/api/ExtensionData.java + */ +public class OpenPgpDecryptMetadata implements Parcelable { + /** + * Since there might be a case where new versions of the client using the library getting + * old versions of the protocol (and thus old versions of this class), we need a versioning + * system for the parcels sent between the clients and the providers. + */ + public static final int PARCELABLE_VERSION = 1; + + String filename; + long modificationTime; + int format; + long originalSize; + + public String getFilename() { + return filename; + } + + public long getModificationTime() { + return modificationTime; + } + + public long getOriginalSize() { + return originalSize; + } + + public int getFormat() { + return format; + } + + public OpenPgpDecryptMetadata() { + } + + public OpenPgpDecryptMetadata(String filename, long modificationTime, + int format, long originalSize) { + this.filename = filename; + this.modificationTime = modificationTime; + this.format = format; + this.originalSize = originalSize; + } + + public OpenPgpDecryptMetadata(OpenPgpDecryptMetadata b) { + this.filename = b.filename; + this.modificationTime = b.modificationTime; + this.format = b.format; + this.originalSize = b.originalSize; + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + /** + * NOTE: When adding fields in the process of updating this API, make sure to bump + * {@link #PARCELABLE_VERSION}. + */ + dest.writeInt(PARCELABLE_VERSION); + // Inject a placeholder that will store the parcel size from this point on + // (not including the size itself). + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeString(filename); + dest.writeLong(modificationTime); + dest.writeInt(format); + dest.writeLong(originalSize); + // Go back and write the size + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + public OpenPgpDecryptMetadata createFromParcel(final Parcel source) { + int parcelableVersion = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + OpenPgpDecryptMetadata vr = new OpenPgpDecryptMetadata(); + vr.filename = source.readString(); + vr.modificationTime = source.readLong(); + vr.format = source.readInt(); + vr.originalSize = source.readLong(); + + // skip over all fields added in future versions of this parcel + source.setDataPosition(startPosition + parcelableSize); + + return vr; + } + + public OpenPgpDecryptMetadata[] newArray(final int size) { + return new OpenPgpDecryptMetadata[size]; + } + }; + + @Override + public String toString() { + String out = new String(); + out += "\nfilename: " + filename; + out += "\nmodificationTime: " + modificationTime; + out += "\nformat: " + format; + out += "\noriginalSize: " + originalSize; + return out; + } + +} From 6bec5eedde31bacbafcb541a728719eeff99d3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 11 Aug 2014 17:19:23 +0200 Subject: [PATCH 42/81] Increase version to 4 --- .../openintents/openpgp/util/OpenPgpApi.java | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index 029584423..238447502 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -34,9 +34,22 @@ public class OpenPgpApi { public static final String TAG = "OpenPgp API"; - public static final int API_VERSION = 3; public static final String SERVICE_INTENT = "org.openintents.openpgp.IOpenPgpService"; + /** + * Version history + * --------------- + *

+ * 3: + * - first public stable version + *

+ * 4: + * - No changes to existing methods -> backward compatible + * - Introduction of ACTION_DECRYPT_METADATA, RESULT_METADATA, and OpenPgpDecryptMetadata parcel + * - Introduction of internal NFC extras: EXTRA_NFC_SIGNED_HASH, EXTRA_NFC_SIG_CREATION_TIMESTAMP + */ + public static final int API_VERSION = 4; + /** * General extras * -------------- @@ -54,7 +67,7 @@ public class OpenPgpApi { * Sign only *

* optional extras: - * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) * String EXTRA_PASSPHRASE (key passphrase) */ public static final String ACTION_SIGN = "org.openintents.openpgp.action.SIGN"; @@ -68,7 +81,7 @@ public class OpenPgpApi { * long[] EXTRA_KEY_IDS *

* optional extras: - * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) * String EXTRA_PASSPHRASE (key passphrase) */ public static final String ACTION_ENCRYPT = "org.openintents.openpgp.action.ENCRYPT"; @@ -82,7 +95,7 @@ public class OpenPgpApi { * long[] EXTRA_KEY_IDS *

* optional extras: - * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) * String EXTRA_PASSPHRASE (key passphrase) */ public static final String ACTION_SIGN_AND_ENCRYPT = "org.openintents.openpgp.action.SIGN_AND_ENCRYPT"; @@ -95,13 +108,24 @@ public class OpenPgpApi { * in addition a PendingIntent is returned via RESULT_INTENT to download missing keys. *

* optional extras: - * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for ouput) + * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) *

* returned extras: * OpenPgpSignatureResult RESULT_SIGNATURE + * OpenPgpDecryptMetadata RESULT_METADATA */ public static final String ACTION_DECRYPT_VERIFY = "org.openintents.openpgp.action.DECRYPT_VERIFY"; + /** + * Decrypts the header of an encrypted file to retrieve metadata such as original filename. + *

+ * This does not decrypt the actual content of the file. + *

+ * returned extras: + * OpenPgpDecryptMetadata RESULT_METADATA + */ + public static final String ACTION_DECRYPT_METADATA = "org.openintents.openpgp.action.DECRYPT_METADATA"; + /** * Get key ids based on given user ids (=emails) *

@@ -165,6 +189,7 @@ public class OpenPgpApi { // DECRYPT_VERIFY public static final String RESULT_SIGNATURE = "signature"; + public static final String RESULT_METADATA = "metadata"; IOpenPgpService mService; Context mContext; From 9aa5f23788f19689fb172fd209de72bc6941afdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 11 Aug 2014 20:16:30 +0200 Subject: [PATCH 43/81] Support mime type in metadata --- .../openpgp/OpenPgpDecryptMetadata.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java b/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java index cfb507458..832feb8d6 100644 --- a/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java +++ b/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java @@ -32,14 +32,18 @@ public class OpenPgpDecryptMetadata implements Parcelable { public static final int PARCELABLE_VERSION = 1; String filename; + String mimeType; long modificationTime; - int format; long originalSize; public String getFilename() { return filename; } + public String getMimeType() { + return mimeType; + } + public long getModificationTime() { return modificationTime; } @@ -48,25 +52,21 @@ public class OpenPgpDecryptMetadata implements Parcelable { return originalSize; } - public int getFormat() { - return format; - } - public OpenPgpDecryptMetadata() { } - public OpenPgpDecryptMetadata(String filename, long modificationTime, - int format, long originalSize) { + public OpenPgpDecryptMetadata(String filename, String mimeType, long modificationTime, + long originalSize) { this.filename = filename; + this.mimeType = mimeType; this.modificationTime = modificationTime; - this.format = format; this.originalSize = originalSize; } public OpenPgpDecryptMetadata(OpenPgpDecryptMetadata b) { this.filename = b.filename; + this.mimeType = b.mimeType; this.modificationTime = b.modificationTime; - this.format = b.format; this.originalSize = b.originalSize; } @@ -87,8 +87,8 @@ public class OpenPgpDecryptMetadata implements Parcelable { int startPosition = dest.dataPosition(); // version 1 dest.writeString(filename); + dest.writeString(mimeType); dest.writeLong(modificationTime); - dest.writeInt(format); dest.writeLong(originalSize); // Go back and write the size int parcelableSize = dest.dataPosition() - startPosition; @@ -105,8 +105,8 @@ public class OpenPgpDecryptMetadata implements Parcelable { OpenPgpDecryptMetadata vr = new OpenPgpDecryptMetadata(); vr.filename = source.readString(); + vr.mimeType = source.readString(); vr.modificationTime = source.readLong(); - vr.format = source.readInt(); vr.originalSize = source.readLong(); // skip over all fields added in future versions of this parcel @@ -122,10 +122,9 @@ public class OpenPgpDecryptMetadata implements Parcelable { @Override public String toString() { - String out = new String(); - out += "\nfilename: " + filename; + String out = "\nfilename: " + filename; + out += "\nmimeType: " + mimeType; out += "\nmodificationTime: " + modificationTime; - out += "\nformat: " + format; out += "\noriginalSize: " + originalSize; return out; } From 777d22d75da9bfecf0bcfe2088da3fef01b2259f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 11 Aug 2014 21:26:34 +0200 Subject: [PATCH 44/81] Rename to OpenPgpMetadata --- ...ryptMetadata.java => OpenPgpMetadata.java} | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) rename src/org/openintents/openpgp/{OpenPgpDecryptMetadata.java => OpenPgpMetadata.java} (84%) diff --git a/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java b/src/org/openintents/openpgp/OpenPgpMetadata.java similarity index 84% rename from src/org/openintents/openpgp/OpenPgpDecryptMetadata.java rename to src/org/openintents/openpgp/OpenPgpMetadata.java index 832feb8d6..2a99e406f 100644 --- a/src/org/openintents/openpgp/OpenPgpDecryptMetadata.java +++ b/src/org/openintents/openpgp/OpenPgpMetadata.java @@ -23,7 +23,7 @@ import android.os.Parcelable; * Parcelable versioning has been copied from Dashclock Widget * https://code.google.com/p/dashclock/source/browse/api/src/main/java/com/google/android/apps/dashclock/api/ExtensionData.java */ -public class OpenPgpDecryptMetadata implements Parcelable { +public class OpenPgpMetadata implements Parcelable { /** * Since there might be a case where new versions of the client using the library getting * old versions of the protocol (and thus old versions of this class), we need a versioning @@ -52,18 +52,18 @@ public class OpenPgpDecryptMetadata implements Parcelable { return originalSize; } - public OpenPgpDecryptMetadata() { + public OpenPgpMetadata() { } - public OpenPgpDecryptMetadata(String filename, String mimeType, long modificationTime, - long originalSize) { + public OpenPgpMetadata(String filename, String mimeType, long modificationTime, + long originalSize) { this.filename = filename; this.mimeType = mimeType; this.modificationTime = modificationTime; this.originalSize = originalSize; } - public OpenPgpDecryptMetadata(OpenPgpDecryptMetadata b) { + public OpenPgpMetadata(OpenPgpMetadata b) { this.filename = b.filename; this.mimeType = b.mimeType; this.modificationTime = b.modificationTime; @@ -97,13 +97,13 @@ public class OpenPgpDecryptMetadata implements Parcelable { dest.setDataPosition(startPosition + parcelableSize); } - public static final Creator CREATOR = new Creator() { - public OpenPgpDecryptMetadata createFromParcel(final Parcel source) { + public static final Creator CREATOR = new Creator() { + public OpenPgpMetadata createFromParcel(final Parcel source) { int parcelableVersion = source.readInt(); int parcelableSize = source.readInt(); int startPosition = source.dataPosition(); - OpenPgpDecryptMetadata vr = new OpenPgpDecryptMetadata(); + OpenPgpMetadata vr = new OpenPgpMetadata(); vr.filename = source.readString(); vr.mimeType = source.readString(); vr.modificationTime = source.readLong(); @@ -115,8 +115,8 @@ public class OpenPgpDecryptMetadata implements Parcelable { return vr; } - public OpenPgpDecryptMetadata[] newArray(final int size) { - return new OpenPgpDecryptMetadata[size]; + public OpenPgpMetadata[] newArray(final int size) { + return new OpenPgpMetadata[size]; } }; From 940a1d8eebe297e6a94f417a24c0403f1d8d650f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 11 Aug 2014 23:56:25 +0200 Subject: [PATCH 45/81] Add EXTRA_ORIGINAL_FILENAME --- src/org/openintents/openpgp/util/OpenPgpApi.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index 238447502..485d3710b 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -45,7 +45,7 @@ public class OpenPgpApi { *

* 4: * - No changes to existing methods -> backward compatible - * - Introduction of ACTION_DECRYPT_METADATA, RESULT_METADATA, and OpenPgpDecryptMetadata parcel + * - Introduction of ACTION_DECRYPT_METADATA, RESULT_METADATA, EXTRA_ORIGINAL_FILENAME, and OpenPgpMetadata parcel * - Introduction of internal NFC extras: EXTRA_NFC_SIGNED_HASH, EXTRA_NFC_SIG_CREATION_TIMESTAMP */ public static final int API_VERSION = 4; @@ -83,6 +83,7 @@ public class OpenPgpApi { * optional extras: * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) * String EXTRA_PASSPHRASE (key passphrase) + * String EXTRA_ORIGINAL_FILENAME (original filename to be encrypted as metadata) */ public static final String ACTION_ENCRYPT = "org.openintents.openpgp.action.ENCRYPT"; @@ -97,6 +98,7 @@ public class OpenPgpApi { * optional extras: * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) * String EXTRA_PASSPHRASE (key passphrase) + * String EXTRA_ORIGINAL_FILENAME (original filename to be encrypted as metadata) */ public static final String ACTION_SIGN_AND_ENCRYPT = "org.openintents.openpgp.action.SIGN_AND_ENCRYPT"; @@ -164,6 +166,7 @@ public class OpenPgpApi { public static final String EXTRA_KEY_IDS = "key_ids"; // optional extras: public static final String EXTRA_PASSPHRASE = "passphrase"; + public static final String EXTRA_ORIGINAL_FILENAME = "original_filename"; // internal NFC states public static final String EXTRA_NFC_SIGNED_HASH = "nfc_signed_hash"; From cbfeb90cec9d9ecea2576eddf42e37154286c667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 12 Aug 2014 13:46:34 +0200 Subject: [PATCH 46/81] Refactor OpenPgpServiceConnection with better callback --- .../util/OpenPgpServiceConnection.java | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java index 0395a7bc5..2700caf01 100644 --- a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java +++ b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java @@ -26,9 +26,11 @@ import android.os.IBinder; public class OpenPgpServiceConnection { - // interface to create callbacks for onServiceConnected + // callback interface public interface OnBound { public void onBound(IOpenPgpService service); + + public void onError(Exception e); } private Context mApplicationContext; @@ -39,19 +41,19 @@ public class OpenPgpServiceConnection { private OnBound mOnBoundListener; /** - * Create new OpenPgpServiceConnection + * Create new connection * * @param context * @param providerPackageName specify package name of OpenPGP provider, * e.g., "org.sufficientlysecure.keychain" */ public OpenPgpServiceConnection(Context context, String providerPackageName) { - this.mApplicationContext = context.getApplicationContext(); + this.mApplicationContext = context; this.mProviderPackageName = providerPackageName; } /** - * Create new OpenPgpServiceConnection + * Create new connection with callback * * @param context * @param providerPackageName specify package name of OpenPGP provider, @@ -60,8 +62,7 @@ public class OpenPgpServiceConnection { */ public OpenPgpServiceConnection(Context context, String providerPackageName, OnBound onBoundListener) { - this.mApplicationContext = context.getApplicationContext(); - this.mProviderPackageName = providerPackageName; + this(context, providerPackageName); this.mOnBoundListener = onBoundListener; } @@ -91,23 +92,25 @@ public class OpenPgpServiceConnection { * * @return */ - public boolean bindToService() { + public void bindToService() { // if not already bound... if (mService == null) { try { - Intent serviceIntent = new Intent(); - serviceIntent.setAction(IOpenPgpService.class.getName()); + Intent serviceIntent = new Intent(OpenPgpApi.SERVICE_INTENT); // NOTE: setPackage is very important to restrict the intent to this provider only! serviceIntent.setPackage(mProviderPackageName); mApplicationContext.bindService(serviceIntent, mServiceConnection, Context.BIND_AUTO_CREATE); - - return true; } catch (Exception e) { - return false; + if (mOnBoundListener != null) { + mOnBoundListener.onError(e); + } } } else { - return true; + // already bound, but also inform client about it with callback + if (mOnBoundListener != null) { + mOnBoundListener.onBound(mService); + } } } From 9e1f732b23d3b5df042f627d65a8ca37cec023fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 12 Aug 2014 14:38:28 +0200 Subject: [PATCH 47/81] More fixes for OpenPgpServiceConnection --- .../openpgp/util/OpenPgpServiceConnection.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java index 2700caf01..dad27ccbe 100644 --- a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java +++ b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java @@ -16,14 +16,14 @@ package org.openintents.openpgp.util; -import org.openintents.openpgp.IOpenPgpService; - import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.IBinder; +import org.openintents.openpgp.IOpenPgpService; + public class OpenPgpServiceConnection { // callback interface @@ -99,8 +99,11 @@ public class OpenPgpServiceConnection { Intent serviceIntent = new Intent(OpenPgpApi.SERVICE_INTENT); // NOTE: setPackage is very important to restrict the intent to this provider only! serviceIntent.setPackage(mProviderPackageName); - mApplicationContext.bindService(serviceIntent, mServiceConnection, + boolean connect = mApplicationContext.bindService(serviceIntent, mServiceConnection, Context.BIND_AUTO_CREATE); + if (!connect) { + throw new Exception("bindService() returned false!"); + } } catch (Exception e) { if (mOnBoundListener != null) { mOnBoundListener.onError(e); From 1d0eeef047c4938f4e25bc06f7e92e83aa46a4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 12 Aug 2014 15:03:00 +0200 Subject: [PATCH 48/81] Use application context --- src/org/openintents/openpgp/util/OpenPgpServiceConnection.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java index dad27ccbe..15096d9eb 100644 --- a/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java +++ b/src/org/openintents/openpgp/util/OpenPgpServiceConnection.java @@ -48,7 +48,7 @@ public class OpenPgpServiceConnection { * e.g., "org.sufficientlysecure.keychain" */ public OpenPgpServiceConnection(Context context, String providerPackageName) { - this.mApplicationContext = context; + this.mApplicationContext = context.getApplicationContext(); this.mProviderPackageName = providerPackageName; } From d15482c22a17c4d07b993d8e576dcb953620ee4b Mon Sep 17 00:00:00 2001 From: Florian Schmaus Date: Sat, 30 Aug 2014 15:19:43 +0200 Subject: [PATCH 49/81] Add my Real-name to the Copyright statement of ParcelFileDescriptorUtil. --- .../openintents/openpgp/util/ParcelFileDescriptorUtil.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java b/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java index 58c62110d..4fd4b39a7 100644 --- a/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java +++ b/src/org/openintents/openpgp/util/ParcelFileDescriptorUtil.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2014 Dominik Schürmann - * 2013 Flow (http://stackoverflow.com/questions/18212152/transfer-inputstream-to-another-service-across-process-boundaries-with-parcelf) + * 2013 Florian Schmaus * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +/** + * Partially based on Stackoverflow: Transfer InputStream to another Service (across process boundaries) + **/ public class ParcelFileDescriptorUtil { public interface IThreadListener { @@ -100,4 +103,4 @@ public class ParcelFileDescriptorUtil { } } } -} \ No newline at end of file +} From e0be0f648ade84a83c7561c0521853447ca8b3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 31 Aug 2014 23:39:56 +0200 Subject: [PATCH 50/81] Add SIGNATURE_KEY_REVOKED, SIGNATURE_KEY_EXPIRED, rename SIGNATURE_PUB_KEY_UNKNOWN --- .../openintents/openpgp/OpenPgpSignatureResult.java | 12 ++++++++---- src/org/openintents/openpgp/util/OpenPgpApi.java | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/org/openintents/openpgp/OpenPgpSignatureResult.java b/src/org/openintents/openpgp/OpenPgpSignatureResult.java index 7a4d799dc..23401a1d8 100644 --- a/src/org/openintents/openpgp/OpenPgpSignatureResult.java +++ b/src/org/openintents/openpgp/OpenPgpSignatureResult.java @@ -37,12 +37,16 @@ public class OpenPgpSignatureResult implements Parcelable { // generic error on signature verification public static final int SIGNATURE_ERROR = 0; - // successfully verified signature, with certified public key + // successfully verified signature, with certified key public static final int SIGNATURE_SUCCESS_CERTIFIED = 1; - // no public key was found for this signature verification - public static final int SIGNATURE_UNKNOWN_PUB_KEY = 2; - // successfully verified signature, but with uncertified public key + // no key was found for this signature verification + public static final int SIGNATURE_KEY_MISSING = 2; + // successfully verified signature, but with uncertified key public static final int SIGNATURE_SUCCESS_UNCERTIFIED = 3; + // key has been revoked + public static final int SIGNATURE_KEY_REVOKED = 4; + // key is expired + public static final int SIGNATURE_KEY_EXPIRED = 5; int status; boolean signatureOnly; diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index 485d3710b..a248a0e2e 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -47,8 +47,10 @@ public class OpenPgpApi { * - No changes to existing methods -> backward compatible * - Introduction of ACTION_DECRYPT_METADATA, RESULT_METADATA, EXTRA_ORIGINAL_FILENAME, and OpenPgpMetadata parcel * - Introduction of internal NFC extras: EXTRA_NFC_SIGNED_HASH, EXTRA_NFC_SIG_CREATION_TIMESTAMP + * 5: + * - OpenPgpSignatureResult: new SIGNATURE_KEY_REVOKED and SIGNATURE_KEY_EXPIRED */ - public static final int API_VERSION = 4; + public static final int API_VERSION = 5; /** * General extras @@ -106,7 +108,7 @@ public class OpenPgpApi { * Decrypts and verifies given input stream. This methods handles encrypted-only, signed-and-encrypted, * and also signed-only input. *

- * If OpenPgpSignatureResult.getStatus() == OpenPgpSignatureResult.SIGNATURE_UNKNOWN_PUB_KEY + * If OpenPgpSignatureResult.getStatus() == OpenPgpSignatureResult.SIGNATURE_KEY_MISSING * in addition a PendingIntent is returned via RESULT_INTENT to download missing keys. *

* optional extras: From 575b9d2279cd36b607316b76af6a968d54102660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 31 Aug 2014 23:48:21 +0200 Subject: [PATCH 51/81] Add userIds to OpenPgpSignatureResult --- .../openpgp/OpenPgpSignatureResult.java | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/org/openintents/openpgp/OpenPgpSignatureResult.java b/src/org/openintents/openpgp/OpenPgpSignatureResult.java index 23401a1d8..3b78abcf8 100644 --- a/src/org/openintents/openpgp/OpenPgpSignatureResult.java +++ b/src/org/openintents/openpgp/OpenPgpSignatureResult.java @@ -21,6 +21,7 @@ import android.os.Parcelable; import org.openintents.openpgp.util.OpenPgpUtils; +import java.util.ArrayList; import java.util.Locale; /** @@ -33,7 +34,7 @@ public class OpenPgpSignatureResult implements Parcelable { * old versions of the protocol (and thus old versions of this class), we need a versioning * system for the parcels sent between the clients and the providers. */ - public static final int PARCELABLE_VERSION = 1; + public static final int PARCELABLE_VERSION = 2; // generic error on signature verification public static final int SIGNATURE_ERROR = 0; @@ -50,7 +51,8 @@ public class OpenPgpSignatureResult implements Parcelable { int status; boolean signatureOnly; - String userId; + String primaryUserId; + ArrayList userIds; long keyId; public int getStatus() { @@ -69,12 +71,20 @@ public class OpenPgpSignatureResult implements Parcelable { this.signatureOnly = signatureOnly; } - public String getUserId() { - return userId; + public String getPrimaryUserId() { + return primaryUserId; } - public void setUserId(String userId) { - this.userId = userId; + public void setPrimaryUserId(String primaryUserId) { + this.primaryUserId = primaryUserId; + } + + public ArrayList getUserIds() { + return userIds; + } + + public void setUserIds(ArrayList userIds) { + this.userIds = userIds; } public long getKeyId() { @@ -90,18 +100,20 @@ public class OpenPgpSignatureResult implements Parcelable { } public OpenPgpSignatureResult(int signatureStatus, String signatureUserId, - boolean signatureOnly, long keyId) { + boolean signatureOnly, long keyId, ArrayList userIds) { this.status = signatureStatus; this.signatureOnly = signatureOnly; - this.userId = signatureUserId; + this.primaryUserId = signatureUserId; this.keyId = keyId; + this.userIds = userIds; } public OpenPgpSignatureResult(OpenPgpSignatureResult b) { this.status = b.status; - this.userId = b.userId; + this.primaryUserId = b.primaryUserId; this.signatureOnly = b.signatureOnly; this.keyId = b.keyId; + this.userIds = b.userIds; } public int describeContents() { @@ -122,8 +134,10 @@ public class OpenPgpSignatureResult implements Parcelable { // version 1 dest.writeInt(status); dest.writeByte((byte) (signatureOnly ? 1 : 0)); - dest.writeString(userId); + dest.writeString(primaryUserId); dest.writeLong(keyId); + // version 2 + dest.writeList(userIds); // Go back and write the size int parcelableSize = dest.dataPosition() - startPosition; dest.setDataPosition(sizePosition); @@ -140,8 +154,9 @@ public class OpenPgpSignatureResult implements Parcelable { OpenPgpSignatureResult vr = new OpenPgpSignatureResult(); vr.status = source.readInt(); vr.signatureOnly = source.readByte() == 1; - vr.userId = source.readString(); + vr.primaryUserId = source.readString(); vr.keyId = source.readLong(); + source.readStringList(vr.userIds); // skip over all fields added in future versions of this parcel source.setDataPosition(startPosition + parcelableSize); @@ -156,9 +171,9 @@ public class OpenPgpSignatureResult implements Parcelable { @Override public String toString() { - String out = new String(); - out += "\nstatus: " + status; - out += "\nuserId: " + userId; + String out = "\nstatus: " + status; + out += "\nprimaryUserId: " + primaryUserId; + out += "\nuserIds: " + userIds; out += "\nsignatureOnly: " + signatureOnly; out += "\nkeyId: " + OpenPgpUtils.convertKeyIdToHex(keyId); return out; From 8b36d286680ee57b2181e86a3f02ba1278a81166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 31 Aug 2014 23:49:34 +0200 Subject: [PATCH 52/81] Changelog --- src/org/openintents/openpgp/util/OpenPgpApi.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index a248a0e2e..a7c2ad6f9 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -48,7 +48,8 @@ public class OpenPgpApi { * - Introduction of ACTION_DECRYPT_METADATA, RESULT_METADATA, EXTRA_ORIGINAL_FILENAME, and OpenPgpMetadata parcel * - Introduction of internal NFC extras: EXTRA_NFC_SIGNED_HASH, EXTRA_NFC_SIG_CREATION_TIMESTAMP * 5: - * - OpenPgpSignatureResult: new SIGNATURE_KEY_REVOKED and SIGNATURE_KEY_EXPIRED + * - OpenPgpSignatureResult: new consts SIGNATURE_KEY_REVOKED and SIGNATURE_KEY_EXPIRED + * - OpenPgpSignatureResult: ArrayList userIds */ public static final int API_VERSION = 5; From 3bcf7266c3944c5aad417ae339eaf4420783877d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 2 Sep 2014 16:11:07 +0200 Subject: [PATCH 53/81] Update README --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da8361e88..ac54aa296 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ -# OpenPGP Remote API library +# OpenPGP API library + +The OpenPGP API provides methods to do common OpenPGP operations, such as sign, encrypt, decrypt, verify, and more without user interaction from background threads. This is done by connecting your client application to a remote service provided by OpenKeychain or other OpenPGP providers. + +For usage instructions, please consult our Wiki page about the [OpenPGP API](https://github.com/open-keychain/open-keychain/wiki/OpenPGP-API). + +License +======= + + 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 + + http://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. -For usage instructions, please consult our Wiki page about the [OpenPGP Remote API](https://github.com/open-keychain/open-keychain/wiki/OpenPGP-API). \ No newline at end of file From bba03f2f2ff16550853cbf573f4cbf30b0e0f641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 2 Sep 2014 16:11:46 +0200 Subject: [PATCH 54/81] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac54aa296..c9491400a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenPGP API library -The OpenPGP API provides methods to do common OpenPGP operations, such as sign, encrypt, decrypt, verify, and more without user interaction from background threads. This is done by connecting your client application to a remote service provided by OpenKeychain or other OpenPGP providers. +The OpenPGP API provides methods to execute OpenPGP operations, such as sign, encrypt, decrypt, verify, and more without user interaction from background threads. This is done by connecting your client application to a remote service provided by OpenKeychain or other OpenPGP providers. For usage instructions, please consult our Wiki page about the [OpenPGP API](https://github.com/open-keychain/open-keychain/wiki/OpenPGP-API). From 9dd15131e1a5cd67f90574a9513940215a142661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Tue, 2 Sep 2014 16:12:35 +0200 Subject: [PATCH 55/81] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9491400a..aefc9ed34 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenPGP API library -The OpenPGP API provides methods to execute OpenPGP operations, such as sign, encrypt, decrypt, verify, and more without user interaction from background threads. This is done by connecting your client application to a remote service provided by OpenKeychain or other OpenPGP providers. +The OpenPGP API provides methods to execute OpenPGP operations, such as sign, encrypt, decrypt, verify, and more without user interaction from background threads. This is done by connecting your client application to a remote service provided by [OpenKeychain](http://www.openkeychain.org) or other OpenPGP providers. For usage instructions, please consult our Wiki page about the [OpenPGP API](https://github.com/open-keychain/open-keychain/wiki/OpenPGP-API). From ec234ebf010a268922e8f3c08f21f9efd8cf1cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Thu, 4 Sep 2014 01:28:06 +0200 Subject: [PATCH 56/81] Pull from transifex --- res/values-cs/strings.xml | 5 +++++ res/values-de/strings.xml | 5 +++++ res/values-es/strings.xml | 5 +++++ res/values-et/strings.xml | 2 ++ res/values-fi/strings.xml | 2 ++ res/values-fr/strings.xml | 5 +++++ res/values-is/strings.xml | 2 ++ res/values-it/strings.xml | 5 +++++ res/values-ja/strings.xml | 5 +++++ res/values-nl/strings.xml | 2 ++ res/values-pl/strings.xml | 2 ++ res/values-pt/strings.xml | 2 ++ res/values-ru/strings.xml | 5 +++++ res/values-sl/strings.xml | 5 +++++ res/values-tr/strings.xml | 2 ++ res/values-uk/strings.xml | 5 +++++ res/values-zh/strings.xml | 2 ++ 17 files changed, 61 insertions(+) create mode 100644 res/values-cs/strings.xml create mode 100644 res/values-de/strings.xml create mode 100644 res/values-es/strings.xml create mode 100644 res/values-et/strings.xml create mode 100644 res/values-fi/strings.xml create mode 100644 res/values-fr/strings.xml create mode 100644 res/values-is/strings.xml create mode 100644 res/values-it/strings.xml create mode 100644 res/values-ja/strings.xml create mode 100644 res/values-nl/strings.xml create mode 100644 res/values-pl/strings.xml create mode 100644 res/values-pt/strings.xml create mode 100644 res/values-ru/strings.xml create mode 100644 res/values-sl/strings.xml create mode 100644 res/values-tr/strings.xml create mode 100644 res/values-uk/strings.xml create mode 100644 res/values-zh/strings.xml diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml new file mode 100644 index 000000000..c9fe1fab7 --- /dev/null +++ b/res/values-cs/strings.xml @@ -0,0 +1,5 @@ + + + Žádný + Instalovat OpenKeychain pomocí %s + diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml new file mode 100644 index 000000000..91e800adb --- /dev/null +++ b/res/values-de/strings.xml @@ -0,0 +1,5 @@ + + + Keine Auswahl + Installiere OpenKeychain mit %s + diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml new file mode 100644 index 000000000..da8979b45 --- /dev/null +++ b/res/values-es/strings.xml @@ -0,0 +1,5 @@ + + + Ninguno + Instalar OpenKeychain mediante %s + diff --git a/res/values-et/strings.xml b/res/values-et/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-et/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-fi/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml new file mode 100644 index 000000000..9b36df2df --- /dev/null +++ b/res/values-fr/strings.xml @@ -0,0 +1,5 @@ + + + Aucun + Installer OpenKeychain par %s + diff --git a/res/values-is/strings.xml b/res/values-is/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-is/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml new file mode 100644 index 000000000..23e8e8013 --- /dev/null +++ b/res/values-it/strings.xml @@ -0,0 +1,5 @@ + + + Nessuno + Installa OpenKeychain via %s + diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml new file mode 100644 index 000000000..5e337f5ab --- /dev/null +++ b/res/values-ja/strings.xml @@ -0,0 +1,5 @@ + + + 無し + %s 経由でOpenKeychainをインストール + diff --git a/res/values-nl/strings.xml b/res/values-nl/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-nl/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-pl/strings.xml b/res/values-pl/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-pl/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-pt/strings.xml b/res/values-pt/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml new file mode 100644 index 000000000..e8fd1ddf6 --- /dev/null +++ b/res/values-ru/strings.xml @@ -0,0 +1,5 @@ + + + Нет + Установить OpenKeychain через %s + diff --git a/res/values-sl/strings.xml b/res/values-sl/strings.xml new file mode 100644 index 000000000..20bf70b0a --- /dev/null +++ b/res/values-sl/strings.xml @@ -0,0 +1,5 @@ + + + Brez + Namesti OpenKeychain prek %s + diff --git a/res/values-tr/strings.xml b/res/values-tr/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-tr/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/res/values-uk/strings.xml b/res/values-uk/strings.xml new file mode 100644 index 000000000..baf600a9f --- /dev/null +++ b/res/values-uk/strings.xml @@ -0,0 +1,5 @@ + + + Жоден + Встановити OpenKeychain через %s + diff --git a/res/values-zh/strings.xml b/res/values-zh/strings.xml new file mode 100644 index 000000000..c757504ac --- /dev/null +++ b/res/values-zh/strings.xml @@ -0,0 +1,2 @@ + + From f6d678cbe50ac2b68264ba62e87c2978765e78e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 7 Sep 2014 19:23:04 +0200 Subject: [PATCH 57/81] Fix nullpointer with new OpenPgpSignatureResult version --- src/org/openintents/openpgp/OpenPgpSignatureResult.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/org/openintents/openpgp/OpenPgpSignatureResult.java b/src/org/openintents/openpgp/OpenPgpSignatureResult.java index 3b78abcf8..8ce4d3c06 100644 --- a/src/org/openintents/openpgp/OpenPgpSignatureResult.java +++ b/src/org/openintents/openpgp/OpenPgpSignatureResult.java @@ -156,6 +156,7 @@ public class OpenPgpSignatureResult implements Parcelable { vr.signatureOnly = source.readByte() == 1; vr.primaryUserId = source.readString(); vr.keyId = source.readLong(); + vr.userIds = new ArrayList(); source.readStringList(vr.userIds); // skip over all fields added in future versions of this parcel From 2cebac6a23e55627131947ba4d515c781fb5d31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Sun, 7 Sep 2014 20:07:13 +0200 Subject: [PATCH 58/81] Fix for user ids in API --- src/org/openintents/openpgp/OpenPgpSignatureResult.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/openintents/openpgp/OpenPgpSignatureResult.java b/src/org/openintents/openpgp/OpenPgpSignatureResult.java index 8ce4d3c06..dbcd74b64 100644 --- a/src/org/openintents/openpgp/OpenPgpSignatureResult.java +++ b/src/org/openintents/openpgp/OpenPgpSignatureResult.java @@ -137,7 +137,7 @@ public class OpenPgpSignatureResult implements Parcelable { dest.writeString(primaryUserId); dest.writeLong(keyId); // version 2 - dest.writeList(userIds); + dest.writeStringList(userIds); // Go back and write the size int parcelableSize = dest.dataPosition() - startPosition; dest.setDataPosition(sizePosition); From 0be263d5d3effd2df5f976fa4a127017268749cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= Date: Mon, 8 Sep 2014 14:09:15 +0200 Subject: [PATCH 59/81] Work on Yubikey decryption --- src/org/openintents/openpgp/util/OpenPgpApi.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/org/openintents/openpgp/util/OpenPgpApi.java b/src/org/openintents/openpgp/util/OpenPgpApi.java index a7c2ad6f9..3e18ab0cf 100644 --- a/src/org/openintents/openpgp/util/OpenPgpApi.java +++ b/src/org/openintents/openpgp/util/OpenPgpApi.java @@ -174,6 +174,7 @@ public class OpenPgpApi { // internal NFC states public static final String EXTRA_NFC_SIGNED_HASH = "nfc_signed_hash"; public static final String EXTRA_NFC_SIG_CREATION_TIMESTAMP = "nfc_sig_creation_timestamp"; + public static final String EXTRA_NFC_DECRYPTED_SESSION_KEY = "nfc_decrypted_session_key"; // GET_KEY public static final String EXTRA_KEY_ID = "key_id"; From 64bdd7e731c11407bfe6b8b1ec6a254ffb989057 Mon Sep 17 00:00:00 2001 From: Sam Whited Date: Wed, 22 Oct 2014 12:38:44 -0400 Subject: [PATCH 60/81] Use Gradle build system --- .gitignore | 41 + .gitmodules | 9 + build.gradle | 16 + conversations/build.gradle | 28 + conversations/src/main/AndroidManifest.xml | 124 ++ .../java/eu/siacs/conversations/Config.java | 25 + .../siacs/conversations/crypto/OtrEngine.java | 231 ++ .../siacs/conversations/crypto/PgpEngine.java | 385 ++++ .../entities/AbstractEntity.java | 21 + .../siacs/conversations/entities/Account.java | 399 ++++ .../conversations/entities/Bookmark.java | 137 ++ .../siacs/conversations/entities/Contact.java | 367 ++++ .../conversations/entities/Conversation.java | 500 +++++ .../conversations/entities/Downloadable.java | 21 + .../entities/DownloadableFile.java | 154 ++ .../conversations/entities/ListItem.java | 7 + .../siacs/conversations/entities/Message.java | 478 ++++ .../conversations/entities/MucOptions.java | 369 ++++ .../conversations/entities/Presences.java | 76 + .../siacs/conversations/entities/Roster.java | 83 + .../generator/AbstractGenerator.java | 48 + .../conversations/generator/IqGenerator.java | 96 + .../generator/MessageGenerator.java | 178 ++ .../generator/PresenceGenerator.java | 57 + .../conversations/http/HttpConnection.java | 255 +++ .../http/HttpConnectionManager.java | 28 + .../conversations/parser/AbstractParser.java | 92 + .../siacs/conversations/parser/IqParser.java | 92 + .../conversations/parser/MessageParser.java | 517 +++++ .../conversations/parser/PresenceParser.java | 133 ++ .../persistance/DatabaseBackend.java | 335 +++ .../persistance/FileBackend.java | 480 ++++ .../persistance/OnPhoneContactsMerged.java | 5 + .../services/AbstractConnectionManager.java | 23 + .../conversations/services/AvatarService.java | 298 +++ .../conversations/services/EventReceiver.java | 24 + .../services/NotificationService.java | 237 ++ .../services/XmppConnectionService.java | 1927 +++++++++++++++++ .../ui/ChooseContactActivity.java | 145 ++ .../ui/ConferenceDetailsActivity.java | 280 +++ .../ui/ContactDetailsActivity.java | 436 ++++ .../ui/ConversationActivity.java | 947 ++++++++ .../ui/ConversationFragment.java | 781 +++++++ .../conversations/ui/EditAccountActivity.java | 423 ++++ .../siacs/conversations/ui/EditMessage.java | 39 + .../ui/ManageAccountActivity.java | 217 ++ .../ui/PublishProfilePictureActivity.java | 242 +++ .../conversations/ui/SettingsActivity.java | 74 + .../conversations/ui/SettingsFragment.java | 15 + .../conversations/ui/ShareWithActivity.java | 185 ++ .../ui/StartConversationActivity.java | 677 ++++++ .../eu/siacs/conversations/ui/UiCallback.java | 11 + .../siacs/conversations/ui/XmppActivity.java | 637 ++++++ .../ui/adapter/AccountAdapter.java | 102 + .../ui/adapter/ConversationAdapter.java | 135 ++ .../ui/adapter/KnownHostsAdapter.java | 74 + .../ui/adapter/ListItemAdapter.java | 44 + .../ui/adapter/MessageAdapter.java | 560 +++++ .../conversations/utils/CryptoHelper.java | 112 + .../siacs/conversations/utils/DNSHelper.java | 185 ++ .../conversations/utils/ExceptionHandler.java | 44 + .../conversations/utils/ExceptionHelper.java | 117 + .../utils/OnPhoneContactsLoadedListener.java | 9 + .../siacs/conversations/utils/PRNGFixes.java | 327 +++ .../conversations/utils/PhoneHelper.java | 95 + .../siacs/conversations/utils/UIHelper.java | 225 ++ .../siacs/conversations/utils/Validator.java | 14 + .../siacs/conversations/utils/XmlHelper.java | 12 + .../utils/zlib/ZLibInputStream.java | 54 + .../utils/zlib/ZLibOutputStream.java | 95 + .../eu/siacs/conversations/xml/Element.java | 148 ++ .../java/eu/siacs/conversations/xml/Tag.java | 104 + .../eu/siacs/conversations/xml/TagWriter.java | 114 + .../eu/siacs/conversations/xml/XmlReader.java | 141 ++ .../conversations/xmpp/OnBindListener.java | 7 + .../xmpp/OnContactStatusChanged.java | 7 + .../xmpp/OnIqPacketReceived.java | 8 + .../xmpp/OnMessageAcknowledged.java | 7 + .../xmpp/OnMessagePacketReceived.java | 8 + .../xmpp/OnPresencePacketReceived.java | 8 + .../conversations/xmpp/OnStatusChanged.java | 7 + .../conversations/xmpp/PacketReceived.java | 5 + .../conversations/xmpp/XmppConnection.java | 1130 ++++++++++ .../xmpp/jingle/JingleCandidate.java | 143 ++ .../xmpp/jingle/JingleConnection.java | 910 ++++++++ .../xmpp/jingle/JingleConnectionManager.java | 163 ++ .../xmpp/jingle/JingleInbandTransport.java | 191 ++ .../xmpp/jingle/JingleSocks5Transport.java | 212 ++ .../xmpp/jingle/JingleTransport.java | 13 + .../OnFileTransmissionStatusChanged.java | 9 + .../xmpp/jingle/OnJinglePacketReceived.java | 9 + .../xmpp/jingle/OnPrimaryCandidateFound.java | 6 + .../xmpp/jingle/OnTransportConnected.java | 7 + .../xmpp/jingle/stanzas/Content.java | 102 + .../xmpp/jingle/stanzas/JinglePacket.java | 95 + .../xmpp/jingle/stanzas/Reason.java | 13 + .../siacs/conversations/xmpp/pep/Avatar.java | 71 + .../xmpp/stanzas/AbstractStanza.java | 34 + .../conversations/xmpp/stanzas/IqPacket.java | 76 + .../xmpp/stanzas/MessagePacket.java | 66 + .../xmpp/stanzas/PresencePacket.java | 8 + .../xmpp/stanzas/csi/ActivePacket.java | 10 + .../xmpp/stanzas/csi/InactivePacket.java | 10 + .../xmpp/stanzas/streammgmt/AckPacket.java | 13 + .../xmpp/stanzas/streammgmt/EnablePacket.java | 13 + .../stanzas/streammgmt/RequestPacket.java | 12 + .../xmpp/stanzas/streammgmt/ResumePacket.java | 14 + .../res/drawable-hdpi/ic_action_add_group.png | Bin 0 -> 876 bytes .../drawable-hdpi/ic_action_add_person.png | Bin 0 -> 616 bytes .../main/res/drawable-hdpi/ic_action_chat.png | Bin 0 -> 295 bytes .../main/res/drawable-hdpi/ic_action_copy.png | Bin 0 -> 381 bytes .../res/drawable-hdpi/ic_action_discard.png | Bin 0 -> 450 bytes .../main/res/drawable-hdpi/ic_action_edit.png | Bin 0 -> 765 bytes .../res/drawable-hdpi/ic_action_edit_dark.png | Bin 0 -> 884 bytes .../res/drawable-hdpi/ic_action_group.png | Bin 0 -> 776 bytes .../main/res/drawable-hdpi/ic_action_new.png | Bin 0 -> 262 bytes .../ic_action_new_attachment.png | Bin 0 -> 587 bytes .../drawable-hdpi/ic_action_not_secure.png | Bin 0 -> 367 bytes .../res/drawable-hdpi/ic_action_refresh.png | Bin 0 -> 678 bytes .../res/drawable-hdpi/ic_action_remove.png | Bin 0 -> 448 bytes .../res/drawable-hdpi/ic_action_search.png | Bin 0 -> 650 bytes .../res/drawable-hdpi/ic_action_secure.png | Bin 0 -> 384 bytes .../drawable-hdpi/ic_action_send_now_away.png | Bin 0 -> 932 bytes .../drawable-hdpi/ic_action_send_now_dnd.png | Bin 0 -> 1135 bytes .../ic_action_send_now_offline.png | Bin 0 -> 767 bytes .../ic_action_send_now_online.png | Bin 0 -> 1095 bytes .../main/res/drawable-hdpi/ic_activity.png | Bin 0 -> 3040 bytes .../main/res/drawable-hdpi/ic_indicator.png | Bin 0 -> 684 bytes .../main/res/drawable-hdpi/ic_launcher.png | Bin 0 -> 4416 bytes .../res/drawable-hdpi/ic_notification.png | Bin 0 -> 1033 bytes .../src/main/res/drawable-hdpi/ic_profile.png | Bin 0 -> 999 bytes .../drawable-hdpi/ic_received_indicator.png | Bin 0 -> 686 bytes .../res/drawable-hdpi/ic_secure_indicator.png | Bin 0 -> 294 bytes .../tab_selected_conversations.9.png | Bin 0 -> 99 bytes .../tab_selected_focused_conversations.9.png | Bin 0 -> 99 bytes .../tab_selected_pressed_conversations.9.png | Bin 0 -> 105 bytes .../tab_unselected_conversations.9.png | Bin 0 -> 101 bytes ...tab_unselected_focused_conversations.9.png | Bin 0 -> 93 bytes ...tab_unselected_pressed_conversations.9.png | Bin 0 -> 100 bytes .../res/drawable-mdpi/ic_action_add_group.png | Bin 0 -> 634 bytes .../drawable-mdpi/ic_action_add_person.png | Bin 0 -> 469 bytes .../main/res/drawable-mdpi/ic_action_chat.png | Bin 0 -> 261 bytes .../main/res/drawable-mdpi/ic_action_copy.png | Bin 0 -> 288 bytes .../res/drawable-mdpi/ic_action_discard.png | Bin 0 -> 324 bytes .../main/res/drawable-mdpi/ic_action_edit.png | Bin 0 -> 522 bytes .../res/drawable-mdpi/ic_action_edit_dark.png | Bin 0 -> 587 bytes .../res/drawable-mdpi/ic_action_group.png | Bin 0 -> 546 bytes .../main/res/drawable-mdpi/ic_action_new.png | Bin 0 -> 185 bytes .../ic_action_new_attachment.png | Bin 0 -> 415 bytes .../drawable-mdpi/ic_action_not_secure.png | Bin 0 -> 298 bytes .../res/drawable-mdpi/ic_action_refresh.png | Bin 0 -> 507 bytes .../res/drawable-mdpi/ic_action_remove.png | Bin 0 -> 282 bytes .../res/drawable-mdpi/ic_action_search.png | Bin 0 -> 449 bytes .../res/drawable-mdpi/ic_action_secure.png | Bin 0 -> 304 bytes .../drawable-mdpi/ic_action_send_now_away.png | Bin 0 -> 650 bytes .../drawable-mdpi/ic_action_send_now_dnd.png | Bin 0 -> 784 bytes .../ic_action_send_now_offline.png | Bin 0 -> 535 bytes .../ic_action_send_now_online.png | Bin 0 -> 779 bytes .../main/res/drawable-mdpi/ic_activity.png | Bin 0 -> 1854 bytes .../main/res/drawable-mdpi/ic_indicator.png | Bin 0 -> 490 bytes .../main/res/drawable-mdpi/ic_launcher.png | Bin 0 -> 2726 bytes .../res/drawable-mdpi/ic_notification.png | Bin 0 -> 681 bytes .../src/main/res/drawable-mdpi/ic_profile.png | Bin 0 -> 622 bytes .../drawable-mdpi/ic_received_indicator.png | Bin 0 -> 447 bytes .../res/drawable-mdpi/ic_secure_indicator.png | Bin 0 -> 295 bytes .../tab_selected_conversations.9.png | Bin 0 -> 96 bytes .../tab_selected_focused_conversations.9.png | Bin 0 -> 96 bytes .../tab_selected_pressed_conversations.9.png | Bin 0 -> 102 bytes .../tab_unselected_conversations.9.png | Bin 0 -> 105 bytes ...tab_unselected_focused_conversations.9.png | Bin 0 -> 90 bytes ...tab_unselected_pressed_conversations.9.png | Bin 0 -> 97 bytes .../drawable-xhdpi/ic_action_add_group.png | Bin 0 -> 1122 bytes .../drawable-xhdpi/ic_action_add_person.png | Bin 0 -> 798 bytes .../res/drawable-xhdpi/ic_action_chat.png | Bin 0 -> 310 bytes .../res/drawable-xhdpi/ic_action_copy.png | Bin 0 -> 353 bytes .../res/drawable-xhdpi/ic_action_discard.png | Bin 0 -> 543 bytes .../res/drawable-xhdpi/ic_action_edit.png | Bin 0 -> 994 bytes .../drawable-xhdpi/ic_action_edit_dark.png | Bin 0 -> 1179 bytes .../res/drawable-xhdpi/ic_action_group.png | Bin 0 -> 1048 bytes .../main/res/drawable-xhdpi/ic_action_new.png | Bin 0 -> 234 bytes .../ic_action_new_attachment.png | Bin 0 -> 753 bytes .../drawable-xhdpi/ic_action_not_secure.png | Bin 0 -> 482 bytes .../res/drawable-xhdpi/ic_action_refresh.png | Bin 0 -> 901 bytes .../res/drawable-xhdpi/ic_action_remove.png | Bin 0 -> 513 bytes .../res/drawable-xhdpi/ic_action_search.png | Bin 0 -> 827 bytes .../res/drawable-xhdpi/ic_action_secure.png | Bin 0 -> 468 bytes .../ic_action_send_now_away.png | Bin 0 -> 1180 bytes .../drawable-xhdpi/ic_action_send_now_dnd.png | Bin 0 -> 1438 bytes .../ic_action_send_now_offline.png | Bin 0 -> 968 bytes .../ic_action_send_now_online.png | Bin 0 -> 1395 bytes .../main/res/drawable-xhdpi/ic_activity.png | Bin 0 -> 4349 bytes .../main/res/drawable-xhdpi/ic_indicator.png | Bin 0 -> 915 bytes .../main/res/drawable-xhdpi/ic_launcher.png | Bin 0 -> 6503 bytes .../res/drawable-xhdpi/ic_notification.png | Bin 0 -> 1407 bytes .../main/res/drawable-xhdpi/ic_profile.png | Bin 0 -> 1374 bytes .../drawable-xhdpi/ic_received_indicator.png | Bin 0 -> 855 bytes .../drawable-xhdpi/ic_secure_indicator.png | Bin 0 -> 410 bytes .../tab_selected_conversations.9.png | Bin 0 -> 104 bytes .../tab_selected_focused_conversations.9.png | Bin 0 -> 103 bytes .../tab_selected_pressed_conversations.9.png | Bin 0 -> 110 bytes .../tab_unselected_conversations.9.png | Bin 0 -> 112 bytes ...tab_unselected_focused_conversations.9.png | Bin 0 -> 93 bytes ...tab_unselected_pressed_conversations.9.png | Bin 0 -> 101 bytes .../drawable-xxhdpi/ic_action_add_group.png | Bin 0 -> 1643 bytes .../drawable-xxhdpi/ic_action_add_person.png | Bin 0 -> 1088 bytes .../res/drawable-xxhdpi/ic_action_chat.png | Bin 0 -> 383 bytes .../res/drawable-xxhdpi/ic_action_copy.png | Bin 0 -> 470 bytes .../res/drawable-xxhdpi/ic_action_discard.png | Bin 0 -> 765 bytes .../res/drawable-xxhdpi/ic_action_edit.png | Bin 0 -> 1458 bytes .../drawable-xxhdpi/ic_action_edit_dark.png | Bin 0 -> 1670 bytes .../res/drawable-xxhdpi/ic_action_group.png | Bin 0 -> 1475 bytes .../res/drawable-xxhdpi/ic_action_new.png | Bin 0 -> 288 bytes .../ic_action_new_attachment.png | Bin 0 -> 1048 bytes .../drawable-xxhdpi/ic_action_not_secure.png | Bin 0 -> 593 bytes .../res/drawable-xxhdpi/ic_action_refresh.png | Bin 0 -> 1274 bytes .../res/drawable-xxhdpi/ic_action_remove.png | Bin 0 -> 681 bytes .../res/drawable-xxhdpi/ic_action_search.png | Bin 0 -> 1152 bytes .../res/drawable-xxhdpi/ic_action_secure.png | Bin 0 -> 586 bytes .../ic_action_send_now_away.png | Bin 0 -> 1426 bytes .../ic_action_send_now_dnd.png | Bin 0 -> 1456 bytes .../ic_action_send_now_offline.png | Bin 0 -> 1433 bytes .../ic_action_send_now_online.png | Bin 0 -> 1458 bytes .../main/res/drawable-xxhdpi/ic_activity.png | Bin 0 -> 7209 bytes .../main/res/drawable-xxhdpi/ic_indicator.png | Bin 0 -> 1298 bytes .../main/res/drawable-xxhdpi/ic_launcher.png | Bin 0 -> 11054 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 2250 bytes .../main/res/drawable-xxhdpi/ic_profile.png | Bin 0 -> 2137 bytes .../drawable-xxhdpi/ic_received_indicator.png | Bin 0 -> 1236 bytes .../drawable-xxhdpi/ic_secure_indicator.png | Bin 0 -> 380 bytes .../tab_selected_conversations.9.png | Bin 0 -> 108 bytes .../tab_selected_focused_conversations.9.png | Bin 0 -> 108 bytes .../tab_selected_pressed_conversations.9.png | Bin 0 -> 114 bytes .../tab_unselected_conversations.9.png | Bin 0 -> 109 bytes ...tab_unselected_focused_conversations.9.png | Bin 0 -> 95 bytes ...tab_unselected_pressed_conversations.9.png | Bin 0 -> 102 bytes .../res/drawable/actionbar_tab_indicator.xml | 21 + .../res/drawable/es_slidingpane_shadow.xml | 12 + conversations/src/main/res/drawable/grey.xml | 7 + .../src/main/res/drawable/greybackground.xml | 6 + .../src/main/res/drawable/infocard_border.xml | 19 + .../src/main/res/drawable/message_border.xml | 15 + .../src/main/res/drawable/snackbar.xml | 14 + .../fragment_conversations_overview.xml | 30 + .../fragment_conversations_overview.xml | 30 + .../fragment_conversations_overview.xml | 30 + .../fragment_conversations_overview.xml | 32 + .../src/main/res/layout/account_row.xml | 43 + .../src/main/res/layout/actionview_search.xml | 19 + .../res/layout/activity_choose_contact.xml | 13 + .../res/layout/activity_contact_details.xml | 114 + .../main/res/layout/activity_edit_account.xml | 272 +++ .../main/res/layout/activity_muc_details.xml | 119 + .../activity_publish_profile_picture.xml | 106 + .../layout/activity_start_conversation.xml | 8 + conversations/src/main/res/layout/contact.xml | 51 + .../src/main/res/layout/contact_key.xml | 41 + .../main/res/layout/conversation_list_row.xml | 68 + .../main/res/layout/create_contact_dialog.xml | 39 + .../main/res/layout/dialog_clear_history.xml | 21 + .../src/main/res/layout/dialog_verify_otr.xml | 60 + .../main/res/layout/fragment_conversation.xml | 102 + .../fragment_conversations_overview.xml | 30 + .../res/layout/join_conference_dialog.xml | 47 + .../src/main/res/layout/manage_accounts.xml | 16 + .../src/main/res/layout/message_null.xml | 7 + .../src/main/res/layout/message_received.xml | 97 + .../src/main/res/layout/message_sent.xml | 108 + .../src/main/res/layout/message_status.xml | 22 + .../src/main/res/layout/quickedit.xml | 19 + .../src/main/res/layout/share_with.xml | 13 + .../src/main/res/menu/attachment_choices.xml | 15 + .../src/main/res/menu/choose_contact.xml | 11 + .../src/main/res/menu/conference_context.xml | 11 + .../src/main/res/menu/contact_context.xml | 14 + .../src/main/res/menu/contact_details.xml | 27 + .../src/main/res/menu/conversations.xml | 63 + .../src/main/res/menu/encryption_choices.xml | 16 + .../src/main/res/menu/manageaccounts.xml | 15 + .../main/res/menu/manageaccounts_context.xml | 21 + .../src/main/res/menu/muc_details.xml | 21 + .../src/main/res/menu/share_with.xml | 11 + .../src/main/res/menu/start_conversation.xml | 31 + .../src/main/res/values-ca/arrays.xml | 24 + .../src/main/res/values-ca/strings.xml | 83 + .../src/main/res/values-cs/arrays.xml | 39 + .../src/main/res/values-cs/strings.xml | 260 +++ .../src/main/res/values-de/arrays.xml | 31 + .../src/main/res/values-de/strings.xml | 269 +++ .../src/main/res/values-es/arrays.xml | 39 + .../src/main/res/values-es/strings.xml | 269 +++ .../src/main/res/values-eu/arrays.xml | 39 + .../src/main/res/values-eu/strings.xml | 276 +++ .../src/main/res/values-fr/arrays.xml | 24 + .../src/main/res/values-fr/strings.xml | 273 +++ .../src/main/res/values-gl/arrays.xml | 24 + .../src/main/res/values-gl/strings.xml | 130 ++ .../src/main/res/values-it/arrays.xml | 39 + .../src/main/res/values-it/strings.xml | 260 +++ .../src/main/res/values-iw/arrays.xml | 24 + .../src/main/res/values-iw/strings.xml | 224 ++ .../src/main/res/values-nl/arrays.xml | 24 + .../src/main/res/values-nl/strings.xml | 233 ++ .../src/main/res/values-ru/arrays.xml | 24 + .../src/main/res/values-ru/strings.xml | 260 +++ .../src/main/res/values-sv/arrays.xml | 24 + .../src/main/res/values-sv/strings.xml | 260 +++ .../src/main/res/values-zh-rCN/arrays.xml | 39 + .../src/main/res/values-zh-rCN/strings.xml | 260 +++ .../src/main/res/values-zh-rTW/arrays.xml | 39 + .../src/main/res/values-zh-rTW/strings.xml | 263 +++ conversations/src/main/res/values/arrays.xml | 39 + conversations/src/main/res/values/attrs.xml | 8 + conversations/src/main/res/values/colors.xml | 17 + conversations/src/main/res/values/strings.xml | 276 +++ conversations/src/main/res/values/styles.xml | 8 + conversations/src/main/res/values/themes.xml | 35 + .../src/main/res/xml/preferences.xml | 114 + gradlew | 164 ++ gradlew.bat | 90 + memorizingTrustManager | 1 + minidns | 1 + openpgpapilib | 1 + settings.gradle | 4 + 323 files changed, 25921 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 build.gradle create mode 100644 conversations/build.gradle create mode 100644 conversations/src/main/AndroidManifest.xml create mode 100644 conversations/src/main/java/eu/siacs/conversations/Config.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/crypto/OtrEngine.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/AbstractEntity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Account.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Bookmark.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Contact.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Conversation.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Downloadable.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/DownloadableFile.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/ListItem.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Message.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/MucOptions.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Presences.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/entities/Roster.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/generator/IqGenerator.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/generator/PresenceGenerator.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/http/HttpConnection.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/parser/AbstractParser.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/parser/IqParser.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/parser/MessageParser.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/parser/PresenceParser.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/persistance/FileBackend.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/persistance/OnPhoneContactsMerged.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/services/AbstractConnectionManager.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/services/AvatarService.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/services/EventReceiver.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/services/NotificationService.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ConversationActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/EditMessage.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ManageAccountActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/SettingsFragment.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/UiCallback.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/XmppActivity.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/adapter/KnownHostsAdapter.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/DNSHelper.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHandler.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHelper.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/PRNGFixes.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/UIHelper.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/Validator.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/XmlHelper.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibInputStream.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibOutputStream.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xml/Element.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xml/Tag.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xml/TagWriter.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xml/XmlReader.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnBindListener.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnContactStatusChanged.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnIqPacketReceived.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessageAcknowledged.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessagePacketReceived.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnPresencePacketReceived.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/OnStatusChanged.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/PacketReceived.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInbandTransport.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnFileTransmissionStatusChanged.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnJinglePacketReceived.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnTransportConnected.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractStanza.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/IqPacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/PresencePacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java create mode 100644 conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_add_group.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_add_person.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_chat.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_copy.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_discard.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_edit.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_edit_dark.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_group.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_new.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_new_attachment.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_not_secure.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_refresh.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_remove.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_search.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_secure.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_send_now_away.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_send_now_dnd.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_send_now_offline.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_action_send_now_online.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_activity.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_indicator.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_launcher.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_notification.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_profile.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_received_indicator.png create mode 100644 conversations/src/main/res/drawable-hdpi/ic_secure_indicator.png create mode 100644 conversations/src/main/res/drawable-hdpi/tab_selected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-hdpi/tab_selected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-hdpi/tab_selected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-hdpi/tab_unselected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-hdpi/tab_unselected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-hdpi/tab_unselected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_add_group.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_add_person.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_chat.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_copy.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_discard.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_edit.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_edit_dark.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_group.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_new.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_new_attachment.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_not_secure.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_refresh.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_remove.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_search.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_secure.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_send_now_away.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_send_now_dnd.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_send_now_offline.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_action_send_now_online.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_activity.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_indicator.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_launcher.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_notification.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_profile.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_received_indicator.png create mode 100644 conversations/src/main/res/drawable-mdpi/ic_secure_indicator.png create mode 100644 conversations/src/main/res/drawable-mdpi/tab_selected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-mdpi/tab_selected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-mdpi/tab_selected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-mdpi/tab_unselected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-mdpi/tab_unselected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-mdpi/tab_unselected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_add_group.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_add_person.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_chat.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_copy.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_discard.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_edit.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_edit_dark.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_group.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_new.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_new_attachment.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_not_secure.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_refresh.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_remove.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_search.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_secure.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_send_now_away.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_send_now_dnd.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_send_now_offline.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_action_send_now_online.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_activity.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_indicator.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_launcher.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_notification.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_profile.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_received_indicator.png create mode 100644 conversations/src/main/res/drawable-xhdpi/ic_secure_indicator.png create mode 100644 conversations/src/main/res/drawable-xhdpi/tab_selected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xhdpi/tab_selected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xhdpi/tab_selected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xhdpi/tab_unselected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xhdpi/tab_unselected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xhdpi/tab_unselected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_add_group.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_add_person.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_chat.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_copy.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_discard.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_edit.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_edit_dark.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_group.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_new.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_new_attachment.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_not_secure.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_refresh.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_remove.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_search.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_secure.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_away.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_dnd.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_offline.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_online.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_activity.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_indicator.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_launcher.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_notification.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_profile.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_received_indicator.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/ic_secure_indicator.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/tab_selected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/tab_selected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/tab_selected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/tab_unselected_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/tab_unselected_focused_conversations.9.png create mode 100644 conversations/src/main/res/drawable-xxhdpi/tab_unselected_pressed_conversations.9.png create mode 100644 conversations/src/main/res/drawable/actionbar_tab_indicator.xml create mode 100644 conversations/src/main/res/drawable/es_slidingpane_shadow.xml create mode 100644 conversations/src/main/res/drawable/grey.xml create mode 100644 conversations/src/main/res/drawable/greybackground.xml create mode 100644 conversations/src/main/res/drawable/infocard_border.xml create mode 100644 conversations/src/main/res/drawable/message_border.xml create mode 100644 conversations/src/main/res/drawable/snackbar.xml create mode 100644 conversations/src/main/res/layout-w360dp/fragment_conversations_overview.xml create mode 100644 conversations/src/main/res/layout-w384dp/fragment_conversations_overview.xml create mode 100644 conversations/src/main/res/layout-w600dp/fragment_conversations_overview.xml create mode 100644 conversations/src/main/res/layout-w960dp/fragment_conversations_overview.xml create mode 100644 conversations/src/main/res/layout/account_row.xml create mode 100644 conversations/src/main/res/layout/actionview_search.xml create mode 100644 conversations/src/main/res/layout/activity_choose_contact.xml create mode 100644 conversations/src/main/res/layout/activity_contact_details.xml create mode 100644 conversations/src/main/res/layout/activity_edit_account.xml create mode 100644 conversations/src/main/res/layout/activity_muc_details.xml create mode 100644 conversations/src/main/res/layout/activity_publish_profile_picture.xml create mode 100644 conversations/src/main/res/layout/activity_start_conversation.xml create mode 100644 conversations/src/main/res/layout/contact.xml create mode 100644 conversations/src/main/res/layout/contact_key.xml create mode 100644 conversations/src/main/res/layout/conversation_list_row.xml create mode 100644 conversations/src/main/res/layout/create_contact_dialog.xml create mode 100644 conversations/src/main/res/layout/dialog_clear_history.xml create mode 100644 conversations/src/main/res/layout/dialog_verify_otr.xml create mode 100644 conversations/src/main/res/layout/fragment_conversation.xml create mode 100644 conversations/src/main/res/layout/fragment_conversations_overview.xml create mode 100644 conversations/src/main/res/layout/join_conference_dialog.xml create mode 100644 conversations/src/main/res/layout/manage_accounts.xml create mode 100644 conversations/src/main/res/layout/message_null.xml create mode 100644 conversations/src/main/res/layout/message_received.xml create mode 100644 conversations/src/main/res/layout/message_sent.xml create mode 100644 conversations/src/main/res/layout/message_status.xml create mode 100644 conversations/src/main/res/layout/quickedit.xml create mode 100644 conversations/src/main/res/layout/share_with.xml create mode 100644 conversations/src/main/res/menu/attachment_choices.xml create mode 100644 conversations/src/main/res/menu/choose_contact.xml create mode 100644 conversations/src/main/res/menu/conference_context.xml create mode 100644 conversations/src/main/res/menu/contact_context.xml create mode 100644 conversations/src/main/res/menu/contact_details.xml create mode 100644 conversations/src/main/res/menu/conversations.xml create mode 100644 conversations/src/main/res/menu/encryption_choices.xml create mode 100644 conversations/src/main/res/menu/manageaccounts.xml create mode 100644 conversations/src/main/res/menu/manageaccounts_context.xml create mode 100644 conversations/src/main/res/menu/muc_details.xml create mode 100644 conversations/src/main/res/menu/share_with.xml create mode 100644 conversations/src/main/res/menu/start_conversation.xml create mode 100644 conversations/src/main/res/values-ca/arrays.xml create mode 100644 conversations/src/main/res/values-ca/strings.xml create mode 100644 conversations/src/main/res/values-cs/arrays.xml create mode 100644 conversations/src/main/res/values-cs/strings.xml create mode 100644 conversations/src/main/res/values-de/arrays.xml create mode 100644 conversations/src/main/res/values-de/strings.xml create mode 100644 conversations/src/main/res/values-es/arrays.xml create mode 100644 conversations/src/main/res/values-es/strings.xml create mode 100644 conversations/src/main/res/values-eu/arrays.xml create mode 100644 conversations/src/main/res/values-eu/strings.xml create mode 100644 conversations/src/main/res/values-fr/arrays.xml create mode 100644 conversations/src/main/res/values-fr/strings.xml create mode 100644 conversations/src/main/res/values-gl/arrays.xml create mode 100644 conversations/src/main/res/values-gl/strings.xml create mode 100644 conversations/src/main/res/values-it/arrays.xml create mode 100644 conversations/src/main/res/values-it/strings.xml create mode 100644 conversations/src/main/res/values-iw/arrays.xml create mode 100644 conversations/src/main/res/values-iw/strings.xml create mode 100644 conversations/src/main/res/values-nl/arrays.xml create mode 100644 conversations/src/main/res/values-nl/strings.xml create mode 100644 conversations/src/main/res/values-ru/arrays.xml create mode 100644 conversations/src/main/res/values-ru/strings.xml create mode 100644 conversations/src/main/res/values-sv/arrays.xml create mode 100644 conversations/src/main/res/values-sv/strings.xml create mode 100644 conversations/src/main/res/values-zh-rCN/arrays.xml create mode 100644 conversations/src/main/res/values-zh-rCN/strings.xml create mode 100644 conversations/src/main/res/values-zh-rTW/arrays.xml create mode 100644 conversations/src/main/res/values-zh-rTW/strings.xml create mode 100644 conversations/src/main/res/values/arrays.xml create mode 100644 conversations/src/main/res/values/attrs.xml create mode 100644 conversations/src/main/res/values/colors.xml create mode 100644 conversations/src/main/res/values/strings.xml create mode 100644 conversations/src/main/res/values/styles.xml create mode 100644 conversations/src/main/res/values/themes.xml create mode 100644 conversations/src/main/res/xml/preferences.xml create mode 100755 gradlew create mode 100644 gradlew.bat create mode 160000 memorizingTrustManager create mode 160000 minidns create mode 160000 openpgpapilib create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e7f0d7fe6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +.classpath +*.swp +.settings + +# https://github.com/github/gitignore/blob/master/Gradle.gitignore +.gradle/ +gradle/ +build/ +# Ignore Gradle GUI config +gradle-app.setting + +# https://github.com/github/gitignore/blob/master/Android.gitignore +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +*.iml +.idea + +import-summary.txt + +*.jar diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e92902458 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "minidns"] + path = minidns + url = https://github.com/rtreffer/minidns.git +[submodule "openpgpapilib"] + path = openpgpapilib + url = https://github.com/open-keychain/openpgp-api-lib.git +[submodule "memorizingTrustManager"] + path = memorizingTrustManager + url = https://github.com/iNPUTmice/MemorizingTrustManager.git diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..8942ba1c2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,16 @@ +// Top-level build file where you can add configuration options common to all +// sub-projects/modules. +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:0.12.2' + } +} + +allprojects { + repositories { + jcenter() + } +} diff --git a/conversations/build.gradle b/conversations/build.gradle new file mode 100644 index 000000000..08060e59c --- /dev/null +++ b/conversations/build.gradle @@ -0,0 +1,28 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 19 + buildToolsVersion "20.0.0" + + defaultConfig { + applicationId "eu.siacs.conversations" + minSdkVersion 14 + targetSdkVersion 19 + } + + buildTypes { + release { + runProguard false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + } + } +} + +dependencies { + compile project(':minidns') + compile project(':openpgpapilib') + compile project(':memorizingTrustManager') + compile files('libs/android-support-v13.jar') + compile files('libs/bcprov-jdk15on-150.jar') + compile files('libs/otr4j-0.10.jar') +} diff --git a/conversations/src/main/AndroidManifest.xml b/conversations/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a66de8ba6 --- /dev/null +++ b/conversations/src/main/AndroidManifest.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/conversations/src/main/java/eu/siacs/conversations/Config.java b/conversations/src/main/java/eu/siacs/conversations/Config.java new file mode 100644 index 000000000..1725eca69 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/Config.java @@ -0,0 +1,25 @@ +package eu.siacs.conversations; + +import android.graphics.Bitmap; + +public final class Config { + + public static final String LOGTAG = "conversations"; + + public static final int PING_MAX_INTERVAL = 300; + public static final int PING_MIN_INTERVAL = 30; + public static final int PING_TIMEOUT = 10; + public static final int CONNECT_TIMEOUT = 90; + public static final int CARBON_GRACE_PERIOD = 60; + + public static final int AVATAR_SIZE = 192; + public static final Bitmap.CompressFormat AVATAR_FORMAT = Bitmap.CompressFormat.WEBP; + + public static final int MESSAGE_MERGE_WINDOW = 20; + + public static final boolean PARSE_EMOTICONS = false; + + private Config() { + + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/crypto/OtrEngine.java b/conversations/src/main/java/eu/siacs/conversations/crypto/OtrEngine.java new file mode 100644 index 000000000..e0bd0e793 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/crypto/OtrEngine.java @@ -0,0 +1,231 @@ +package eu.siacs.conversations.crypto; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.DSAPrivateKeySpec; +import java.security.spec.DSAPublicKeySpec; +import java.security.spec.InvalidKeySpecException; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.util.Log; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; + +import net.java.otr4j.OtrEngineHost; +import net.java.otr4j.OtrException; +import net.java.otr4j.OtrPolicy; +import net.java.otr4j.OtrPolicyImpl; +import net.java.otr4j.session.InstanceTag; +import net.java.otr4j.session.SessionID; + +public class OtrEngine implements OtrEngineHost { + + private Account account; + private OtrPolicy otrPolicy; + private KeyPair keyPair; + private XmppConnectionService mXmppConnectionService; + + public OtrEngine(XmppConnectionService service, Account account) { + this.account = account; + this.otrPolicy = new OtrPolicyImpl(); + this.otrPolicy.setAllowV1(false); + this.otrPolicy.setAllowV2(true); + this.otrPolicy.setAllowV3(true); + this.keyPair = loadKey(account.getKeys()); + this.mXmppConnectionService = service; + } + + private KeyPair loadKey(JSONObject keys) { + if (keys == null) { + return null; + } + try { + BigInteger x = new BigInteger(keys.getString("otr_x"), 16); + BigInteger y = new BigInteger(keys.getString("otr_y"), 16); + BigInteger p = new BigInteger(keys.getString("otr_p"), 16); + BigInteger q = new BigInteger(keys.getString("otr_q"), 16); + BigInteger g = new BigInteger(keys.getString("otr_g"), 16); + KeyFactory keyFactory = KeyFactory.getInstance("DSA"); + DSAPublicKeySpec pubKeySpec = new DSAPublicKeySpec(y, p, q, g); + DSAPrivateKeySpec privateKeySpec = new DSAPrivateKeySpec(x, p, q, g); + PublicKey publicKey = keyFactory.generatePublic(pubKeySpec); + PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec); + return new KeyPair(publicKey, privateKey); + } catch (JSONException e) { + return null; + } catch (NoSuchAlgorithmException e) { + return null; + } catch (InvalidKeySpecException e) { + return null; + } + } + + private void saveKey() { + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + KeyFactory keyFactory; + try { + keyFactory = KeyFactory.getInstance("DSA"); + DSAPrivateKeySpec privateKeySpec = keyFactory.getKeySpec( + privateKey, DSAPrivateKeySpec.class); + DSAPublicKeySpec publicKeySpec = keyFactory.getKeySpec(publicKey, + DSAPublicKeySpec.class); + this.account.setKey("otr_x", privateKeySpec.getX().toString(16)); + this.account.setKey("otr_g", privateKeySpec.getG().toString(16)); + this.account.setKey("otr_p", privateKeySpec.getP().toString(16)); + this.account.setKey("otr_q", privateKeySpec.getQ().toString(16)); + this.account.setKey("otr_y", publicKeySpec.getY().toString(16)); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } catch (InvalidKeySpecException e) { + e.printStackTrace(); + } + + } + + @Override + public void askForSecret(SessionID arg0, InstanceTag arg1, String arg2) { + // TODO Auto-generated method stub + + } + + @Override + public void finishedSessionMessage(SessionID arg0, String arg1) + throws OtrException { + + } + + @Override + public String getFallbackMessage(SessionID arg0) { + return "I would like to start a private (OTR encrypted) conversation but your client doesn’t seem to support that"; + } + + @Override + public byte[] getLocalFingerprintRaw(SessionID arg0) { + // TODO Auto-generated method stub + return null; + } + + public PublicKey getPublicKey() { + if (this.keyPair == null) { + return null; + } + return this.keyPair.getPublic(); + } + + @Override + public KeyPair getLocalKeyPair(SessionID arg0) throws OtrException { + if (this.keyPair == null) { + KeyPairGenerator kg; + try { + kg = KeyPairGenerator.getInstance("DSA"); + this.keyPair = kg.genKeyPair(); + this.saveKey(); + mXmppConnectionService.databaseBackend.updateAccount(account); + } catch (NoSuchAlgorithmException e) { + Log.d(Config.LOGTAG, + "error generating key pair " + e.getMessage()); + } + } + return this.keyPair; + } + + @Override + public String getReplyForUnreadableMessage(SessionID arg0) { + // TODO Auto-generated method stub + return null; + } + + @Override + public OtrPolicy getSessionPolicy(SessionID arg0) { + return otrPolicy; + } + + @Override + public void injectMessage(SessionID session, String body) + throws OtrException { + MessagePacket packet = new MessagePacket(); + packet.setFrom(account.getFullJid()); + if (session.getUserID().isEmpty()) { + packet.setTo(session.getAccountID()); + } else { + packet.setTo(session.getAccountID() + "/" + session.getUserID()); + } + packet.setBody(body); + packet.addChild("private", "urn:xmpp:carbons:2"); + packet.addChild("no-copy", "urn:xmpp:hints"); + packet.setType(MessagePacket.TYPE_CHAT); + account.getXmppConnection().sendMessagePacket(packet); + } + + @Override + public void messageFromAnotherInstanceReceived(SessionID id) { + Log.d(Config.LOGTAG, + "unreadable message received from " + id.getAccountID()); + } + + @Override + public void multipleInstancesDetected(SessionID arg0) { + // TODO Auto-generated method stub + + } + + @Override + public void requireEncryptedMessage(SessionID arg0, String arg1) + throws OtrException { + // TODO Auto-generated method stub + + } + + @Override + public void showError(SessionID arg0, String arg1) throws OtrException { + // TODO Auto-generated method stub + + } + + @Override + public void smpAborted(SessionID arg0) throws OtrException { + // TODO Auto-generated method stub + + } + + @Override + public void smpError(SessionID arg0, int arg1, boolean arg2) + throws OtrException { + throw new OtrException(new Exception("smp error")); + } + + @Override + public void unencryptedMessageReceived(SessionID arg0, String arg1) + throws OtrException { + throw new OtrException(new Exception("unencrypted message received")); + } + + @Override + public void unreadableMessageReceived(SessionID arg0) throws OtrException { + throw new OtrException(new Exception("unreadable message received")); + } + + @Override + public void unverify(SessionID arg0, String arg1) { + // TODO Auto-generated method stub + + } + + @Override + public void verify(SessionID arg0, String arg1, boolean arg2) { + // TODO Auto-generated method stub + + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java b/conversations/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java new file mode 100644 index 000000000..2696c7d2a --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/crypto/PgpEngine.java @@ -0,0 +1,385 @@ +package eu.siacs.conversations.crypto; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.openintents.openpgp.OpenPgpError; +import org.openintents.openpgp.OpenPgpSignatureResult; +import org.openintents.openpgp.util.OpenPgpApi; +import org.openintents.openpgp.util.OpenPgpApi.IOpenPgpCallback; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.UiCallback; +import android.app.PendingIntent; +import android.content.Intent; +import android.graphics.BitmapFactory; +import android.util.Log; + +public class PgpEngine { + private OpenPgpApi api; + private XmppConnectionService mXmppConnectionService; + + public PgpEngine(OpenPgpApi api, XmppConnectionService service) { + this.api = api; + this.mXmppConnectionService = service; + } + + public void decrypt(final Message message, + final UiCallback callback) { + Log.d(Config.LOGTAG, "decrypting message " + message.getUuid()); + Intent params = new Intent(); + params.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY); + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, message + .getConversation().getAccount().getJid()); + if (message.getType() == Message.TYPE_TEXT) { + InputStream is = new ByteArrayInputStream(message.getBody() + .getBytes()); + final OutputStream os = new ByteArrayOutputStream(); + api.executeApiAsync(params, is, os, new IOpenPgpCallback() { + + @Override + public void onReturn(Intent result) { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, + OpenPgpApi.RESULT_CODE_ERROR)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + try { + os.flush(); + if (message.getEncryption() == Message.ENCRYPTION_PGP) { + message.setBody(os.toString()); + message.setEncryption(Message.ENCRYPTION_DECRYPTED); + callback.success(message); + } + } catch (IOException e) { + callback.error(R.string.openpgp_error, message); + return; + } + + return; + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + callback.userInputRequried((PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT), + message); + return; + case OpenPgpApi.RESULT_CODE_ERROR: + OpenPgpError error = result + .getParcelableExtra(OpenPgpApi.RESULT_ERROR); + Log.d(Config.LOGTAG, + "openpgp error: " + error.getMessage()); + callback.error(R.string.openpgp_error, message); + return; + default: + return; + } + } + }); + } else if (message.getType() == Message.TYPE_IMAGE) { + try { + final DownloadableFile inputFile = this.mXmppConnectionService + .getFileBackend().getFile(message, false); + final DownloadableFile outputFile = this.mXmppConnectionService + .getFileBackend().getFile(message, true); + outputFile.createNewFile(); + InputStream is = new FileInputStream(inputFile); + OutputStream os = new FileOutputStream(outputFile); + api.executeApiAsync(params, is, os, new IOpenPgpCallback() { + + @Override + public void onReturn(Intent result) { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, + OpenPgpApi.RESULT_CODE_ERROR)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile( + outputFile.getAbsolutePath(), options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + message.setBody(Long.toString(outputFile.getSize()) + + ',' + imageWidth + ',' + imageHeight); + message.setEncryption(Message.ENCRYPTION_DECRYPTED); + PgpEngine.this.mXmppConnectionService + .updateMessage(message); + ; + callback.success(message); + return; + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + callback.userInputRequried( + (PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT), + message); + return; + case OpenPgpApi.RESULT_CODE_ERROR: + callback.error(R.string.openpgp_error, message); + return; + default: + return; + } + } + }); + } catch (FileNotFoundException e) { + callback.error(R.string.error_decrypting_file, message); + } catch (IOException e) { + callback.error(R.string.error_decrypting_file, message); + } + + } + } + + public void encrypt(final Message message, + final UiCallback callback) { + + Intent params = new Intent(); + params.setAction(OpenPgpApi.ACTION_ENCRYPT); + if (message.getConversation().getMode() == Conversation.MODE_SINGLE) { + long[] keys = { message.getConversation().getContact() + .getPgpKeyId() }; + params.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keys); + } else { + params.putExtra(OpenPgpApi.EXTRA_KEY_IDS, message.getConversation() + .getMucOptions().getPgpKeyIds()); + } + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, message + .getConversation().getAccount().getJid()); + + if (message.getType() == Message.TYPE_TEXT) { + params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true); + + InputStream is = new ByteArrayInputStream(message.getBody() + .getBytes()); + final OutputStream os = new ByteArrayOutputStream(); + api.executeApiAsync(params, is, os, new IOpenPgpCallback() { + + @Override + public void onReturn(Intent result) { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, + OpenPgpApi.RESULT_CODE_ERROR)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + try { + os.flush(); + StringBuilder encryptedMessageBody = new StringBuilder(); + String[] lines = os.toString().split("\n"); + for (int i = 2; i < lines.length - 1; ++i) { + if (!lines[i].contains("Version")) { + encryptedMessageBody.append(lines[i].trim()); + } + } + message.setEncryptedBody(encryptedMessageBody + .toString()); + callback.success(message); + } catch (IOException e) { + callback.error(R.string.openpgp_error, message); + } + + break; + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + callback.userInputRequried((PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT), + message); + break; + case OpenPgpApi.RESULT_CODE_ERROR: + callback.error(R.string.openpgp_error, message); + break; + } + } + }); + } else if (message.getType() == Message.TYPE_IMAGE) { + try { + DownloadableFile inputFile = this.mXmppConnectionService + .getFileBackend().getFile(message, true); + DownloadableFile outputFile = this.mXmppConnectionService + .getFileBackend().getFile(message, false); + outputFile.createNewFile(); + InputStream is = new FileInputStream(inputFile); + OutputStream os = new FileOutputStream(outputFile); + api.executeApiAsync(params, is, os, new IOpenPgpCallback() { + + @Override + public void onReturn(Intent result) { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, + OpenPgpApi.RESULT_CODE_ERROR)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + callback.success(message); + break; + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + callback.userInputRequried( + (PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT), + message); + break; + case OpenPgpApi.RESULT_CODE_ERROR: + callback.error(R.string.openpgp_error, message); + break; + } + } + }); + } catch (FileNotFoundException e) { + Log.d(Config.LOGTAG, "file not found: " + e.getMessage()); + } catch (IOException e) { + Log.d(Config.LOGTAG, "io exception during file encrypt"); + } + } + } + + public long fetchKeyId(Account account, String status, String signature) { + if ((signature == null) || (api == null)) { + return 0; + } + if (status == null) { + status = ""; + } + StringBuilder pgpSig = new StringBuilder(); + pgpSig.append("-----BEGIN PGP SIGNED MESSAGE-----"); + pgpSig.append('\n'); + pgpSig.append('\n'); + pgpSig.append(status); + pgpSig.append('\n'); + pgpSig.append("-----BEGIN PGP SIGNATURE-----"); + pgpSig.append('\n'); + pgpSig.append('\n'); + pgpSig.append(signature.replace("\n", "").trim()); + pgpSig.append('\n'); + pgpSig.append("-----END PGP SIGNATURE-----"); + Intent params = new Intent(); + params.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY); + params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true); + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, account.getJid()); + InputStream is = new ByteArrayInputStream(pgpSig.toString().getBytes()); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + Intent result = api.executeApi(params, is, os); + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, + OpenPgpApi.RESULT_CODE_ERROR)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + OpenPgpSignatureResult sigResult = result + .getParcelableExtra(OpenPgpApi.RESULT_SIGNATURE); + if (sigResult != null) { + return sigResult.getKeyId(); + } else { + return 0; + } + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + return 0; + case OpenPgpApi.RESULT_CODE_ERROR: + Log.d(Config.LOGTAG, + "openpgp error: " + + ((OpenPgpError) result + .getParcelableExtra(OpenPgpApi.RESULT_ERROR)) + .getMessage()); + return 0; + } + return 0; + } + + public void generateSignature(final Account account, String status, + final UiCallback callback) { + Intent params = new Intent(); + params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true); + params.setAction(OpenPgpApi.ACTION_SIGN); + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, account.getJid()); + InputStream is = new ByteArrayInputStream(status.getBytes()); + final OutputStream os = new ByteArrayOutputStream(); + api.executeApiAsync(params, is, os, new IOpenPgpCallback() { + + @Override + public void onReturn(Intent result) { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, 0)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + StringBuilder signatureBuilder = new StringBuilder(); + try { + os.flush(); + String[] lines = os.toString().split("\n"); + boolean sig = false; + for (String line : lines) { + if (sig) { + if (line.contains("END PGP SIGNATURE")) { + sig = false; + } else { + if (!line.contains("Version")) { + signatureBuilder.append(line.trim()); + } + } + } + if (line.contains("BEGIN PGP SIGNATURE")) { + sig = true; + } + } + } catch (IOException e) { + callback.error(R.string.openpgp_error, account); + return; + } + account.setKey("pgp_signature", signatureBuilder.toString()); + callback.success(account); + return; + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + callback.userInputRequried((PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT), + account); + return; + case OpenPgpApi.RESULT_CODE_ERROR: + callback.error(R.string.openpgp_error, account); + return; + } + } + }); + } + + public void hasKey(final Contact contact, final UiCallback callback) { + Intent params = new Intent(); + params.setAction(OpenPgpApi.ACTION_GET_KEY); + params.putExtra(OpenPgpApi.EXTRA_KEY_ID, contact.getPgpKeyId()); + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, contact.getAccount() + .getJid()); + api.executeApiAsync(params, null, null, new IOpenPgpCallback() { + + @Override + public void onReturn(Intent result) { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, 0)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + callback.success(contact); + return; + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + callback.userInputRequried((PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT), + contact); + return; + case OpenPgpApi.RESULT_CODE_ERROR: + callback.error(R.string.openpgp_error, contact); + return; + } + } + }); + } + + public PendingIntent getIntentForKey(Contact contact) { + Intent params = new Intent(); + params.setAction(OpenPgpApi.ACTION_GET_KEY); + params.putExtra(OpenPgpApi.EXTRA_KEY_ID, contact.getPgpKeyId()); + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, contact.getAccount() + .getJid()); + Intent result = api.executeApi(params, null, null); + return (PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT); + } + + public PendingIntent getIntentForKey(Account account, long pgpKeyId) { + Intent params = new Intent(); + params.setAction(OpenPgpApi.ACTION_GET_KEY); + params.putExtra(OpenPgpApi.EXTRA_KEY_ID, pgpKeyId); + params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, account.getJid()); + Intent result = api.executeApi(params, null, null); + return (PendingIntent) result + .getParcelableExtra(OpenPgpApi.RESULT_INTENT); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/AbstractEntity.java b/conversations/src/main/java/eu/siacs/conversations/entities/AbstractEntity.java new file mode 100644 index 000000000..92b8a7298 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/AbstractEntity.java @@ -0,0 +1,21 @@ +package eu.siacs.conversations.entities; + +import android.content.ContentValues; + +public abstract class AbstractEntity { + + public static final String UUID = "uuid"; + + protected String uuid; + + public String getUuid() { + return this.uuid; + } + + public abstract ContentValues getContentValues(); + + public boolean equals(AbstractEntity entity) { + return this.getUuid().equals(entity.getUuid()); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Account.java b/conversations/src/main/java/eu/siacs/conversations/entities/Account.java new file mode 100644 index 000000000..80a9d62f9 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Account.java @@ -0,0 +1,399 @@ +package eu.siacs.conversations.entities; + +import java.security.interfaces.DSAPublicKey; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +import net.java.otr4j.crypto.OtrCryptoEngineImpl; +import net.java.otr4j.crypto.OtrCryptoException; + +import org.json.JSONException; +import org.json.JSONObject; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.OtrEngine; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xmpp.XmppConnection; +import android.content.ContentValues; +import android.database.Cursor; +import android.os.SystemClock; + +public class Account extends AbstractEntity { + + public static final String TABLENAME = "accounts"; + + public static final String USERNAME = "username"; + public static final String SERVER = "server"; + public static final String PASSWORD = "password"; + public static final String OPTIONS = "options"; + public static final String ROSTERVERSION = "rosterversion"; + public static final String KEYS = "keys"; + public static final String AVATAR = "avatar"; + + public static final int OPTION_USETLS = 0; + public static final int OPTION_DISABLED = 1; + public static final int OPTION_REGISTER = 2; + public static final int OPTION_USECOMPRESSION = 3; + + public static final int STATUS_CONNECTING = 0; + public static final int STATUS_DISABLED = -2; + public static final int STATUS_OFFLINE = -1; + public static final int STATUS_ONLINE = 1; + public static final int STATUS_NO_INTERNET = 2; + public static final int STATUS_UNAUTHORIZED = 3; + public static final int STATUS_SERVER_NOT_FOUND = 5; + + public static final int STATUS_REGISTRATION_FAILED = 7; + public static final int STATUS_REGISTRATION_CONFLICT = 8; + public static final int STATUS_REGISTRATION_SUCCESSFULL = 9; + public static final int STATUS_REGISTRATION_NOT_SUPPORTED = 10; + + protected String username; + protected String server; + protected String password; + protected int options = 0; + protected String rosterVersion; + protected String resource = "mobile"; + protected int status = -1; + protected JSONObject keys = new JSONObject(); + protected String avatar; + + protected boolean online = false; + + private OtrEngine otrEngine = null; + private XmppConnection xmppConnection = null; + private Presences presences = new Presences(); + private long mEndGracePeriod = 0L; + private String otrFingerprint; + private Roster roster = null; + + private List bookmarks = new CopyOnWriteArrayList(); + public List pendingConferenceJoins = new CopyOnWriteArrayList(); + public List pendingConferenceLeaves = new CopyOnWriteArrayList(); + + public Account() { + this.uuid = "0"; + } + + public Account(String username, String server, String password) { + this(java.util.UUID.randomUUID().toString(), username, server, + password, 0, null, "", null); + } + + public Account(String uuid, String username, String server, + String password, int options, String rosterVersion, String keys, + String avatar) { + this.uuid = uuid; + this.username = username; + this.server = server; + this.password = password; + this.options = options; + this.rosterVersion = rosterVersion; + try { + this.keys = new JSONObject(keys); + } catch (JSONException e) { + + } + this.avatar = avatar; + } + + public boolean isOptionSet(int option) { + return ((options & (1 << option)) != 0); + } + + public void setOption(int option, boolean value) { + if (value) { + this.options |= 1 << option; + } else { + this.options &= ~(1 << option); + } + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getServer() { + return server; + } + + public void setServer(String server) { + this.server = server; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setStatus(int status) { + this.status = status; + } + + public int getStatus() { + if (isOptionSet(OPTION_DISABLED)) { + return STATUS_DISABLED; + } else { + return this.status; + } + } + + public boolean errorStatus() { + int s = getStatus(); + return (s == STATUS_REGISTRATION_FAILED + || s == STATUS_REGISTRATION_CONFLICT + || s == STATUS_REGISTRATION_NOT_SUPPORTED + || s == STATUS_SERVER_NOT_FOUND || s == STATUS_UNAUTHORIZED); + } + + public boolean hasErrorStatus() { + if (getXmppConnection() == null) { + return false; + } else { + return getStatus() > STATUS_NO_INTERNET + && (getXmppConnection().getAttempt() >= 2); + } + } + + public void setResource(String resource) { + this.resource = resource; + } + + public String getResource() { + return this.resource; + } + + public String getJid() { + return username.toLowerCase(Locale.getDefault()) + "@" + + server.toLowerCase(Locale.getDefault()); + } + + public JSONObject getKeys() { + return keys; + } + + public String getSSLFingerprint() { + if (keys.has("ssl_cert")) { + try { + return keys.getString("ssl_cert"); + } catch (JSONException e) { + return null; + } + } else { + return null; + } + } + + public void setSSLCertFingerprint(String fingerprint) { + this.setKey("ssl_cert", fingerprint); + } + + public boolean setKey(String keyName, String keyValue) { + try { + this.keys.put(keyName, keyValue); + return true; + } catch (JSONException e) { + return false; + } + } + + @Override + public ContentValues getContentValues() { + ContentValues values = new ContentValues(); + values.put(UUID, uuid); + values.put(USERNAME, username); + values.put(SERVER, server); + values.put(PASSWORD, password); + values.put(OPTIONS, options); + values.put(KEYS, this.keys.toString()); + values.put(ROSTERVERSION, rosterVersion); + values.put(AVATAR, avatar); + return values; + } + + public static Account fromCursor(Cursor cursor) { + return new Account(cursor.getString(cursor.getColumnIndex(UUID)), + cursor.getString(cursor.getColumnIndex(USERNAME)), + cursor.getString(cursor.getColumnIndex(SERVER)), + cursor.getString(cursor.getColumnIndex(PASSWORD)), + cursor.getInt(cursor.getColumnIndex(OPTIONS)), + cursor.getString(cursor.getColumnIndex(ROSTERVERSION)), + cursor.getString(cursor.getColumnIndex(KEYS)), + cursor.getString(cursor.getColumnIndex(AVATAR))); + } + + public OtrEngine getOtrEngine(XmppConnectionService context) { + if (otrEngine == null) { + otrEngine = new OtrEngine(context, this); + } + return this.otrEngine; + } + + public XmppConnection getXmppConnection() { + return this.xmppConnection; + } + + public void setXmppConnection(XmppConnection connection) { + this.xmppConnection = connection; + } + + public String getFullJid() { + return this.getJid() + "/" + this.resource; + } + + public String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + DSAPublicKey pubkey = (DSAPublicKey) this.otrEngine + .getPublicKey(); + if (pubkey == null) { + return null; + } + StringBuilder builder = new StringBuilder( + new OtrCryptoEngineImpl().getFingerprint(pubkey)); + builder.insert(8, " "); + builder.insert(17, " "); + builder.insert(26, " "); + builder.insert(35, " "); + this.otrFingerprint = builder.toString(); + } catch (OtrCryptoException e) { + + } + } + return this.otrFingerprint; + } + + public String getRosterVersion() { + if (this.rosterVersion == null) { + return ""; + } else { + return this.rosterVersion; + } + } + + public void setRosterVersion(String version) { + this.rosterVersion = version; + } + + public String getOtrFingerprint(XmppConnectionService service) { + this.getOtrEngine(service); + return this.getOtrFingerprint(); + } + + public void updatePresence(String resource, int status) { + this.presences.updatePresence(resource, status); + } + + public void removePresence(String resource) { + this.presences.removePresence(resource); + } + + public void clearPresences() { + this.presences = new Presences(); + } + + public int countPresences() { + return this.presences.size(); + } + + public String getPgpSignature() { + if (keys.has("pgp_signature")) { + try { + return keys.getString("pgp_signature"); + } catch (JSONException e) { + return null; + } + } else { + return null; + } + } + + public Roster getRoster() { + if (this.roster == null) { + this.roster = new Roster(this); + } + return this.roster; + } + + public void setBookmarks(List bookmarks) { + this.bookmarks = bookmarks; + } + + public List getBookmarks() { + return this.bookmarks; + } + + public boolean hasBookmarkFor(String conferenceJid) { + for (Bookmark bmark : this.bookmarks) { + if (bmark.getJid().equals(conferenceJid)) { + return true; + } + } + return false; + } + + public boolean setAvatar(String filename) { + if (this.avatar != null && this.avatar.equals(filename)) { + return false; + } else { + this.avatar = filename; + return true; + } + } + + public String getAvatar() { + return this.avatar; + } + + public int getReadableStatusId() { + switch (getStatus()) { + + case Account.STATUS_DISABLED: + return R.string.account_status_disabled; + case Account.STATUS_ONLINE: + return R.string.account_status_online; + case Account.STATUS_CONNECTING: + return R.string.account_status_connecting; + case Account.STATUS_OFFLINE: + return R.string.account_status_offline; + case Account.STATUS_UNAUTHORIZED: + return R.string.account_status_unauthorized; + case Account.STATUS_SERVER_NOT_FOUND: + return R.string.account_status_not_found; + case Account.STATUS_NO_INTERNET: + return R.string.account_status_no_internet; + case Account.STATUS_REGISTRATION_FAILED: + return R.string.account_status_regis_fail; + case Account.STATUS_REGISTRATION_CONFLICT: + return R.string.account_status_regis_conflict; + case Account.STATUS_REGISTRATION_SUCCESSFULL: + return R.string.account_status_regis_success; + case Account.STATUS_REGISTRATION_NOT_SUPPORTED: + return R.string.account_status_regis_not_sup; + default: + return R.string.account_status_unknown; + } + } + + public void activateGracePeriod() { + this.mEndGracePeriod = SystemClock.elapsedRealtime() + + (Config.CARBON_GRACE_PERIOD * 1000); + } + + public void deactivateGracePeriod() { + this.mEndGracePeriod = 0L; + } + + public boolean inGracePeriod() { + return SystemClock.elapsedRealtime() < this.mEndGracePeriod; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Bookmark.java b/conversations/src/main/java/eu/siacs/conversations/entities/Bookmark.java new file mode 100644 index 000000000..dd9e805c2 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Bookmark.java @@ -0,0 +1,137 @@ +package eu.siacs.conversations.entities; + +import java.util.Locale; + +import eu.siacs.conversations.xml.Element; + +public class Bookmark extends Element implements ListItem { + + private Account account; + private Conversation mJoinedConversation; + + public Bookmark(Account account, String jid) { + super("conference"); + this.setAttribute("jid", jid); + this.account = account; + } + + private Bookmark(Account account) { + super("conference"); + this.account = account; + } + + public static Bookmark parse(Element element, Account account) { + Bookmark bookmark = new Bookmark(account); + bookmark.setAttributes(element.getAttributes()); + bookmark.setChildren(element.getChildren()); + return bookmark; + } + + public void setAutojoin(boolean autojoin) { + if (autojoin) { + this.setAttribute("autojoin", "true"); + } else { + this.setAttribute("autojoin", "false"); + } + } + + public void setName(String name) { + this.name = name; + } + + public void setNick(String nick) { + Element element = this.findChild("nick"); + if (element == null) { + element = this.addChild("nick"); + } + element.setContent(nick); + } + + public void setPassword(String password) { + Element element = this.findChild("password"); + if (element != null) { + element.setContent(password); + } + } + + @Override + public int compareTo(ListItem another) { + return this.getDisplayName().compareToIgnoreCase( + another.getDisplayName()); + } + + @Override + public String getDisplayName() { + if (this.mJoinedConversation != null + && (this.mJoinedConversation.getMucOptions().getSubject() != null)) { + return this.mJoinedConversation.getMucOptions().getSubject(); + } else if (getName() != null) { + return getName(); + } else { + return this.getJid().split("@")[0]; + } + } + + @Override + public String getJid() { + String jid = this.getAttribute("jid"); + if (jid != null) { + return jid.toLowerCase(Locale.US); + } else { + return null; + } + } + + public String getNick() { + Element nick = this.findChild("nick"); + if (nick != null) { + return nick.getContent(); + } else { + return null; + } + } + + public boolean autojoin() { + String autojoin = this.getAttribute("autojoin"); + return (autojoin != null && (autojoin.equalsIgnoreCase("true") || autojoin + .equalsIgnoreCase("1"))); + } + + public String getPassword() { + Element password = this.findChild("password"); + if (password != null) { + return password.getContent(); + } else { + return null; + } + } + + public boolean match(String needle) { + return needle == null + || getJid().contains(needle.toLowerCase(Locale.US)) + || getDisplayName().toLowerCase(Locale.US).contains( + needle.toLowerCase(Locale.US)); + } + + public Account getAccount() { + return this.account; + } + + public void setConversation(Conversation conversation) { + this.mJoinedConversation = conversation; + } + + public Conversation getConversation() { + return this.mJoinedConversation; + } + + public String getName() { + return this.getAttribute("name"); + } + + public void unregisterConversation() { + if (this.mJoinedConversation != null) { + this.mJoinedConversation.deregisterWithBookmark(); + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Contact.java b/conversations/src/main/java/eu/siacs/conversations/entities/Contact.java new file mode 100644 index 000000000..60c31a424 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -0,0 +1,367 @@ +package eu.siacs.conversations.entities; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import eu.siacs.conversations.xml.Element; +import android.content.ContentValues; +import android.database.Cursor; + +public class Contact implements ListItem { + public static final String TABLENAME = "contacts"; + + public static final String SYSTEMNAME = "systemname"; + public static final String SERVERNAME = "servername"; + public static final String JID = "jid"; + public static final String OPTIONS = "options"; + public static final String SYSTEMACCOUNT = "systemaccount"; + public static final String PHOTOURI = "photouri"; + public static final String KEYS = "pgpkey"; + public static final String ACCOUNT = "accountUuid"; + public static final String AVATAR = "avatar"; + + protected String accountUuid; + protected String systemName; + protected String serverName; + protected String presenceName; + protected String jid; + protected int subscription = 0; + protected String systemAccount; + protected String photoUri; + protected String avatar; + protected JSONObject keys = new JSONObject(); + protected Presences presences = new Presences(); + + protected Account account; + + protected boolean inRoster = true; + + public Lastseen lastseen = new Lastseen(); + + public Contact(String account, String systemName, String serverName, + String jid, int subscription, String photoUri, + String systemAccount, String keys, String avatar) { + this.accountUuid = account; + this.systemName = systemName; + this.serverName = serverName; + this.jid = jid; + this.subscription = subscription; + this.photoUri = photoUri; + this.systemAccount = systemAccount; + if (keys == null) { + keys = ""; + } + try { + this.keys = new JSONObject(keys); + } catch (JSONException e) { + this.keys = new JSONObject(); + } + this.avatar = avatar; + } + + public Contact(String jid) { + this.jid = jid; + } + + public String getDisplayName() { + if (this.systemName != null) { + return this.systemName; + } else if (this.serverName != null) { + return this.serverName; + } else if (this.presenceName != null) { + return this.presenceName; + } else { + return this.jid.split("@")[0]; + } + } + + public String getProfilePhoto() { + return this.photoUri; + } + + public String getJid() { + return this.jid.toLowerCase(Locale.getDefault()); + } + + public boolean match(String needle) { + return needle == null + || jid.contains(needle.toLowerCase()) + || getDisplayName().toLowerCase() + .contains(needle.toLowerCase()); + } + + public ContentValues getContentValues() { + ContentValues values = new ContentValues(); + values.put(ACCOUNT, accountUuid); + values.put(SYSTEMNAME, systemName); + values.put(SERVERNAME, serverName); + values.put(JID, jid); + values.put(OPTIONS, subscription); + values.put(SYSTEMACCOUNT, systemAccount); + values.put(PHOTOURI, photoUri); + values.put(KEYS, keys.toString()); + values.put(AVATAR, avatar); + return values; + } + + public static Contact fromCursor(Cursor cursor) { + return new Contact(cursor.getString(cursor.getColumnIndex(ACCOUNT)), + cursor.getString(cursor.getColumnIndex(SYSTEMNAME)), + cursor.getString(cursor.getColumnIndex(SERVERNAME)), + cursor.getString(cursor.getColumnIndex(JID)), + cursor.getInt(cursor.getColumnIndex(OPTIONS)), + cursor.getString(cursor.getColumnIndex(PHOTOURI)), + cursor.getString(cursor.getColumnIndex(SYSTEMACCOUNT)), + cursor.getString(cursor.getColumnIndex(KEYS)), + cursor.getString(cursor.getColumnIndex(AVATAR))); + } + + public int getSubscription() { + return this.subscription; + } + + public void setSystemAccount(String account) { + this.systemAccount = account; + } + + public void setAccount(Account account) { + this.account = account; + this.accountUuid = account.getUuid(); + } + + public Account getAccount() { + return this.account; + } + + public Presences getPresences() { + return this.presences; + } + + public void updatePresence(String resource, int status) { + this.presences.updatePresence(resource, status); + } + + public void removePresence(String resource) { + this.presences.removePresence(resource); + } + + public void clearPresences() { + this.presences.clearPresences(); + this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST); + } + + public int getMostAvailableStatus() { + return this.presences.getMostAvailableStatus(); + } + + public void setPresences(Presences pres) { + this.presences = pres; + } + + public void setPhotoUri(String uri) { + this.photoUri = uri; + } + + public void setServerName(String serverName) { + this.serverName = serverName; + } + + public void setSystemName(String systemName) { + this.systemName = systemName; + } + + public void setPresenceName(String presenceName) { + this.presenceName = presenceName; + } + + public String getSystemAccount() { + return systemAccount; + } + + public Set getOtrFingerprints() { + Set set = new HashSet(); + try { + if (this.keys.has("otr_fingerprints")) { + JSONArray fingerprints = this.keys + .getJSONArray("otr_fingerprints"); + for (int i = 0; i < fingerprints.length(); ++i) { + set.add(fingerprints.getString(i)); + } + } + } catch (JSONException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + return set; + } + + public void addOtrFingerprint(String print) { + try { + JSONArray fingerprints; + if (!this.keys.has("otr_fingerprints")) { + fingerprints = new JSONArray(); + + } else { + fingerprints = this.keys.getJSONArray("otr_fingerprints"); + } + fingerprints.put(print); + this.keys.put("otr_fingerprints", fingerprints); + } catch (JSONException e) { + + } + } + + public void setPgpKeyId(long keyId) { + try { + this.keys.put("pgp_keyid", keyId); + } catch (JSONException e) { + + } + } + + public long getPgpKeyId() { + if (this.keys.has("pgp_keyid")) { + try { + return this.keys.getLong("pgp_keyid"); + } catch (JSONException e) { + return 0; + } + } else { + return 0; + } + } + + public void setOption(int option) { + this.subscription |= 1 << option; + } + + public void resetOption(int option) { + this.subscription &= ~(1 << option); + } + + public boolean getOption(int option) { + return ((this.subscription & (1 << option)) != 0); + } + + public boolean showInRoster() { + return (this.getOption(Contact.Options.IN_ROSTER) && (!this + .getOption(Contact.Options.DIRTY_DELETE))) + || (this.getOption(Contact.Options.DIRTY_PUSH)); + } + + public void parseSubscriptionFromElement(Element item) { + String ask = item.getAttribute("ask"); + String subscription = item.getAttribute("subscription"); + + if (subscription != null) { + if (subscription.equals("to")) { + this.resetOption(Contact.Options.FROM); + this.setOption(Contact.Options.TO); + } else if (subscription.equals("from")) { + this.resetOption(Contact.Options.TO); + this.setOption(Contact.Options.FROM); + this.resetOption(Contact.Options.PREEMPTIVE_GRANT); + } else if (subscription.equals("both")) { + this.setOption(Contact.Options.TO); + this.setOption(Contact.Options.FROM); + this.resetOption(Contact.Options.PREEMPTIVE_GRANT); + } else if (subscription.equals("none")) { + this.resetOption(Contact.Options.FROM); + this.resetOption(Contact.Options.TO); + } + } + + // do NOT override asking if pending push request + if (!this.getOption(Contact.Options.DIRTY_PUSH)) { + if ((ask != null) && (ask.equals("subscribe"))) { + this.setOption(Contact.Options.ASKING); + } else { + this.resetOption(Contact.Options.ASKING); + } + } + } + + public Element asElement() { + Element item = new Element("item"); + item.setAttribute("jid", this.jid); + if (this.serverName != null) { + item.setAttribute("name", this.serverName); + } + return item; + } + + public class Options { + public static final int TO = 0; + public static final int FROM = 1; + public static final int ASKING = 2; + public static final int PREEMPTIVE_GRANT = 3; + public static final int IN_ROSTER = 4; + public static final int PENDING_SUBSCRIPTION_REQUEST = 5; + public static final int DIRTY_PUSH = 6; + public static final int DIRTY_DELETE = 7; + } + + public class Lastseen { + public long time = 0; + public String presence = null; + } + + @Override + public int compareTo(ListItem another) { + return this.getDisplayName().compareToIgnoreCase( + another.getDisplayName()); + } + + public String getServer() { + String[] split = getJid().split("@"); + if (split.length >= 2) { + return split[1]; + } else { + return null; + } + } + + public boolean setAvatar(String filename) { + if (this.avatar != null && this.avatar.equals(filename)) { + return false; + } else { + this.avatar = filename; + return true; + } + } + + public String getAvatar() { + return this.avatar; + } + + public boolean deleteOtrFingerprint(String fingerprint) { + boolean success = false; + try { + if (this.keys.has("otr_fingerprints")) { + JSONArray newPrints = new JSONArray(); + JSONArray oldPrints = this.keys + .getJSONArray("otr_fingerprints"); + for (int i = 0; i < oldPrints.length(); ++i) { + if (!oldPrints.getString(i).equals(fingerprint)) { + newPrints.put(oldPrints.getString(i)); + } else { + success = true; + } + } + this.keys.put("otr_fingerprints", newPrints); + } + return success; + } catch (JSONException e) { + return false; + } + } + + public boolean trusted() { + return getOption(Options.FROM) && getOption(Options.TO); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Conversation.java b/conversations/src/main/java/eu/siacs/conversations/entities/Conversation.java new file mode 100644 index 000000000..9d4c36db5 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -0,0 +1,500 @@ +package eu.siacs.conversations.entities; + +import java.security.interfaces.DSAPublicKey; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONException; +import org.json.JSONObject; + +import eu.siacs.conversations.services.XmppConnectionService; + +import net.java.otr4j.OtrException; +import net.java.otr4j.crypto.OtrCryptoEngineImpl; +import net.java.otr4j.crypto.OtrCryptoException; +import net.java.otr4j.session.SessionID; +import net.java.otr4j.session.SessionImpl; +import net.java.otr4j.session.SessionStatus; +import android.content.ContentValues; +import android.database.Cursor; +import android.os.SystemClock; + +public class Conversation extends AbstractEntity { + public static final String TABLENAME = "conversations"; + + public static final int STATUS_AVAILABLE = 0; + public static final int STATUS_ARCHIVED = 1; + public static final int STATUS_DELETED = 2; + + public static final int MODE_MULTI = 1; + public static final int MODE_SINGLE = 0; + + public static final String NAME = "name"; + public static final String ACCOUNT = "accountUuid"; + public static final String CONTACT = "contactUuid"; + public static final String CONTACTJID = "contactJid"; + public static final String STATUS = "status"; + public static final String CREATED = "created"; + public static final String MODE = "mode"; + public static final String ATTRIBUTES = "attributes"; + + public static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption"; + public static final String ATTRIBUTE_MUC_PASSWORD = "muc_password"; + public static final String ATTRIBUTE_MUTED_TILL = "muted_till"; + + private String name; + private String contactUuid; + private String accountUuid; + private String contactJid; + private int status; + private long created; + private int mode; + + private JSONObject attributes = new JSONObject(); + + private String nextPresence; + + protected ArrayList messages = new ArrayList(); + protected Account account = null; + + private transient SessionImpl otrSession; + + private transient String otrFingerprint = null; + + private String nextMessage; + + private transient MucOptions mucOptions = null; + + // private transient String latestMarkableMessageId; + + private byte[] symmetricKey; + + private Bookmark bookmark; + + public Conversation(String name, Account account, String contactJid, + int mode) { + this(java.util.UUID.randomUUID().toString(), name, null, account + .getUuid(), contactJid, System.currentTimeMillis(), + STATUS_AVAILABLE, mode, ""); + this.account = account; + } + + public Conversation(String uuid, String name, String contactUuid, + String accountUuid, String contactJid, long created, int status, + int mode, String attributes) { + this.uuid = uuid; + this.name = name; + this.contactUuid = contactUuid; + this.accountUuid = accountUuid; + this.contactJid = contactJid; + this.created = created; + this.status = status; + this.mode = mode; + try { + if (attributes == null) { + attributes = new String(); + } + this.attributes = new JSONObject(attributes); + } catch (JSONException e) { + this.attributes = new JSONObject(); + } + } + + public List getMessages() { + return messages; + } + + public boolean isRead() { + if ((this.messages == null) || (this.messages.size() == 0)) + return true; + return this.messages.get(this.messages.size() - 1).isRead(); + } + + public void markRead() { + if (this.messages == null) { + return; + } + for (int i = this.messages.size() - 1; i >= 0; --i) { + if (messages.get(i).isRead()) { + break; + } + this.messages.get(i).markRead(); + } + } + + public String getLatestMarkableMessageId() { + if (this.messages == null) { + return null; + } + for (int i = this.messages.size() - 1; i >= 0; --i) { + if (this.messages.get(i).getStatus() <= Message.STATUS_RECEIVED + && this.messages.get(i).markable) { + if (this.messages.get(i).isRead()) { + return null; + } else { + return this.messages.get(i).getRemoteMsgId(); + } + } + } + return null; + } + + public Message getLatestMessage() { + if ((this.messages == null) || (this.messages.size() == 0)) { + Message message = new Message(this, "", Message.ENCRYPTION_NONE); + message.setTime(getCreated()); + return message; + } else { + Message message = this.messages.get(this.messages.size() - 1); + message.setConversation(this); + return message; + } + } + + public void setMessages(ArrayList msgs) { + this.messages = msgs; + } + + public String getName() { + if (getMode() == MODE_MULTI && getMucOptions().getSubject() != null) { + return getMucOptions().getSubject(); + } else if (getMode() == MODE_MULTI && bookmark != null + && bookmark.getName() != null) { + return bookmark.getName(); + } else { + return this.getContact().getDisplayName(); + } + } + + public String getProfilePhotoString() { + return this.getContact().getProfilePhoto(); + } + + public String getAccountUuid() { + return this.accountUuid; + } + + public Account getAccount() { + return this.account; + } + + public Contact getContact() { + return this.account.getRoster().getContact(this.contactJid); + } + + public void setAccount(Account account) { + this.account = account; + } + + public String getContactJid() { + return this.contactJid; + } + + public int getStatus() { + return this.status; + } + + public long getCreated() { + return this.created; + } + + public ContentValues getContentValues() { + ContentValues values = new ContentValues(); + values.put(UUID, uuid); + values.put(NAME, name); + values.put(CONTACT, contactUuid); + values.put(ACCOUNT, accountUuid); + values.put(CONTACTJID, contactJid); + values.put(CREATED, created); + values.put(STATUS, status); + values.put(MODE, mode); + values.put(ATTRIBUTES, attributes.toString()); + return values; + } + + public static Conversation fromCursor(Cursor cursor) { + return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)), + cursor.getString(cursor.getColumnIndex(NAME)), + cursor.getString(cursor.getColumnIndex(CONTACT)), + cursor.getString(cursor.getColumnIndex(ACCOUNT)), + cursor.getString(cursor.getColumnIndex(CONTACTJID)), + cursor.getLong(cursor.getColumnIndex(CREATED)), + cursor.getInt(cursor.getColumnIndex(STATUS)), + cursor.getInt(cursor.getColumnIndex(MODE)), + cursor.getString(cursor.getColumnIndex(ATTRIBUTES))); + } + + public void setStatus(int status) { + this.status = status; + } + + public int getMode() { + return this.mode; + } + + public void setMode(int mode) { + this.mode = mode; + } + + public SessionImpl startOtrSession(XmppConnectionService service, + String presence, boolean sendStart) { + if (this.otrSession != null) { + return this.otrSession; + } else { + SessionID sessionId = new SessionID(this.getContactJid().split("/", + 2)[0], presence, "xmpp"); + this.otrSession = new SessionImpl(sessionId, getAccount() + .getOtrEngine(service)); + try { + if (sendStart) { + this.otrSession.startSession(); + return this.otrSession; + } + return this.otrSession; + } catch (OtrException e) { + return null; + } + } + + } + + public SessionImpl getOtrSession() { + return this.otrSession; + } + + public void resetOtrSession() { + this.otrFingerprint = null; + this.otrSession = null; + } + + public void startOtrIfNeeded() { + if (this.otrSession != null + && this.otrSession.getSessionStatus() != SessionStatus.ENCRYPTED) { + try { + this.otrSession.startSession(); + } catch (OtrException e) { + this.resetOtrSession(); + } + } + } + + public boolean endOtrIfNeeded() { + if (this.otrSession != null) { + if (this.otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + this.otrSession.endSession(); + this.resetOtrSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + this.resetOtrSession(); + return false; + } + } else { + return false; + } + } + + public boolean hasValidOtrSession() { + return this.otrSession != null; + } + + public String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (getOtrSession() == null) { + return ""; + } + DSAPublicKey remotePubKey = (DSAPublicKey) getOtrSession() + .getRemotePublicKey(); + StringBuilder builder = new StringBuilder( + new OtrCryptoEngineImpl().getFingerprint(remotePubKey)); + builder.insert(8, " "); + builder.insert(17, " "); + builder.insert(26, " "); + builder.insert(35, " "); + this.otrFingerprint = builder.toString(); + } catch (OtrCryptoException e) { + + } + } + return this.otrFingerprint; + } + + public synchronized MucOptions getMucOptions() { + if (this.mucOptions == null) { + this.mucOptions = new MucOptions(this); + } + return this.mucOptions; + } + + public void resetMucOptions() { + this.mucOptions = null; + } + + public void setContactJid(String jid) { + this.contactJid = jid; + } + + public void setNextPresence(String presence) { + this.nextPresence = presence; + } + + public String getNextPresence() { + return this.nextPresence; + } + + public int getLatestEncryption() { + int latestEncryption = this.getLatestMessage().getEncryption(); + if ((latestEncryption == Message.ENCRYPTION_DECRYPTED) + || (latestEncryption == Message.ENCRYPTION_DECRYPTION_FAILED)) { + return Message.ENCRYPTION_PGP; + } else { + return latestEncryption; + } + } + + public int getNextEncryption(boolean force) { + int next = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, -1); + if (next == -1) { + int latest = this.getLatestEncryption(); + if (latest == Message.ENCRYPTION_NONE) { + if (force && getMode() == MODE_SINGLE) { + return Message.ENCRYPTION_OTR; + } else if (getContact().getPresences().size() == 1) { + if (getContact().getOtrFingerprints().size() >= 1) { + return Message.ENCRYPTION_OTR; + } else { + return latest; + } + } else { + return latest; + } + } else { + return latest; + } + } + if (next == Message.ENCRYPTION_NONE && force + && getMode() == MODE_SINGLE) { + return Message.ENCRYPTION_OTR; + } else { + return next; + } + } + + public void setNextEncryption(int encryption) { + this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, String.valueOf(encryption)); + } + + public String getNextMessage() { + if (this.nextMessage == null) { + return ""; + } else { + return this.nextMessage; + } + } + + public void setNextMessage(String message) { + this.nextMessage = message; + } + + public void setSymmetricKey(byte[] key) { + this.symmetricKey = key; + } + + public byte[] getSymmetricKey() { + return this.symmetricKey; + } + + public void setBookmark(Bookmark bookmark) { + this.bookmark = bookmark; + this.bookmark.setConversation(this); + } + + public void deregisterWithBookmark() { + if (this.bookmark != null) { + this.bookmark.setConversation(null); + } + } + + public Bookmark getBookmark() { + return this.bookmark; + } + + public boolean hasDuplicateMessage(Message message) { + for (int i = this.getMessages().size() - 1; i >= 0; --i) { + if (this.messages.get(i).equals(message)) { + return true; + } + } + return false; + } + + public void setMutedTill(long value) { + this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value)); + } + + public boolean isMuted() { + return SystemClock.elapsedRealtime() < this.getLongAttribute( + ATTRIBUTE_MUTED_TILL, 0); + } + + public boolean setAttribute(String key, String value) { + try { + this.attributes.put(key, value); + return true; + } catch (JSONException e) { + return false; + } + } + + public String getAttribute(String key) { + try { + return this.attributes.getString(key); + } catch (JSONException e) { + return null; + } + } + + public int getIntAttribute(String key, int defaultValue) { + String value = this.getAttribute(key); + if (value == null) { + return defaultValue; + } else { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + } + + public long getLongAttribute(String key, long defaultValue) { + String value = this.getAttribute(key); + if (value == null) { + return defaultValue; + } else { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + } + + public void add(Message message) { + message.setConversation(this); + synchronized (this.messages) { + this.messages.add(message); + } + } + + public void addAll(int index, List messages) { + synchronized (this.messages) { + this.messages.addAll(index, messages); + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Downloadable.java b/conversations/src/main/java/eu/siacs/conversations/entities/Downloadable.java new file mode 100644 index 000000000..70516b204 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Downloadable.java @@ -0,0 +1,21 @@ +package eu.siacs.conversations.entities; + +public interface Downloadable { + + public final String[] VALID_EXTENSIONS = { "webp", "jpeg", "jpg", "png" }; + public final String[] VALID_CRYPTO_EXTENSIONS = { "pgp", "gpg", "otr" }; + + public static final int STATUS_UNKNOWN = 0x200; + public static final int STATUS_CHECKING = 0x201; + public static final int STATUS_FAILED = 0x202; + public static final int STATUS_OFFER = 0x203; + public static final int STATUS_DOWNLOADING = 0x204; + public static final int STATUS_DELETED = 0x205; + public static final int STATUS_OFFER_CHECK_FILESIZE = 0x206; + + public boolean start(); + + public int getStatus(); + + public long getFileSize(); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/DownloadableFile.java b/conversations/src/main/java/eu/siacs/conversations/entities/DownloadableFile.java new file mode 100644 index 000000000..1605c75b4 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/DownloadableFile.java @@ -0,0 +1,154 @@ +package eu.siacs.conversations.entities; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import eu.siacs.conversations.Config; +import android.util.Log; + +public class DownloadableFile extends File { + + private static final long serialVersionUID = 2247012619505115863L; + + private long expectedSize = 0; + private String sha1sum; + private Key aeskey; + + private byte[] iv = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0xf }; + + public DownloadableFile(String path) { + super(path); + } + + public long getSize() { + return super.length(); + } + + public long getExpectedSize() { + if (this.aeskey != null) { + if (this.expectedSize == 0) { + return 0; + } else { + return (this.expectedSize / 16 + 1) * 16; + } + } else { + return this.expectedSize; + } + } + + public void setExpectedSize(long size) { + this.expectedSize = size; + } + + public String getSha1Sum() { + return this.sha1sum; + } + + public void setSha1Sum(String sum) { + this.sha1sum = sum; + } + + public void setKey(byte[] key) { + if (key.length == 48) { + byte[] secretKey = new byte[32]; + byte[] iv = new byte[16]; + System.arraycopy(key, 0, iv, 0, 16); + System.arraycopy(key, 16, secretKey, 0, 32); + this.aeskey = new SecretKeySpec(secretKey, "AES"); + this.iv = iv; + } else if (key.length >= 32) { + byte[] secretKey = new byte[32]; + System.arraycopy(key, 0, secretKey, 0, 32); + this.aeskey = new SecretKeySpec(secretKey, "AES"); + } else if (key.length >= 16) { + byte[] secretKey = new byte[16]; + System.arraycopy(key, 0, secretKey, 0, 16); + this.aeskey = new SecretKeySpec(secretKey, "AES"); + } + } + + public Key getKey() { + return this.aeskey; + } + + public InputStream createInputStream() { + if (this.getKey() == null) { + try { + return new FileInputStream(this); + } catch (FileNotFoundException e) { + return null; + } + } else { + try { + IvParameterSpec ips = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, this.getKey(), ips); + Log.d(Config.LOGTAG, "opening encrypted input stream"); + return new CipherInputStream(new FileInputStream(this), cipher); + } catch (NoSuchAlgorithmException e) { + Log.d(Config.LOGTAG, "no such algo: " + e.getMessage()); + return null; + } catch (NoSuchPaddingException e) { + Log.d(Config.LOGTAG, "no such padding: " + e.getMessage()); + return null; + } catch (InvalidKeyException e) { + Log.d(Config.LOGTAG, "invalid key: " + e.getMessage()); + return null; + } catch (InvalidAlgorithmParameterException e) { + Log.d(Config.LOGTAG, "invavid iv:" + e.getMessage()); + return null; + } catch (FileNotFoundException e) { + return null; + } + } + } + + public OutputStream createOutputStream() { + if (this.getKey() == null) { + try { + return new FileOutputStream(this); + } catch (FileNotFoundException e) { + return null; + } + } else { + try { + IvParameterSpec ips = new IvParameterSpec(this.iv); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, this.getKey(), ips); + Log.d(Config.LOGTAG, "opening encrypted output stream"); + return new CipherOutputStream(new FileOutputStream(this), + cipher); + } catch (NoSuchAlgorithmException e) { + Log.d(Config.LOGTAG, "no such algo: " + e.getMessage()); + return null; + } catch (NoSuchPaddingException e) { + Log.d(Config.LOGTAG, "no such padding: " + e.getMessage()); + return null; + } catch (InvalidKeyException e) { + Log.d(Config.LOGTAG, "invalid key: " + e.getMessage()); + return null; + } catch (InvalidAlgorithmParameterException e) { + Log.d(Config.LOGTAG, "invavid iv:" + e.getMessage()); + return null; + } catch (FileNotFoundException e) { + return null; + } + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/ListItem.java b/conversations/src/main/java/eu/siacs/conversations/entities/ListItem.java new file mode 100644 index 000000000..a1872d2f2 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/ListItem.java @@ -0,0 +1,7 @@ +package eu.siacs.conversations.entities; + +public interface ListItem extends Comparable { + public String getDisplayName(); + + public String getJid(); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Message.java b/conversations/src/main/java/eu/siacs/conversations/entities/Message.java new file mode 100644 index 000000000..a390c7ca0 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Message.java @@ -0,0 +1,478 @@ +package eu.siacs.conversations.entities; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Arrays; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +public class Message extends AbstractEntity { + + public static final String TABLENAME = "messages"; + + public static final int STATUS_RECEIVED = 0; + public static final int STATUS_UNSEND = 1; + public static final int STATUS_SEND = 2; + public static final int STATUS_SEND_FAILED = 3; + public static final int STATUS_SEND_REJECTED = 4; + public static final int STATUS_WAITING = 5; + public static final int STATUS_OFFERED = 6; + public static final int STATUS_SEND_RECEIVED = 7; + public static final int STATUS_SEND_DISPLAYED = 8; + + public static final int ENCRYPTION_NONE = 0; + public static final int ENCRYPTION_PGP = 1; + public static final int ENCRYPTION_OTR = 2; + public static final int ENCRYPTION_DECRYPTED = 3; + public static final int ENCRYPTION_DECRYPTION_FAILED = 4; + + public static final int TYPE_TEXT = 0; + public static final int TYPE_IMAGE = 1; + public static final int TYPE_AUDIO = 2; + public static final int TYPE_STATUS = 3; + public static final int TYPE_PRIVATE = 4; + + public static String CONVERSATION = "conversationUuid"; + public static String COUNTERPART = "counterpart"; + public static String TRUE_COUNTERPART = "trueCounterpart"; + public static String BODY = "body"; + public static String TIME_SENT = "timeSent"; + public static String ENCRYPTION = "encryption"; + public static String STATUS = "status"; + public static String TYPE = "type"; + public static String REMOTE_MSG_ID = "remoteMsgId"; + + protected String conversationUuid; + protected String counterpart; + protected String trueCounterpart; + protected String body; + protected String encryptedBody; + protected long timeSent; + protected int encryption; + protected int status; + protected int type; + protected boolean read = true; + protected String remoteMsgId = null; + + protected Conversation conversation = null; + protected Downloadable downloadable = null; + public boolean markable = false; + + private Message mNextMessage = null; + private Message mPreviousMessage = null; + + private Message() { + + } + + public Message(Conversation conversation, String body, int encryption) { + this(java.util.UUID.randomUUID().toString(), conversation.getUuid(), + conversation.getContactJid(), null, body, System + .currentTimeMillis(), encryption, + Message.STATUS_UNSEND, TYPE_TEXT, null); + this.conversation = conversation; + } + + public Message(Conversation conversation, String counterpart, String body, + int encryption, int status) { + this(java.util.UUID.randomUUID().toString(), conversation.getUuid(), + counterpart, null, body, System.currentTimeMillis(), + encryption, status, TYPE_TEXT, null); + this.conversation = conversation; + } + + public Message(String uuid, String conversationUUid, String counterpart, + String trueCounterpart, String body, long timeSent, int encryption, + int status, int type, String remoteMsgId) { + this.uuid = uuid; + this.conversationUuid = conversationUUid; + this.counterpart = counterpart; + this.trueCounterpart = trueCounterpart; + this.body = body; + this.timeSent = timeSent; + this.encryption = encryption; + this.status = status; + this.type = type; + this.remoteMsgId = remoteMsgId; + } + + @Override + public ContentValues getContentValues() { + ContentValues values = new ContentValues(); + values.put(UUID, uuid); + values.put(CONVERSATION, conversationUuid); + values.put(COUNTERPART, counterpart); + values.put(TRUE_COUNTERPART, trueCounterpart); + values.put(BODY, body); + values.put(TIME_SENT, timeSent); + values.put(ENCRYPTION, encryption); + values.put(STATUS, status); + values.put(TYPE, type); + values.put(REMOTE_MSG_ID, remoteMsgId); + return values; + } + + public String getConversationUuid() { + return conversationUuid; + } + + public Conversation getConversation() { + return this.conversation; + } + + public String getCounterpart() { + return counterpart; + } + + public Contact getContact() { + if (this.conversation.getMode() == Conversation.MODE_SINGLE) { + return this.conversation.getContact(); + } else { + if (this.trueCounterpart == null) { + return null; + } else { + return this.conversation.getAccount().getRoster() + .getContactFromRoster(this.trueCounterpart); + } + } + } + + public String getBody() { + return body; + } + + public String getReadableBody(Context context) { + if (encryption == ENCRYPTION_PGP) { + return context.getText(R.string.encrypted_message_received) + .toString(); + } else if (encryption == ENCRYPTION_DECRYPTION_FAILED) { + return context.getText(R.string.decryption_failed).toString(); + } else if (type == TYPE_IMAGE) { + return context.getText(R.string.image_file).toString(); + } else { + return body.trim(); + } + } + + public long getTimeSent() { + return timeSent; + } + + public int getEncryption() { + return encryption; + } + + public int getStatus() { + return status; + } + + public String getRemoteMsgId() { + return this.remoteMsgId; + } + + public void setRemoteMsgId(String id) { + this.remoteMsgId = id; + } + + public static Message fromCursor(Cursor cursor) { + return new Message(cursor.getString(cursor.getColumnIndex(UUID)), + cursor.getString(cursor.getColumnIndex(CONVERSATION)), + cursor.getString(cursor.getColumnIndex(COUNTERPART)), + cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART)), + cursor.getString(cursor.getColumnIndex(BODY)), + cursor.getLong(cursor.getColumnIndex(TIME_SENT)), + cursor.getInt(cursor.getColumnIndex(ENCRYPTION)), + cursor.getInt(cursor.getColumnIndex(STATUS)), + cursor.getInt(cursor.getColumnIndex(TYPE)), + cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID))); + } + + public void setConversation(Conversation conv) { + this.conversation = conv; + } + + public void setStatus(int status) { + this.status = status; + } + + public boolean isRead() { + return this.read; + } + + public void markRead() { + this.read = true; + } + + public void markUnread() { + this.read = false; + } + + public void setTime(long time) { + this.timeSent = time; + } + + public void setEncryption(int encryption) { + this.encryption = encryption; + } + + public void setBody(String body) { + this.body = body; + } + + public String getEncryptedBody() { + return this.encryptedBody; + } + + public void setEncryptedBody(String body) { + this.encryptedBody = body; + } + + public void setType(int type) { + this.type = type; + } + + public int getType() { + return this.type; + } + + public void setPresence(String presence) { + if (presence == null) { + this.counterpart = this.counterpart.split("/", 2)[0]; + } else { + this.counterpart = this.counterpart.split("/", 2)[0] + "/" + + presence; + } + } + + public void setTrueCounterpart(String trueCounterpart) { + this.trueCounterpart = trueCounterpart; + } + + public String getPresence() { + String[] counterparts = this.counterpart.split("/", 2); + if (counterparts.length == 2) { + return counterparts[1]; + } else { + if (this.counterpart.contains("/")) { + return ""; + } else { + return null; + } + } + } + + public void setDownloadable(Downloadable downloadable) { + this.downloadable = downloadable; + } + + public Downloadable getDownloadable() { + return this.downloadable; + } + + public static Message createStatusMessage(Conversation conversation) { + Message message = new Message(); + message.setType(Message.TYPE_STATUS); + message.setConversation(conversation); + return message; + } + + public void setCounterpart(String counterpart) { + this.counterpart = counterpart; + } + + public boolean equals(Message message) { + if ((this.remoteMsgId != null) && (this.body != null) + && (this.counterpart != null)) { + return this.remoteMsgId.equals(message.getRemoteMsgId()) + && this.body.equals(message.getBody()) + && this.counterpart.equals(message.getCounterpart()); + } else { + return false; + } + } + + public Message next() { + if (this.mNextMessage == null) { + synchronized (this.conversation.messages) { + int index = this.conversation.messages.indexOf(this); + if (index < 0 + || index >= this.conversation.getMessages().size() - 1) { + this.mNextMessage = null; + } else { + this.mNextMessage = this.conversation.messages + .get(index + 1); + } + } + } + return this.mNextMessage; + } + + public Message prev() { + if (this.mPreviousMessage == null) { + synchronized (this.conversation.messages) { + int index = this.conversation.messages.indexOf(this); + if (index <= 0 || index > this.conversation.messages.size()) { + this.mPreviousMessage = null; + } else { + this.mPreviousMessage = this.conversation.messages + .get(index - 1); + } + } + } + return this.mPreviousMessage; + } + + public boolean mergable(Message message) { + if (message == null) { + return false; + } + return (message.getType() == Message.TYPE_TEXT + && this.getDownloadable() == null + && message.getDownloadable() == null + && message.getEncryption() != Message.ENCRYPTION_PGP + && this.getType() == message.getType() + && this.getEncryption() == message.getEncryption() + && this.getCounterpart().equals(message.getCounterpart()) + && (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) && ((this + .getStatus() == message.getStatus() || ((this.getStatus() == Message.STATUS_SEND || this + .getStatus() == Message.STATUS_SEND_RECEIVED) && (message + .getStatus() == Message.STATUS_UNSEND + || message.getStatus() == Message.STATUS_SEND || message + .getStatus() == Message.STATUS_SEND_DISPLAYED))))); + } + + public String getMergedBody() { + Message next = this.next(); + if (this.mergable(next)) { + return body.trim() + '\n' + next.getMergedBody(); + } + return body.trim(); + } + + public int getMergedStatus() { + Message next = this.next(); + if (this.mergable(next)) { + return next.getMergedStatus(); + } else { + return getStatus(); + } + } + + public long getMergedTimeSent() { + Message next = this.next(); + if (this.mergable(next)) { + return next.getMergedTimeSent(); + } else { + return getTimeSent(); + } + } + + public boolean wasMergedIntoPrevious() { + Message prev = this.prev(); + if (prev == null) { + return false; + } else { + return prev.mergable(this); + } + } + + public boolean bodyContainsDownloadable() { + Contact contact = this.getContact(); + if (status <= STATUS_RECEIVED + && (contact == null || !contact.trusted())) { + return false; + } + try { + URL url = new URL(this.getBody()); + if (!url.getProtocol().equalsIgnoreCase("http") + && !url.getProtocol().equalsIgnoreCase("https")) { + return false; + } + if (url.getPath() == null) { + return false; + } + String[] pathParts = url.getPath().split("/"); + String filename = pathParts[pathParts.length - 1]; + String[] extensionParts = filename.split("\\."); + if (extensionParts.length == 2 + && Arrays.asList(Downloadable.VALID_EXTENSIONS).contains( + extensionParts[extensionParts.length - 1])) { + return true; + } else if (extensionParts.length == 3 + && Arrays + .asList(Downloadable.VALID_CRYPTO_EXTENSIONS) + .contains(extensionParts[extensionParts.length - 1]) + && Arrays.asList(Downloadable.VALID_EXTENSIONS).contains( + extensionParts[extensionParts.length - 2])) { + return true; + } else { + return false; + } + } catch (MalformedURLException e) { + return false; + } + } + + public ImageParams getImageParams() { + ImageParams params = new ImageParams(); + if (this.downloadable != null) { + params.size = this.downloadable.getFileSize(); + } + if (body == null) { + return params; + } + String parts[] = body.split(","); + if (parts.length == 1) { + try { + params.size = Long.parseLong(parts[0]); + } catch (NumberFormatException e) { + params.origin = parts[0]; + } + } else if (parts.length == 3) { + try { + params.size = Long.parseLong(parts[0]); + } catch (NumberFormatException e) { + params.size = 0; + } + try { + params.width = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + params.width = 0; + } + try { + params.height = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + params.height = 0; + } + } else if (parts.length == 4) { + params.origin = parts[0]; + try { + params.size = Long.parseLong(parts[1]); + } catch (NumberFormatException e) { + params.size = 0; + } + try { + params.width = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + params.width = 0; + } + try { + params.height = Integer.parseInt(parts[3]); + } catch (NumberFormatException e) { + params.height = 0; + } + } + return params; + } + + public class ImageParams { + public long size = 0; + public int width = 0; + public int height = 0; + public String origin; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/conversations/src/main/java/eu/siacs/conversations/entities/MucOptions.java new file mode 100644 index 000000000..d7407cd5e --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/MucOptions.java @@ -0,0 +1,369 @@ +package eu.siacs.conversations.entities; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import eu.siacs.conversations.crypto.PgpEngine; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.stanzas.PresencePacket; +import android.annotation.SuppressLint; + +@SuppressLint("DefaultLocale") +public class MucOptions { + public static final int ERROR_NO_ERROR = 0; + public static final int ERROR_NICK_IN_USE = 1; + public static final int ERROR_ROOM_NOT_FOUND = 2; + public static final int ERROR_PASSWORD_REQUIRED = 3; + public static final int ERROR_BANNED = 4; + public static final int ERROR_MEMBERS_ONLY = 5; + + public static final int KICKED_FROM_ROOM = 9; + + public static final String STATUS_CODE_BANNED = "301"; + public static final String STATUS_CODE_KICKED = "307"; + + public interface OnRenameListener { + public void onRename(boolean success); + } + + public class User { + public static final int ROLE_MODERATOR = 3; + public static final int ROLE_NONE = 0; + public static final int ROLE_PARTICIPANT = 2; + public static final int ROLE_VISITOR = 1; + public static final int AFFILIATION_ADMIN = 4; + public static final int AFFILIATION_OWNER = 3; + public static final int AFFILIATION_MEMBER = 2; + public static final int AFFILIATION_OUTCAST = 1; + public static final int AFFILIATION_NONE = 0; + + private int role; + private int affiliation; + private String name; + private String jid; + private long pgpKeyId = 0; + + public String getName() { + return name; + } + + public void setName(String user) { + this.name = user; + } + + public void setJid(String jid) { + this.jid = jid; + } + + public String getJid() { + return this.jid; + } + + public int getRole() { + return this.role; + } + + public void setRole(String role) { + role = role.toLowerCase(); + if (role.equals("moderator")) { + this.role = ROLE_MODERATOR; + } else if (role.equals("participant")) { + this.role = ROLE_PARTICIPANT; + } else if (role.equals("visitor")) { + this.role = ROLE_VISITOR; + } else { + this.role = ROLE_NONE; + } + } + + public int getAffiliation() { + return this.affiliation; + } + + public void setAffiliation(String affiliation) { + if (affiliation.equalsIgnoreCase("admin")) { + this.affiliation = AFFILIATION_ADMIN; + } else if (affiliation.equalsIgnoreCase("owner")) { + this.affiliation = AFFILIATION_OWNER; + } else if (affiliation.equalsIgnoreCase("member")) { + this.affiliation = AFFILIATION_MEMBER; + } else if (affiliation.equalsIgnoreCase("outcast")) { + this.affiliation = AFFILIATION_OUTCAST; + } else { + this.affiliation = AFFILIATION_NONE; + } + } + + public void setPgpKeyId(long id) { + this.pgpKeyId = id; + } + + public long getPgpKeyId() { + return this.pgpKeyId; + } + + public Contact getContact() { + return account.getRoster().getContactFromRoster(getJid()); + } + } + + private Account account; + private List users = new CopyOnWriteArrayList(); + private Conversation conversation; + private boolean isOnline = false; + private int error = ERROR_ROOM_NOT_FOUND; + private OnRenameListener renameListener = null; + private boolean aboutToRename = false; + private User self = new User(); + private String subject = null; + private String joinnick; + private String password = null; + + public MucOptions(Conversation conversation) { + this.account = conversation.getAccount(); + this.conversation = conversation; + } + + public void deleteUser(String name) { + for (int i = 0; i < users.size(); ++i) { + if (users.get(i).getName().equals(name)) { + users.remove(i); + return; + } + } + } + + public void addUser(User user) { + for (int i = 0; i < users.size(); ++i) { + if (users.get(i).getName().equals(user.getName())) { + users.set(i, user); + return; + } + } + users.add(user); + } + + public void processPacket(PresencePacket packet, PgpEngine pgp) { + String[] fromParts = packet.getFrom().split("/", 2); + if (fromParts.length >= 2) { + String name = fromParts[1]; + String type = packet.getAttribute("type"); + if (type == null) { + User user = new User(); + Element item = packet.findChild("x", + "http://jabber.org/protocol/muc#user") + .findChild("item"); + user.setName(name); + user.setAffiliation(item.getAttribute("affiliation")); + user.setRole(item.getAttribute("role")); + user.setJid(item.getAttribute("jid")); + user.setName(name); + if (name.equals(this.joinnick)) { + this.isOnline = true; + this.error = ERROR_NO_ERROR; + self = user; + if (aboutToRename) { + if (renameListener != null) { + renameListener.onRename(true); + } + aboutToRename = false; + } + } else { + addUser(user); + } + if (pgp != null) { + Element x = packet.findChild("x", "jabber:x:signed"); + if (x != null) { + Element status = packet.findChild("status"); + String msg; + if (status != null) { + msg = status.getContent(); + } else { + msg = ""; + } + user.setPgpKeyId(pgp.fetchKeyId(account, msg, + x.getContent())); + } + } + } else if (type.equals("unavailable") && name.equals(this.joinnick)) { + Element x = packet.findChild("x", + "http://jabber.org/protocol/muc#user"); + if (x != null) { + Element status = x.findChild("status"); + if (status != null) { + String code = status.getAttribute("code"); + if (STATUS_CODE_KICKED.equals(code)) { + this.isOnline = false; + this.error = KICKED_FROM_ROOM; + } else if (STATUS_CODE_BANNED.equals(code)) { + this.isOnline = false; + this.error = ERROR_BANNED; + } + } + } + } else if (type.equals("unavailable")) { + deleteUser(packet.getAttribute("from").split("/", 2)[1]); + } else if (type.equals("error")) { + Element error = packet.findChild("error"); + if (error != null && error.hasChild("conflict")) { + if (aboutToRename) { + if (renameListener != null) { + renameListener.onRename(false); + } + aboutToRename = false; + this.setJoinNick(getActualNick()); + } else { + this.error = ERROR_NICK_IN_USE; + } + } else if (error != null && error.hasChild("not-authorized")) { + this.error = ERROR_PASSWORD_REQUIRED; + } else if (error != null && error.hasChild("forbidden")) { + this.error = ERROR_BANNED; + } else if (error != null + && error.hasChild("registration-required")) { + this.error = ERROR_MEMBERS_ONLY; + } + } + } + } + + public List getUsers() { + return this.users; + } + + public String getProposedNick() { + String[] mucParts = conversation.getContactJid().split("/", 2); + if (conversation.getBookmark() != null + && conversation.getBookmark().getNick() != null) { + return conversation.getBookmark().getNick(); + } else { + if (mucParts.length == 2) { + return mucParts[1]; + } else { + return account.getUsername(); + } + } + } + + public String getActualNick() { + if (this.self.getName() != null) { + return this.self.getName(); + } else { + return this.getProposedNick(); + } + } + + public void setJoinNick(String nick) { + this.joinnick = nick; + } + + public boolean online() { + return this.isOnline; + } + + public int getError() { + return this.error; + } + + public void setOnRenameListener(OnRenameListener listener) { + this.renameListener = listener; + } + + public OnRenameListener getOnRenameListener() { + return this.renameListener; + } + + public void setOffline() { + this.users.clear(); + this.error = 0; + this.isOnline = false; + } + + public User getSelf() { + return self; + } + + public void setSubject(String content) { + this.subject = content; + } + + public String getSubject() { + return this.subject; + } + + public void flagAboutToRename() { + this.aboutToRename = true; + } + + public long[] getPgpKeyIds() { + List ids = new ArrayList(); + for (User user : getUsers()) { + if (user.getPgpKeyId() != 0) { + ids.add(user.getPgpKeyId()); + } + } + long[] primitivLongArray = new long[ids.size()]; + for (int i = 0; i < ids.size(); ++i) { + primitivLongArray[i] = ids.get(i); + } + return primitivLongArray; + } + + public boolean pgpKeysInUse() { + for (User user : getUsers()) { + if (user.getPgpKeyId() != 0) { + return true; + } + } + return false; + } + + public boolean everybodyHasKeys() { + for (User user : getUsers()) { + if (user.getPgpKeyId() == 0) { + return false; + } + } + return true; + } + + public String getJoinJid() { + return this.conversation.getContactJid().split("/", 2)[0] + "/" + + this.joinnick; + } + + public String getTrueCounterpart(String counterpart) { + for (User user : this.getUsers()) { + if (user.getName().equals(counterpart)) { + return user.getJid(); + } + } + return null; + } + + public String getPassword() { + this.password = conversation + .getAttribute(Conversation.ATTRIBUTE_MUC_PASSWORD); + if (this.password == null && conversation.getBookmark() != null + && conversation.getBookmark().getPassword() != null) { + return conversation.getBookmark().getPassword(); + } else { + return this.password; + } + } + + public void setPassword(String password) { + if (conversation.getBookmark() != null) { + conversation.getBookmark().setPassword(password); + } else { + this.password = password; + } + conversation + .setAttribute(Conversation.ATTRIBUTE_MUC_PASSWORD, password); + } + + public Conversation getConversation() { + return this.conversation; + } +} \ No newline at end of file diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Presences.java b/conversations/src/main/java/eu/siacs/conversations/entities/Presences.java new file mode 100644 index 000000000..b58998473 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Presences.java @@ -0,0 +1,76 @@ +package eu.siacs.conversations.entities; + +import java.util.Hashtable; +import java.util.Iterator; +import java.util.Map.Entry; + +import eu.siacs.conversations.xml.Element; + +public class Presences { + + public static final int CHAT = -1; + public static final int ONLINE = 0; + public static final int AWAY = 1; + public static final int XA = 2; + public static final int DND = 3; + public static final int OFFLINE = 4; + + private Hashtable presences = new Hashtable(); + + public Hashtable getPresences() { + return this.presences; + } + + public void updatePresence(String resource, int status) { + this.presences.put(resource, status); + } + + public void removePresence(String resource) { + this.presences.remove(resource); + } + + public void clearPresences() { + this.presences.clear(); + } + + public int getMostAvailableStatus() { + int status = OFFLINE; + Iterator> it = presences.entrySet().iterator(); + while (it.hasNext()) { + Entry entry = it.next(); + if (entry.getValue() < status) + status = entry.getValue(); + } + return status; + } + + public static int parseShow(Element show) { + if ((show == null) || (show.getContent() == null)) { + return Presences.ONLINE; + } else if (show.getContent().equals("away")) { + return Presences.AWAY; + } else if (show.getContent().equals("xa")) { + return Presences.XA; + } else if (show.getContent().equals("chat")) { + return Presences.CHAT; + } else if (show.getContent().equals("dnd")) { + return Presences.DND; + } else { + return Presences.OFFLINE; + } + } + + public int size() { + return presences.size(); + } + + public String[] asStringArray() { + final String[] presencesArray = new String[presences.size()]; + presences.keySet().toArray(presencesArray); + return presencesArray; + } + + public boolean has(String presence) { + return presences.containsKey(presence); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/entities/Roster.java b/conversations/src/main/java/eu/siacs/conversations/entities/Roster.java new file mode 100644 index 000000000..3267b15ae --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/entities/Roster.java @@ -0,0 +1,83 @@ +package eu.siacs.conversations.entities; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; + +public class Roster { + Account account; + ConcurrentHashMap contacts = new ConcurrentHashMap(); + private String version = null; + + public Roster(Account account) { + this.account = account; + } + + public Contact getContactFromRoster(String jid) { + if (jid == null) { + return null; + } + String cleanJid = jid.split("/", 2)[0]; + Contact contact = contacts.get(cleanJid); + if (contact != null && contact.showInRoster()) { + return contact; + } else { + return null; + } + } + + public Contact getContact(String jid) { + String cleanJid = jid.split("/", 2)[0].toLowerCase(Locale.getDefault()); + if (contacts.containsKey(cleanJid)) { + return contacts.get(cleanJid); + } else { + Contact contact = new Contact(cleanJid); + contact.setAccount(account); + contacts.put(cleanJid, contact); + return contact; + } + } + + public void clearPresences() { + for (Contact contact : getContacts()) { + contact.clearPresences(); + } + } + + public void markAllAsNotInRoster() { + for (Contact contact : getContacts()) { + contact.resetOption(Contact.Options.IN_ROSTER); + } + } + + public void clearSystemAccounts() { + for (Contact contact : getContacts()) { + contact.setPhotoUri(null); + contact.setSystemName(null); + contact.setSystemAccount(null); + } + } + + public List getContacts() { + return new ArrayList(this.contacts.values()); + } + + public void initContact(Contact contact) { + contact.setAccount(account); + contact.setOption(Contact.Options.IN_ROSTER); + contacts.put(contact.getJid(), contact); + } + + public void setVersion(String version) { + this.version = version; + } + + public String getVersion() { + return this.version; + } + + public Account getAccount() { + return this.account; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/conversations/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java new file mode 100644 index 000000000..c96d116d0 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -0,0 +1,48 @@ +package eu.siacs.conversations.generator; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import eu.siacs.conversations.services.XmppConnectionService; + +import android.util.Base64; + +public abstract class AbstractGenerator { + public final String[] FEATURES = { "urn:xmpp:jingle:1", + "urn:xmpp:jingle:apps:file-transfer:3", + "urn:xmpp:jingle:transports:s5b:1", + "urn:xmpp:jingle:transports:ibb:1", "urn:xmpp:receipts", + "urn:xmpp:chat-markers:0", "http://jabber.org/protocol/muc", + "jabber:x:conference", "http://jabber.org/protocol/caps", + "http://jabber.org/protocol/disco#info", + "urn:xmpp:avatar:metadata+notify" }; + public final String IDENTITY_NAME = "Conversations 0.7"; + public final String IDENTITY_TYPE = "phone"; + + protected XmppConnectionService mXmppConnectionService; + + protected AbstractGenerator(XmppConnectionService service) { + this.mXmppConnectionService = service; + } + + public String getCapHash() { + StringBuilder s = new StringBuilder(); + s.append("client/" + IDENTITY_TYPE + "//" + IDENTITY_NAME + "<"); + MessageDigest md = null; + try { + md = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + return null; + } + List features = Arrays.asList(FEATURES); + Collections.sort(features); + for (String feature : features) { + s.append(feature + "<"); + } + byte[] sha1 = md.digest(s.toString().getBytes()); + return new String(Base64.encode(sha1, Base64.DEFAULT)).trim(); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/conversations/src/main/java/eu/siacs/conversations/generator/IqGenerator.java new file mode 100644 index 000000000..d44bf0ca1 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/generator/IqGenerator.java @@ -0,0 +1,96 @@ +package eu.siacs.conversations.generator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.pep.Avatar; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class IqGenerator extends AbstractGenerator { + + public IqGenerator(XmppConnectionService service) { + super(service); + } + + public IqPacket discoResponse(IqPacket request) { + IqPacket packet = new IqPacket(IqPacket.TYPE_RESULT); + packet.setId(request.getId()); + packet.setTo(request.getFrom()); + Element query = packet.addChild("query", + "http://jabber.org/protocol/disco#info"); + query.setAttribute("node", request.query().getAttribute("node")); + Element identity = query.addChild("identity"); + identity.setAttribute("category", "client"); + identity.setAttribute("type", this.IDENTITY_TYPE); + identity.setAttribute("name", IDENTITY_NAME); + List features = Arrays.asList(FEATURES); + Collections.sort(features); + for (String feature : features) { + query.addChild("feature").setAttribute("var", feature); + } + return packet; + } + + protected IqPacket publish(String node, Element item) { + IqPacket packet = new IqPacket(IqPacket.TYPE_SET); + Element pubsub = packet.addChild("pubsub", + "http://jabber.org/protocol/pubsub"); + Element publish = pubsub.addChild("publish"); + publish.setAttribute("node", node); + publish.addChild(item); + return packet; + } + + protected IqPacket retrieve(String node, Element item) { + IqPacket packet = new IqPacket(IqPacket.TYPE_GET); + Element pubsub = packet.addChild("pubsub", + "http://jabber.org/protocol/pubsub"); + Element items = pubsub.addChild("items"); + items.setAttribute("node", node); + if (item != null) { + items.addChild(item); + } + return packet; + } + + public IqPacket publishAvatar(Avatar avatar) { + Element item = new Element("item"); + item.setAttribute("id", avatar.sha1sum); + Element data = item.addChild("data", "urn:xmpp:avatar:data"); + data.setContent(avatar.image); + return publish("urn:xmpp:avatar:data", item); + } + + public IqPacket publishAvatarMetadata(Avatar avatar) { + Element item = new Element("item"); + item.setAttribute("id", avatar.sha1sum); + Element metadata = item + .addChild("metadata", "urn:xmpp:avatar:metadata"); + Element info = metadata.addChild("info"); + info.setAttribute("bytes", avatar.size); + info.setAttribute("id", avatar.sha1sum); + info.setAttribute("height", avatar.height); + info.setAttribute("width", avatar.height); + info.setAttribute("type", avatar.type); + return publish("urn:xmpp:avatar:metadata", item); + } + + public IqPacket retrieveAvatar(Avatar avatar) { + Element item = new Element("item"); + item.setAttribute("id", avatar.sha1sum); + IqPacket packet = retrieve("urn:xmpp:avatar:data", item); + packet.setTo(avatar.owner); + return packet; + } + + public IqPacket retrieveAvatarMetaData(String to) { + IqPacket packet = retrieve("urn:xmpp:avatar:metadata", null); + if (to != null) { + packet.setTo(to); + } + return packet; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/conversations/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java new file mode 100644 index 000000000..dd833e56c --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -0,0 +1,178 @@ +package eu.siacs.conversations.generator; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; + +public class MessageGenerator extends AbstractGenerator { + public MessageGenerator(XmppConnectionService service) { + super(service); + } + + private MessagePacket preparePacket(Message message, boolean addDelay) { + Conversation conversation = message.getConversation(); + Account account = conversation.getAccount(); + MessagePacket packet = new MessagePacket(); + if (conversation.getMode() == Conversation.MODE_SINGLE) { + packet.setTo(message.getCounterpart()); + packet.setType(MessagePacket.TYPE_CHAT); + packet.addChild("markable", "urn:xmpp:chat-markers:0"); + if (this.mXmppConnectionService.indicateReceived()) { + packet.addChild("request", "urn:xmpp:receipts"); + } + } else if (message.getType() == Message.TYPE_PRIVATE) { + packet.setTo(message.getCounterpart()); + packet.setType(MessagePacket.TYPE_CHAT); + } else { + packet.setTo(message.getCounterpart().split("/", 2)[0]); + packet.setType(MessagePacket.TYPE_GROUPCHAT); + } + packet.setFrom(account.getFullJid()); + packet.setId(message.getUuid()); + if (addDelay) { + addDelay(packet, message.getTimeSent()); + } + return packet; + } + + private void addDelay(MessagePacket packet, long timestamp) { + final SimpleDateFormat mDateFormat = new SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + Element delay = packet.addChild("delay", "urn:xmpp:delay"); + Date date = new Date(timestamp); + delay.setAttribute("stamp", mDateFormat.format(date)); + } + + public MessagePacket generateOtrChat(Message message) { + return generateOtrChat(message, false); + } + + public MessagePacket generateOtrChat(Message message, boolean addDelay) { + Session otrSession = message.getConversation().getOtrSession(); + if (otrSession == null) { + return null; + } + MessagePacket packet = preparePacket(message, addDelay); + packet.addChild("private", "urn:xmpp:carbons:2"); + packet.addChild("no-copy", "urn:xmpp:hints"); + try { + packet.setBody(otrSession.transformSending(message.getBody())); + return packet; + } catch (OtrException e) { + return null; + } + } + + public MessagePacket generateChat(Message message) { + return generateChat(message, false); + } + + public MessagePacket generateChat(Message message, boolean addDelay) { + MessagePacket packet = preparePacket(message, addDelay); + packet.setBody(message.getBody()); + return packet; + } + + public MessagePacket generatePgpChat(Message message) { + return generatePgpChat(message, false); + } + + public MessagePacket generatePgpChat(Message message, boolean addDelay) { + MessagePacket packet = preparePacket(message, addDelay); + packet.setBody("This is an XEP-0027 encryted message"); + if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { + packet.addChild("x", "jabber:x:encrypted").setContent( + message.getEncryptedBody()); + } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { + packet.addChild("x", "jabber:x:encrypted").setContent( + message.getBody()); + } + return packet; + } + + public MessagePacket generateNotAcceptable(MessagePacket origin) { + MessagePacket packet = generateError(origin); + Element error = packet.addChild("error"); + error.setAttribute("type", "modify"); + error.setAttribute("code", "406"); + error.addChild("not-acceptable"); + return packet; + } + + private MessagePacket generateError(MessagePacket origin) { + MessagePacket packet = new MessagePacket(); + packet.setId(origin.getId()); + packet.setTo(origin.getFrom()); + packet.setBody(origin.getBody()); + packet.setType(MessagePacket.TYPE_ERROR); + return packet; + } + + public MessagePacket confirm(Account account, String to, String id) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_NORMAL); + packet.setTo(to); + packet.setFrom(account.getFullJid()); + Element received = packet.addChild("displayed", + "urn:xmpp:chat-markers:0"); + received.setAttribute("id", id); + return packet; + } + + public MessagePacket conferenceSubject(Conversation conversation, + String subject) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_GROUPCHAT); + packet.setTo(conversation.getContactJid().split("/", 2)[0]); + Element subjectChild = new Element("subject"); + subjectChild.setContent(subject); + packet.addChild(subjectChild); + packet.setFrom(conversation.getAccount().getJid()); + return packet; + } + + public MessagePacket directInvite(Conversation conversation, String contact) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_NORMAL); + packet.setTo(contact); + packet.setFrom(conversation.getAccount().getFullJid()); + Element x = packet.addChild("x", "jabber:x:conference"); + x.setAttribute("jid", conversation.getContactJid().split("/", 2)[0]); + return packet; + } + + public MessagePacket invite(Conversation conversation, String contact) { + MessagePacket packet = new MessagePacket(); + packet.setTo(conversation.getContactJid().split("/", 2)[0]); + packet.setFrom(conversation.getAccount().getFullJid()); + Element x = new Element("x"); + x.setAttribute("xmlns", "http://jabber.org/protocol/muc#user"); + Element invite = new Element("invite"); + invite.setAttribute("to", contact); + x.addChild(invite); + packet.addChild(x); + return packet; + } + + public MessagePacket received(Account account, + MessagePacket originalMessage, String namespace) { + MessagePacket receivedPacket = new MessagePacket(); + receivedPacket.setType(MessagePacket.TYPE_NORMAL); + receivedPacket.setTo(originalMessage.getFrom()); + receivedPacket.setFrom(account.getFullJid()); + Element received = receivedPacket.addChild("received", namespace); + received.setAttribute("id", originalMessage.getId()); + return receivedPacket; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/generator/PresenceGenerator.java b/conversations/src/main/java/eu/siacs/conversations/generator/PresenceGenerator.java new file mode 100644 index 000000000..d896dd001 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/generator/PresenceGenerator.java @@ -0,0 +1,57 @@ +package eu.siacs.conversations.generator; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.stanzas.PresencePacket; + +public class PresenceGenerator extends AbstractGenerator { + + public PresenceGenerator(XmppConnectionService service) { + super(service); + } + + private PresencePacket subscription(String type, Contact contact) { + PresencePacket packet = new PresencePacket(); + packet.setAttribute("type", type); + packet.setAttribute("to", contact.getJid()); + packet.setAttribute("from", contact.getAccount().getJid()); + return packet; + } + + public PresencePacket requestPresenceUpdatesFrom(Contact contact) { + return subscription("subscribe", contact); + } + + public PresencePacket stopPresenceUpdatesFrom(Contact contact) { + return subscription("unsubscribe", contact); + } + + public PresencePacket stopPresenceUpdatesTo(Contact contact) { + return subscription("unsubscribed", contact); + } + + public PresencePacket sendPresenceUpdatesTo(Contact contact) { + return subscription("subscribed", contact); + } + + public PresencePacket sendPresence(Account account) { + PresencePacket packet = new PresencePacket(); + packet.setAttribute("from", account.getFullJid()); + String sig = account.getPgpSignature(); + if (sig != null) { + packet.addChild("status").setContent("online"); + packet.addChild("x", "jabber:x:signed").setContent(sig); + } + String capHash = getCapHash(); + if (capHash != null) { + Element cap = packet.addChild("c", + "http://jabber.org/protocol/caps"); + cap.setAttribute("hash", "sha-1"); + cap.setAttribute("node", "http://conversions.siacs.eu"); + cap.setAttribute("ver", capHash); + } + return packet; + } +} \ No newline at end of file diff --git a/conversations/src/main/java/eu/siacs/conversations/http/HttpConnection.java b/conversations/src/main/java/eu/siacs/conversations/http/HttpConnection.java new file mode 100644 index 000000000..407a13d94 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/http/HttpConnection.java @@ -0,0 +1,255 @@ +package eu.siacs.conversations.http; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.X509TrustManager; + +import org.apache.http.conn.ssl.StrictHostnameVerifier; + +import android.content.Intent; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.util.Log; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Downloadable; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; + +public class HttpConnection implements Downloadable { + + private HttpConnectionManager mHttpConnectionManager; + private XmppConnectionService mXmppConnectionService; + + private URL mUrl; + private Message message; + private DownloadableFile file; + private int mStatus = Downloadable.STATUS_UNKNOWN; + + public HttpConnection(HttpConnectionManager manager) { + this.mHttpConnectionManager = manager; + this.mXmppConnectionService = manager.getXmppConnectionService(); + } + + @Override + public boolean start() { + if (mXmppConnectionService.hasInternetConnection()) { + if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) { + checkFileSize(true); + } else { + new Thread(new FileDownloader(true)).start(); + } + return true; + } else { + return false; + } + } + + public void init(Message message) { + this.message = message; + this.message.setDownloadable(this); + try { + mUrl = new URL(message.getBody()); + this.file = mXmppConnectionService.getFileBackend().getFile( + message, false); + String reference = mUrl.getRef(); + if (reference != null && reference.length() == 96) { + this.file.setKey(CryptoHelper.hexToBytes(reference)); + } + if (this.message.getEncryption() == Message.ENCRYPTION_OTR + && this.file.getKey() == null) { + this.message.setEncryption(Message.ENCRYPTION_NONE); + } + checkFileSize(false); + } catch (MalformedURLException e) { + this.cancel(); + } + } + + private void checkFileSize(boolean interactive) { + new Thread(new FileSizeChecker(interactive)).start(); + } + + public void cancel() { + mHttpConnectionManager.finishConnection(this); + message.setDownloadable(null); + mXmppConnectionService.updateConversationUi(); + } + + private void finish() { + Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + intent.setData(Uri.fromFile(file)); + mXmppConnectionService.sendBroadcast(intent); + message.setDownloadable(null); + mHttpConnectionManager.finishConnection(this); + } + + private void changeStatus(int status) { + this.mStatus = status; + mXmppConnectionService.updateConversationUi(); + } + + private void setupTrustManager(HttpsURLConnection connection, + boolean interactive) { + X509TrustManager trustManager; + HostnameVerifier hostnameVerifier; + if (interactive) { + trustManager = mXmppConnectionService.getMemorizingTrustManager(); + hostnameVerifier = mXmppConnectionService + .getMemorizingTrustManager().wrapHostnameVerifier( + new StrictHostnameVerifier()); + } else { + trustManager = mXmppConnectionService.getMemorizingTrustManager() + .getNonInteractive(); + hostnameVerifier = mXmppConnectionService + .getMemorizingTrustManager() + .wrapHostnameVerifierNonInteractive( + new StrictHostnameVerifier()); + } + try { + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, new X509TrustManager[] { trustManager }, + mXmppConnectionService.getRNG()); + connection.setSSLSocketFactory(sc.getSocketFactory()); + connection.setHostnameVerifier(hostnameVerifier); + } catch (KeyManagementException e) { + return; + } catch (NoSuchAlgorithmException e) { + return; + } + } + + private class FileSizeChecker implements Runnable { + + private boolean interactive = false; + + public FileSizeChecker(boolean interactive) { + this.interactive = interactive; + } + + @Override + public void run() { + long size; + try { + size = retrieveFileSize(); + } catch (SSLHandshakeException e) { + changeStatus(STATUS_OFFER_CHECK_FILESIZE); + return; + } catch (IOException e) { + cancel(); + return; + } + file.setExpectedSize(size); + if (size <= mHttpConnectionManager.getAutoAcceptFileSize()) { + new Thread(new FileDownloader(interactive)).start(); + } else { + changeStatus(STATUS_OFFER); + } + } + + private long retrieveFileSize() throws IOException, + SSLHandshakeException { + changeStatus(STATUS_CHECKING); + HttpURLConnection connection = (HttpURLConnection) mUrl + .openConnection(); + connection.setRequestMethod("HEAD"); + if (connection instanceof HttpsURLConnection) { + setupTrustManager((HttpsURLConnection) connection, interactive); + } + connection.connect(); + String contentLength = connection.getHeaderField("Content-Length"); + if (contentLength == null) { + throw new IOException(); + } + try { + return Long.parseLong(contentLength, 10); + } catch (NumberFormatException e) { + throw new IOException(); + } + } + + } + + private class FileDownloader implements Runnable { + + private boolean interactive = false; + + public FileDownloader(boolean interactive) { + this.interactive = interactive; + } + + @Override + public void run() { + try { + changeStatus(STATUS_DOWNLOADING); + download(); + updateImageBounds(); + finish(); + } catch (SSLHandshakeException e) { + changeStatus(STATUS_OFFER); + } catch (IOException e) { + cancel(); + } + } + + private void download() throws SSLHandshakeException, IOException { + HttpURLConnection connection = (HttpURLConnection) mUrl + .openConnection(); + if (connection instanceof HttpsURLConnection) { + setupTrustManager((HttpsURLConnection) connection, interactive); + } + connection.connect(); + BufferedInputStream is = new BufferedInputStream( + connection.getInputStream()); + OutputStream os = file.createOutputStream(); + int count = -1; + byte[] buffer = new byte[1024]; + while ((count = is.read(buffer)) != -1) { + os.write(buffer, 0, count); + } + os.flush(); + os.close(); + is.close(); + } + + private void updateImageBounds() { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(file.getAbsolutePath(), options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + message.setBody(mUrl.toString() + "," + file.getSize() + ',' + + imageWidth + ',' + imageHeight); + message.setType(Message.TYPE_IMAGE); + mXmppConnectionService.updateMessage(message); + } + + } + + @Override + public int getStatus() { + return this.mStatus; + } + + @Override + public long getFileSize() { + if (this.file != null) { + return this.file.getExpectedSize(); + } else { + return 0; + } + } +} \ No newline at end of file diff --git a/conversations/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java b/conversations/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java new file mode 100644 index 000000000..9a2a24052 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java @@ -0,0 +1,28 @@ +package eu.siacs.conversations.http; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.AbstractConnectionManager; +import eu.siacs.conversations.services.XmppConnectionService; + +public class HttpConnectionManager extends AbstractConnectionManager { + + public HttpConnectionManager(XmppConnectionService service) { + super(service); + } + + private List connections = new CopyOnWriteArrayList(); + + public HttpConnection createNewConnection(Message message) { + HttpConnection connection = new HttpConnection(this); + connection.init(message); + this.connections.add(connection); + return connection; + } + + public void finishConnection(HttpConnection connection) { + this.connections.remove(connection); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/parser/AbstractParser.java b/conversations/src/main/java/eu/siacs/conversations/parser/AbstractParser.java new file mode 100644 index 000000000..5541c1c61 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/parser/AbstractParser.java @@ -0,0 +1,92 @@ +package eu.siacs.conversations.parser; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Locale; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; + +public abstract class AbstractParser { + + protected XmppConnectionService mXmppConnectionService; + + protected AbstractParser(XmppConnectionService service) { + this.mXmppConnectionService = service; + } + + protected long getTimestamp(Element packet) { + long now = System.currentTimeMillis(); + ArrayList stamps = new ArrayList(); + for (Element child : packet.getChildren()) { + if (child.getName().equals("delay")) { + stamps.add(child.getAttribute("stamp").replace("Z", "+0000")); + } + } + Collections.sort(stamps); + if (stamps.size() >= 1) { + try { + String stamp = stamps.get(stamps.size() - 1); + if (stamp.contains(".")) { + Date date = new SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US) + .parse(stamp); + if (now < date.getTime()) { + return now; + } else { + return date.getTime(); + } + } else { + Date date = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", + Locale.US).parse(stamp); + if (now < date.getTime()) { + return now; + } else { + return date.getTime(); + } + } + } catch (ParseException e) { + return now; + } + } else { + return now; + } + } + + protected void updateLastseen(Element packet, Account account, + boolean presenceOverwrite) { + String[] fromParts = packet.getAttribute("from").split("/", 2); + String from = fromParts[0]; + String presence = null; + if (fromParts.length >= 2) { + presence = fromParts[1]; + } else { + presence = ""; + } + Contact contact = account.getRoster().getContact(from); + long timestamp = getTimestamp(packet); + if (timestamp >= contact.lastseen.time) { + contact.lastseen.time = timestamp; + if ((presence != null) && (presenceOverwrite)) { + contact.lastseen.presence = presence; + } + } + } + + protected String avatarData(Element items) { + Element item = items.findChild("item"); + if (item == null) { + return null; + } + Element data = item.findChild("data", "urn:xmpp:avatar:data"); + if (data == null) { + return null; + } + return data.getContent(); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/parser/IqParser.java b/conversations/src/main/java/eu/siacs/conversations/parser/IqParser.java new file mode 100644 index 000000000..df6754f26 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/parser/IqParser.java @@ -0,0 +1,92 @@ +package eu.siacs.conversations.parser; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class IqParser extends AbstractParser implements OnIqPacketReceived { + + public IqParser(XmppConnectionService service) { + super(service); + } + + public void rosterItems(Account account, Element query) { + String version = query.getAttribute("ver"); + if (version != null) { + account.getRoster().setVersion(version); + } + for (Element item : query.getChildren()) { + if (item.getName().equals("item")) { + String jid = item.getAttribute("jid"); + String name = item.getAttribute("name"); + String subscription = item.getAttribute("subscription"); + Contact contact = account.getRoster().getContact(jid); + if (!contact.getOption(Contact.Options.DIRTY_PUSH)) { + contact.setServerName(name); + } + if (subscription != null) { + if (subscription.equals("remove")) { + contact.resetOption(Contact.Options.IN_ROSTER); + contact.resetOption(Contact.Options.DIRTY_DELETE); + contact.resetOption(Contact.Options.PREEMPTIVE_GRANT); + } else { + contact.setOption(Contact.Options.IN_ROSTER); + contact.resetOption(Contact.Options.DIRTY_PUSH); + contact.parseSubscriptionFromElement(item); + } + } + } + } + mXmppConnectionService.updateRosterUi(); + } + + public String avatarData(IqPacket packet) { + Element pubsub = packet.findChild("pubsub", + "http://jabber.org/protocol/pubsub"); + if (pubsub == null) { + return null; + } + Element items = pubsub.findChild("items"); + if (items == null) { + return null; + } + return super.avatarData(items); + } + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + if (packet.hasChild("query", "jabber:iq:roster")) { + String from = packet.getFrom(); + if ((from == null) || (from.equals(account.getJid()))) { + Element query = packet.findChild("query"); + this.rosterItems(account, query); + } + } else if (packet.hasChild("open", "http://jabber.org/protocol/ibb") + || packet.hasChild("data", "http://jabber.org/protocol/ibb")) { + mXmppConnectionService.getJingleConnectionManager() + .deliverIbbPacket(account, packet); + } else if (packet.hasChild("query", + "http://jabber.org/protocol/disco#info")) { + IqPacket response = mXmppConnectionService.getIqGenerator() + .discoResponse(packet); + account.getXmppConnection().sendIqPacket(response, null); + } else if (packet.hasChild("ping", "urn:xmpp:ping")) { + IqPacket response = packet.generateRespone(IqPacket.TYPE_RESULT); + mXmppConnectionService.sendIqPacket(account, response, null); + } else { + if ((packet.getType() == IqPacket.TYPE_GET) + || (packet.getType() == IqPacket.TYPE_SET)) { + IqPacket response = packet.generateRespone(IqPacket.TYPE_ERROR); + Element error = response.addChild("error"); + error.setAttribute("type", "cancel"); + error.addChild("feature-not-implemented", + "urn:ietf:params:xml:ns:xmpp-stanzas"); + account.getXmppConnection().sendIqPacket(response, null); + } + } + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/conversations/src/main/java/eu/siacs/conversations/parser/MessageParser.java new file mode 100644 index 000000000..b5e14305a --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -0,0 +1,517 @@ +package eu.siacs.conversations.parser; + +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionStatus; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.NotificationService; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnMessagePacketReceived; +import eu.siacs.conversations.xmpp.pep.Avatar; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; + +public class MessageParser extends AbstractParser implements + OnMessagePacketReceived { + public MessageParser(XmppConnectionService service) { + super(service); + } + + private Message parseChat(MessagePacket packet, Account account) { + String[] fromParts = packet.getFrom().split("/", 2); + Conversation conversation = mXmppConnectionService + .findOrCreateConversation(account, fromParts[0], false); + updateLastseen(packet, account, true); + String pgpBody = getPgpBody(packet); + Message finishedMessage; + if (pgpBody != null) { + finishedMessage = new Message(conversation, packet.getFrom(), + pgpBody, Message.ENCRYPTION_PGP, Message.STATUS_RECEIVED); + } else { + finishedMessage = new Message(conversation, packet.getFrom(), + packet.getBody(), Message.ENCRYPTION_NONE, + Message.STATUS_RECEIVED); + } + finishedMessage.setRemoteMsgId(packet.getId()); + finishedMessage.markable = isMarkable(packet); + if (conversation.getMode() == Conversation.MODE_MULTI + && fromParts.length >= 2) { + finishedMessage.setType(Message.TYPE_PRIVATE); + finishedMessage.setPresence(fromParts[1]); + finishedMessage.setTrueCounterpart(conversation.getMucOptions() + .getTrueCounterpart(fromParts[1])); + if (conversation.hasDuplicateMessage(finishedMessage)) { + return null; + } + + } + finishedMessage.setTime(getTimestamp(packet)); + return finishedMessage; + } + + private Message parseOtrChat(MessagePacket packet, Account account) { + boolean properlyAddressed = (packet.getTo().split("/", 2).length == 2) + || (account.countPresences() == 1); + String[] fromParts = packet.getFrom().split("/", 2); + Conversation conversation = mXmppConnectionService + .findOrCreateConversation(account, fromParts[0], false); + String presence; + if (fromParts.length >= 2) { + presence = fromParts[1]; + } else { + presence = ""; + } + updateLastseen(packet, account, true); + String body = packet.getBody(); + if (body.matches("^\\?OTRv\\d*\\?")) { + conversation.endOtrIfNeeded(); + } + if (!conversation.hasValidOtrSession()) { + if (properlyAddressed) { + conversation.startOtrSession(mXmppConnectionService, presence, + false); + } else { + return null; + } + } else { + String foreignPresence = conversation.getOtrSession() + .getSessionID().getUserID(); + if (!foreignPresence.equals(presence)) { + conversation.endOtrIfNeeded(); + if (properlyAddressed) { + conversation.startOtrSession(mXmppConnectionService, + presence, false); + } else { + return null; + } + } + } + try { + Session otrSession = conversation.getOtrSession(); + SessionStatus before = otrSession.getSessionStatus(); + body = otrSession.transformReceiving(body); + SessionStatus after = otrSession.getSessionStatus(); + if ((before != after) && (after == SessionStatus.ENCRYPTED)) { + mXmppConnectionService.onOtrSessionEstablished(conversation); + } else if ((before != after) && (after == SessionStatus.FINISHED)) { + conversation.resetOtrSession(); + mXmppConnectionService.updateConversationUi(); + } + if ((body == null) || (body.isEmpty())) { + return null; + } + if (body.startsWith(CryptoHelper.FILETRANSFER)) { + String key = body.substring(CryptoHelper.FILETRANSFER.length()); + conversation.setSymmetricKey(CryptoHelper.hexToBytes(key)); + return null; + } + Message finishedMessage = new Message(conversation, + packet.getFrom(), body, Message.ENCRYPTION_OTR, + Message.STATUS_RECEIVED); + finishedMessage.setTime(getTimestamp(packet)); + finishedMessage.setRemoteMsgId(packet.getId()); + finishedMessage.markable = isMarkable(packet); + return finishedMessage; + } catch (Exception e) { + String receivedId = packet.getId(); + if (receivedId != null) { + mXmppConnectionService.replyWithNotAcceptable(account, packet); + } + conversation.resetOtrSession(); + return null; + } + } + + private Message parseGroupchat(MessagePacket packet, Account account) { + int status; + String[] fromParts = packet.getFrom().split("/", 2); + if (mXmppConnectionService.find(account.pendingConferenceLeaves, + account, fromParts[0]) != null) { + return null; + } + Conversation conversation = mXmppConnectionService + .findOrCreateConversation(account, fromParts[0], true); + if (packet.hasChild("subject")) { + conversation.getMucOptions().setSubject( + packet.findChild("subject").getContent()); + mXmppConnectionService.updateConversationUi(); + return null; + } + if ((fromParts.length == 1)) { + return null; + } + String counterPart = fromParts[1]; + if (counterPart.equals(conversation.getMucOptions().getActualNick())) { + if (mXmppConnectionService.markMessage(conversation, + packet.getId(), Message.STATUS_SEND)) { + return null; + } else { + status = Message.STATUS_SEND; + } + } else { + status = Message.STATUS_RECEIVED; + } + String pgpBody = getPgpBody(packet); + Message finishedMessage; + if (pgpBody == null) { + finishedMessage = new Message(conversation, counterPart, + packet.getBody(), Message.ENCRYPTION_NONE, status); + } else { + finishedMessage = new Message(conversation, counterPart, pgpBody, + Message.ENCRYPTION_PGP, status); + } + finishedMessage.setRemoteMsgId(packet.getId()); + finishedMessage.markable = isMarkable(packet); + if (status == Message.STATUS_RECEIVED) { + finishedMessage.setTrueCounterpart(conversation.getMucOptions() + .getTrueCounterpart(counterPart)); + } + if (packet.hasChild("delay") + && conversation.hasDuplicateMessage(finishedMessage)) { + return null; + } + finishedMessage.setTime(getTimestamp(packet)); + return finishedMessage; + } + + private Message parseCarbonMessage(MessagePacket packet, Account account) { + int status; + String fullJid; + Element forwarded; + if (packet.hasChild("received", "urn:xmpp:carbons:2")) { + forwarded = packet.findChild("received", "urn:xmpp:carbons:2") + .findChild("forwarded", "urn:xmpp:forward:0"); + status = Message.STATUS_RECEIVED; + } else if (packet.hasChild("sent", "urn:xmpp:carbons:2")) { + forwarded = packet.findChild("sent", "urn:xmpp:carbons:2") + .findChild("forwarded", "urn:xmpp:forward:0"); + status = Message.STATUS_SEND; + } else { + return null; + } + if (forwarded == null) { + return null; + } + Element message = forwarded.findChild("message"); + if (message == null) { + return null; + } + if (!message.hasChild("body")) { + if (status == Message.STATUS_RECEIVED + && message.getAttribute("from") != null) { + parseNonMessage(message, account); + } else if (status == Message.STATUS_SEND + && message.hasChild("displayed", "urn:xmpp:chat-markers:0")) { + String to = message.getAttribute("to"); + if (to != null) { + Conversation conversation = mXmppConnectionService.find( + mXmppConnectionService.getConversations(), account, + to.split("/")[0]); + if (conversation != null) { + mXmppConnectionService.markRead(conversation, false); + } + } + } + return null; + } + if (status == Message.STATUS_RECEIVED) { + fullJid = message.getAttribute("from"); + if (fullJid == null) { + return null; + } else { + updateLastseen(message, account, true); + } + } else { + fullJid = message.getAttribute("to"); + if (fullJid == null) { + return null; + } + } + String[] parts = fullJid.split("/", 2); + Conversation conversation = mXmppConnectionService + .findOrCreateConversation(account, parts[0], false); + String pgpBody = getPgpBody(message); + Message finishedMessage; + if (pgpBody != null) { + finishedMessage = new Message(conversation, fullJid, pgpBody, + Message.ENCRYPTION_PGP, status); + } else { + String body = message.findChild("body").getContent(); + finishedMessage = new Message(conversation, fullJid, body, + Message.ENCRYPTION_NONE, status); + } + finishedMessage.setTime(getTimestamp(message)); + finishedMessage.setRemoteMsgId(message.getAttribute("id")); + finishedMessage.markable = isMarkable(message); + if (conversation.getMode() == Conversation.MODE_MULTI + && parts.length >= 2) { + finishedMessage.setType(Message.TYPE_PRIVATE); + finishedMessage.setPresence(parts[1]); + finishedMessage.setTrueCounterpart(conversation.getMucOptions() + .getTrueCounterpart(parts[1])); + if (conversation.hasDuplicateMessage(finishedMessage)) { + return null; + } + } + + return finishedMessage; + } + + private void parseError(MessagePacket packet, Account account) { + String[] fromParts = packet.getFrom().split("/", 2); + mXmppConnectionService.markMessage(account, fromParts[0], + packet.getId(), Message.STATUS_SEND_FAILED); + } + + private void parseNonMessage(Element packet, Account account) { + String from = packet.getAttribute("from"); + if (packet.hasChild("event", "http://jabber.org/protocol/pubsub#event")) { + Element event = packet.findChild("event", + "http://jabber.org/protocol/pubsub#event"); + parseEvent(event, packet.getAttribute("from"), account); + } else if (from != null + && packet.hasChild("displayed", "urn:xmpp:chat-markers:0")) { + String id = packet + .findChild("displayed", "urn:xmpp:chat-markers:0") + .getAttribute("id"); + updateLastseen(packet, account, true); + mXmppConnectionService.markMessage(account, from.split("/", 2)[0], + id, Message.STATUS_SEND_DISPLAYED); + } else if (from != null + && packet.hasChild("received", "urn:xmpp:chat-markers:0")) { + String id = packet.findChild("received", "urn:xmpp:chat-markers:0") + .getAttribute("id"); + updateLastseen(packet, account, false); + mXmppConnectionService.markMessage(account, from.split("/", 2)[0], + id, Message.STATUS_SEND_RECEIVED); + } else if (from != null + && packet.hasChild("received", "urn:xmpp:receipts")) { + String id = packet.findChild("received", "urn:xmpp:receipts") + .getAttribute("id"); + updateLastseen(packet, account, false); + mXmppConnectionService.markMessage(account, from.split("/", 2)[0], + id, Message.STATUS_SEND_RECEIVED); + } else if (packet.hasChild("x", "http://jabber.org/protocol/muc#user")) { + Element x = packet.findChild("x", + "http://jabber.org/protocol/muc#user"); + if (x.hasChild("invite")) { + Conversation conversation = mXmppConnectionService + .findOrCreateConversation(account, + packet.getAttribute("from"), true); + if (!conversation.getMucOptions().online()) { + if (x.hasChild("password")) { + Element password = x.findChild("password"); + conversation.getMucOptions().setPassword( + password.getContent()); + mXmppConnectionService.databaseBackend + .updateConversation(conversation); + } + mXmppConnectionService.joinMuc(conversation); + mXmppConnectionService.updateConversationUi(); + } + } + } else if (packet.hasChild("x", "jabber:x:conference")) { + Element x = packet.findChild("x", "jabber:x:conference"); + String jid = x.getAttribute("jid"); + String password = x.getAttribute("password"); + if (jid != null) { + Conversation conversation = mXmppConnectionService + .findOrCreateConversation(account, jid, true); + if (!conversation.getMucOptions().online()) { + if (password != null) { + conversation.getMucOptions().setPassword(password); + mXmppConnectionService.databaseBackend + .updateConversation(conversation); + } + mXmppConnectionService.joinMuc(conversation); + mXmppConnectionService.updateConversationUi(); + } + } + } + } + + private void parseEvent(Element event, String from, Account account) { + Element items = event.findChild("items"); + String node = items.getAttribute("node"); + if (node != null) { + if (node.equals("urn:xmpp:avatar:metadata")) { + Avatar avatar = Avatar.parseMetadata(items); + if (avatar != null) { + avatar.owner = from; + if (mXmppConnectionService.getFileBackend().isAvatarCached( + avatar)) { + if (account.getJid().equals(from)) { + if (account.setAvatar(avatar.getFilename())) { + mXmppConnectionService.databaseBackend + .updateAccount(account); + } + mXmppConnectionService.getAvatarService().clear( + account); + mXmppConnectionService.updateConversationUi(); + mXmppConnectionService.updateAccountUi(); + } else { + Contact contact = account.getRoster().getContact( + from); + contact.setAvatar(avatar.getFilename()); + mXmppConnectionService.getAvatarService().clear( + contact); + mXmppConnectionService.updateConversationUi(); + mXmppConnectionService.updateRosterUi(); + } + } else { + mXmppConnectionService.fetchAvatar(account, avatar); + } + } + } else if (node.equals("http://jabber.org/protocol/nick")) { + Element item = items.findChild("item"); + if (item != null) { + Element nick = item.findChild("nick", + "http://jabber.org/protocol/nick"); + if (nick != null) { + if (from != null) { + Contact contact = account.getRoster().getContact( + from); + contact.setPresenceName(nick.getContent()); + } + } + } + } + } + } + + private String getPgpBody(Element message) { + Element child = message.findChild("x", "jabber:x:encrypted"); + if (child == null) { + return null; + } else { + return child.getContent(); + } + } + + private boolean isMarkable(Element message) { + return message.hasChild("markable", "urn:xmpp:chat-markers:0"); + } + + @Override + public void onMessagePacketReceived(Account account, MessagePacket packet) { + Message message = null; + boolean notify = mXmppConnectionService.getPreferences().getBoolean( + "show_notification", true); + boolean alwaysNotifyInConference = notify + && mXmppConnectionService.getPreferences().getBoolean( + "always_notify_in_conference", false); + + this.parseNick(packet, account); + + if ((packet.getType() == MessagePacket.TYPE_CHAT || packet.getType() == MessagePacket.TYPE_NORMAL)) { + if ((packet.getBody() != null) + && (packet.getBody().startsWith("?OTR"))) { + message = this.parseOtrChat(packet, account); + if (message != null) { + message.markUnread(); + } + } else if (packet.hasChild("body") + && !(packet.hasChild("x", + "http://jabber.org/protocol/muc#user"))) { + message = this.parseChat(packet, account); + if (message != null) { + message.markUnread(); + } + } else if (packet.hasChild("received", "urn:xmpp:carbons:2") + || (packet.hasChild("sent", "urn:xmpp:carbons:2"))) { + message = this.parseCarbonMessage(packet, account); + if (message != null) { + if (message.getStatus() == Message.STATUS_SEND) { + account.activateGracePeriod(); + notify = false; + mXmppConnectionService.markRead( + message.getConversation(), false); + } else { + message.markUnread(); + } + } + } else { + parseNonMessage(packet, account); + } + } else if (packet.getType() == MessagePacket.TYPE_GROUPCHAT) { + message = this.parseGroupchat(packet, account); + if (message != null) { + if (message.getStatus() == Message.STATUS_RECEIVED) { + message.markUnread(); + notify = alwaysNotifyInConference + || NotificationService + .wasHighlightedOrPrivate(message); + } else { + mXmppConnectionService.markRead(message.getConversation(), + false); + account.activateGracePeriod(); + notify = false; + } + } + } else if (packet.getType() == MessagePacket.TYPE_ERROR) { + this.parseError(packet, account); + return; + } else if (packet.getType() == MessagePacket.TYPE_HEADLINE) { + this.parseHeadline(packet, account); + return; + } + if ((message == null) || (message.getBody() == null)) { + return; + } + if ((mXmppConnectionService.confirmMessages()) + && ((packet.getId() != null))) { + if (packet.hasChild("markable", "urn:xmpp:chat-markers:0")) { + MessagePacket receipt = mXmppConnectionService + .getMessageGenerator().received(account, packet, + "urn:xmpp:chat-markers:0"); + mXmppConnectionService.sendMessagePacket(account, receipt); + } + if (packet.hasChild("request", "urn:xmpp:receipts")) { + MessagePacket receipt = mXmppConnectionService + .getMessageGenerator().received(account, packet, + "urn:xmpp:receipts"); + mXmppConnectionService.sendMessagePacket(account, receipt); + } + } + Conversation conversation = message.getConversation(); + conversation.add(message); + if (packet.getType() != MessagePacket.TYPE_ERROR) { + if (message.getEncryption() == Message.ENCRYPTION_NONE + || mXmppConnectionService.saveEncryptedMessages()) { + mXmppConnectionService.databaseBackend.createMessage(message); + } + } + if (message.bodyContainsDownloadable()) { + this.mXmppConnectionService.getHttpConnectionManager() + .createNewConnection(message); + } + notify = notify && !conversation.isMuted(); + if (notify) { + mXmppConnectionService.getNotificationService().push(message); + } + mXmppConnectionService.updateConversationUi(); + } + + private void parseHeadline(MessagePacket packet, Account account) { + if (packet.hasChild("event", "http://jabber.org/protocol/pubsub#event")) { + Element event = packet.findChild("event", + "http://jabber.org/protocol/pubsub#event"); + parseEvent(event, packet.getFrom(), account); + } + } + + private void parseNick(MessagePacket packet, Account account) { + Element nick = packet.findChild("nick", + "http://jabber.org/protocol/nick"); + if (nick != null) { + if (packet.getFrom() != null) { + Contact contact = account.getRoster().getContact( + packet.getFrom()); + contact.setPresenceName(nick.getContent()); + } + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/parser/PresenceParser.java b/conversations/src/main/java/eu/siacs/conversations/parser/PresenceParser.java new file mode 100644 index 000000000..4e90cda8c --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/parser/PresenceParser.java @@ -0,0 +1,133 @@ +package eu.siacs.conversations.parser; + +import eu.siacs.conversations.crypto.PgpEngine; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Presences; +import eu.siacs.conversations.generator.PresenceGenerator; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnPresencePacketReceived; +import eu.siacs.conversations.xmpp.stanzas.PresencePacket; + +public class PresenceParser extends AbstractParser implements + OnPresencePacketReceived { + + public PresenceParser(XmppConnectionService service) { + super(service); + } + + public void parseConferencePresence(PresencePacket packet, Account account) { + PgpEngine mPgpEngine = mXmppConnectionService.getPgpEngine(); + if (packet.hasChild("x", "http://jabber.org/protocol/muc#user")) { + Conversation muc = mXmppConnectionService.find(account, packet + .getAttribute("from").split("/", 2)[0]); + if (muc != null) { + boolean before = muc.getMucOptions().online(); + muc.getMucOptions().processPacket(packet, mPgpEngine); + if (before != muc.getMucOptions().online()) { + mXmppConnectionService.updateConversationUi(); + } + mXmppConnectionService.getAvatarService().clear(muc); + } + } else if (packet.hasChild("x", "http://jabber.org/protocol/muc")) { + Conversation muc = mXmppConnectionService.find(account, packet + .getAttribute("from").split("/", 2)[0]); + if (muc != null) { + boolean before = muc.getMucOptions().online(); + muc.getMucOptions().processPacket(packet, mPgpEngine); + if (before != muc.getMucOptions().online()) { + mXmppConnectionService.updateConversationUi(); + } + mXmppConnectionService.getAvatarService().clear(muc); + } + } + } + + public void parseContactPresence(PresencePacket packet, Account account) { + PresenceGenerator mPresenceGenerator = mXmppConnectionService + .getPresenceGenerator(); + if (packet.getFrom() == null) { + return; + } + String[] fromParts = packet.getFrom().split("/", 2); + String type = packet.getAttribute("type"); + if (fromParts[0].equals(account.getJid())) { + if (fromParts.length == 2) { + if (type == null) { + account.updatePresence(fromParts[1], + Presences.parseShow(packet.findChild("show"))); + } else if (type.equals("unavailable")) { + account.removePresence(fromParts[1]); + account.deactivateGracePeriod(); + } + } + } else { + Contact contact = account.getRoster().getContact(packet.getFrom()); + if (type == null) { + String presence; + if (fromParts.length >= 2) { + presence = fromParts[1]; + } else { + presence = ""; + } + int sizeBefore = contact.getPresences().size(); + contact.updatePresence(presence, + Presences.parseShow(packet.findChild("show"))); + PgpEngine pgp = mXmppConnectionService.getPgpEngine(); + if (pgp != null) { + Element x = packet.findChild("x", "jabber:x:signed"); + if (x != null) { + Element status = packet.findChild("status"); + String msg; + if (status != null) { + msg = status.getContent(); + } else { + msg = ""; + } + contact.setPgpKeyId(pgp.fetchKeyId(account, msg, + x.getContent())); + } + } + boolean online = sizeBefore < contact.getPresences().size(); + updateLastseen(packet, account, true); + mXmppConnectionService.onContactStatusChanged + .onContactStatusChanged(contact, online); + } else if (type.equals("unavailable")) { + if (fromParts.length != 2) { + contact.clearPresences(); + } else { + contact.removePresence(fromParts[1]); + } + mXmppConnectionService.onContactStatusChanged + .onContactStatusChanged(contact, false); + } else if (type.equals("subscribe")) { + if (contact.getOption(Contact.Options.PREEMPTIVE_GRANT)) { + mXmppConnectionService.sendPresencePacket(account, + mPresenceGenerator.sendPresenceUpdatesTo(contact)); + } else { + contact.setOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST); + } + } + Element nick = packet.findChild("nick", + "http://jabber.org/protocol/nick"); + if (nick != null) { + contact.setPresenceName(nick.getContent()); + } + } + mXmppConnectionService.updateRosterUi(); + } + + @Override + public void onPresencePacketReceived(Account account, PresencePacket packet) { + if (packet.hasChild("x", "http://jabber.org/protocol/muc#user")) { + this.parseConferencePresence(packet, account); + } else if (packet.hasChild("x", "http://jabber.org/protocol/muc")) { + this.parseConferencePresence(packet, account); + } else { + this.parseContactPresence(packet, account); + } + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/conversations/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java new file mode 100644 index 000000000..b49cf4e61 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -0,0 +1,335 @@ +package eu.siacs.conversations.persistance; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Roster; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteCantOpenDatabaseException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class DatabaseBackend extends SQLiteOpenHelper { + + private static DatabaseBackend instance = null; + + private static final String DATABASE_NAME = "history"; + private static final int DATABASE_VERSION = 8; + + private static String CREATE_CONTATCS_STATEMENT = "create table " + + Contact.TABLENAME + "(" + Contact.ACCOUNT + " TEXT, " + + Contact.SERVERNAME + " TEXT, " + Contact.SYSTEMNAME + " TEXT," + + Contact.JID + " TEXT," + Contact.KEYS + " TEXT," + + Contact.PHOTOURI + " TEXT," + Contact.OPTIONS + " NUMBER," + + Contact.SYSTEMACCOUNT + " NUMBER, " + Contact.AVATAR + " TEXT, " + + "FOREIGN KEY(" + Contact.ACCOUNT + ") REFERENCES " + + Account.TABLENAME + "(" + Account.UUID + + ") ON DELETE CASCADE, UNIQUE(" + Contact.ACCOUNT + ", " + + Contact.JID + ") ON CONFLICT REPLACE);"; + + private DatabaseBackend(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL("PRAGMA foreign_keys=ON;"); + db.execSQL("create table " + Account.TABLENAME + "(" + Account.UUID + + " TEXT PRIMARY KEY," + Account.USERNAME + " TEXT," + + Account.SERVER + " TEXT," + Account.PASSWORD + " TEXT," + + Account.ROSTERVERSION + " TEXT," + Account.OPTIONS + + " NUMBER, " + Account.AVATAR + " TEXT, " + Account.KEYS + + " TEXT)"); + db.execSQL("create table " + Conversation.TABLENAME + " (" + + Conversation.UUID + " TEXT PRIMARY KEY, " + Conversation.NAME + + " TEXT, " + Conversation.CONTACT + " TEXT, " + + Conversation.ACCOUNT + " TEXT, " + Conversation.CONTACTJID + + " TEXT, " + Conversation.CREATED + " NUMBER, " + + Conversation.STATUS + " NUMBER, " + Conversation.MODE + + " NUMBER, " + Conversation.ATTRIBUTES + " TEXT, FOREIGN KEY(" + + Conversation.ACCOUNT + ") REFERENCES " + Account.TABLENAME + + "(" + Account.UUID + ") ON DELETE CASCADE);"); + db.execSQL("create table " + Message.TABLENAME + "( " + Message.UUID + + " TEXT PRIMARY KEY, " + Message.CONVERSATION + " TEXT, " + + Message.TIME_SENT + " NUMBER, " + Message.COUNTERPART + + " TEXT, " + Message.TRUE_COUNTERPART + " TEXT," + + Message.BODY + " TEXT, " + Message.ENCRYPTION + " NUMBER, " + + Message.STATUS + " NUMBER," + Message.TYPE + " NUMBER, " + + Message.REMOTE_MSG_ID + " TEXT, FOREIGN KEY(" + + Message.CONVERSATION + ") REFERENCES " + + Conversation.TABLENAME + "(" + Conversation.UUID + + ") ON DELETE CASCADE);"); + + db.execSQL(CREATE_CONTATCS_STATEMENT); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion < 2 && newVersion >= 2) { + db.execSQL("update " + Account.TABLENAME + " set " + + Account.OPTIONS + " = " + Account.OPTIONS + " | 8"); + } + if (oldVersion < 3 && newVersion >= 3) { + db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + + Message.TYPE + " NUMBER"); + } + if (oldVersion < 5 && newVersion >= 5) { + db.execSQL("DROP TABLE " + Contact.TABLENAME); + db.execSQL(CREATE_CONTATCS_STATEMENT); + db.execSQL("UPDATE " + Account.TABLENAME + " SET " + + Account.ROSTERVERSION + " = NULL"); + } + if (oldVersion < 6 && newVersion >= 6) { + db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + + Message.TRUE_COUNTERPART + " TEXT"); + } + if (oldVersion < 7 && newVersion >= 7) { + db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + + Message.REMOTE_MSG_ID + " TEXT"); + db.execSQL("ALTER TABLE " + Contact.TABLENAME + " ADD COLUMN " + + Contact.AVATAR + " TEXT"); + db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + + Account.AVATAR + " TEXT"); + } + if (oldVersion < 8 && newVersion >= 8) { + db.execSQL("ALTER TABLE " + Conversation.TABLENAME + " ADD COLUMN " + + Conversation.ATTRIBUTES + " TEXT"); + } + } + + public static synchronized DatabaseBackend getInstance(Context context) { + if (instance == null) { + instance = new DatabaseBackend(context); + } + return instance; + } + + public void createConversation(Conversation conversation) { + SQLiteDatabase db = this.getWritableDatabase(); + db.insert(Conversation.TABLENAME, null, conversation.getContentValues()); + } + + public void createMessage(Message message) { + SQLiteDatabase db = this.getWritableDatabase(); + db.insert(Message.TABLENAME, null, message.getContentValues()); + } + + public void createAccount(Account account) { + SQLiteDatabase db = this.getWritableDatabase(); + db.insert(Account.TABLENAME, null, account.getContentValues()); + } + + public void createContact(Contact contact) { + SQLiteDatabase db = this.getWritableDatabase(); + db.insert(Contact.TABLENAME, null, contact.getContentValues()); + } + + public int getConversationCount() { + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor = db.rawQuery("select count(uuid) as count from " + + Conversation.TABLENAME + " where " + Conversation.STATUS + + "=" + Conversation.STATUS_AVAILABLE, null); + cursor.moveToFirst(); + return cursor.getInt(0); + } + + public CopyOnWriteArrayList getConversations(int status) { + CopyOnWriteArrayList list = new CopyOnWriteArrayList(); + SQLiteDatabase db = this.getReadableDatabase(); + String[] selectionArgs = { Integer.toString(status) }; + Cursor cursor = db.rawQuery("select * from " + Conversation.TABLENAME + + " where " + Conversation.STATUS + " = ? order by " + + Conversation.CREATED + " desc", selectionArgs); + while (cursor.moveToNext()) { + list.add(Conversation.fromCursor(cursor)); + } + return list; + } + + public ArrayList getMessages(Conversation conversations, int limit) { + return getMessages(conversations, limit, -1); + } + + public ArrayList getMessages(Conversation conversation, int limit, + long timestamp) { + ArrayList list = new ArrayList(); + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor; + if (timestamp == -1) { + String[] selectionArgs = { conversation.getUuid() }; + cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION + + "=?", selectionArgs, null, null, Message.TIME_SENT + + " DESC", String.valueOf(limit)); + } else { + String[] selectionArgs = { conversation.getUuid(), + Long.toString(timestamp) }; + cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION + + "=? and " + Message.TIME_SENT + " 0) { + cursor.moveToLast(); + do { + Message message = Message.fromCursor(cursor); + message.setConversation(conversation); + list.add(message); + } while (cursor.moveToPrevious()); + } + return list; + } + + public Conversation findConversation(Account account, String contactJid) { + SQLiteDatabase db = this.getReadableDatabase(); + String[] selectionArgs = { account.getUuid(), contactJid + "%" }; + Cursor cursor = db.query(Conversation.TABLENAME, null, + Conversation.ACCOUNT + "=? AND " + Conversation.CONTACTJID + + " like ?", selectionArgs, null, null, null); + if (cursor.getCount() == 0) + return null; + cursor.moveToFirst(); + return Conversation.fromCursor(cursor); + } + + public void updateConversation(Conversation conversation) { + SQLiteDatabase db = this.getWritableDatabase(); + String[] args = { conversation.getUuid() }; + db.update(Conversation.TABLENAME, conversation.getContentValues(), + Conversation.UUID + "=?", args); + } + + public List getAccounts() { + List list = new ArrayList(); + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor = db.query(Account.TABLENAME, null, null, null, null, + null, null); + while (cursor.moveToNext()) { + list.add(Account.fromCursor(cursor)); + } + cursor.close(); + return list; + } + + public void updateAccount(Account account) { + SQLiteDatabase db = this.getWritableDatabase(); + String[] args = { account.getUuid() }; + db.update(Account.TABLENAME, account.getContentValues(), Account.UUID + + "=?", args); + } + + public void deleteAccount(Account account) { + SQLiteDatabase db = this.getWritableDatabase(); + String[] args = { account.getUuid() }; + db.delete(Account.TABLENAME, Account.UUID + "=?", args); + } + + public boolean hasEnabledAccounts() { + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor = db.rawQuery("select count(" + Account.UUID + ") from " + + Account.TABLENAME + " where not options & (1 <<1)", null); + try { + cursor.moveToFirst(); + int count = cursor.getInt(0); + cursor.close(); + return (count > 0); + } catch (SQLiteCantOpenDatabaseException e) { + return true; // better safe than sorry + } + } + + @Override + public SQLiteDatabase getWritableDatabase() { + SQLiteDatabase db = super.getWritableDatabase(); + db.execSQL("PRAGMA foreign_keys=ON;"); + return db; + } + + public void updateMessage(Message message) { + SQLiteDatabase db = this.getWritableDatabase(); + String[] args = { message.getUuid() }; + db.update(Message.TABLENAME, message.getContentValues(), Message.UUID + + "=?", args); + } + + public void readRoster(Roster roster) { + SQLiteDatabase db = this.getReadableDatabase(); + Cursor cursor; + String args[] = { roster.getAccount().getUuid() }; + cursor = db.query(Contact.TABLENAME, null, Contact.ACCOUNT + "=?", + args, null, null, null); + while (cursor.moveToNext()) { + roster.initContact(Contact.fromCursor(cursor)); + } + cursor.close(); + } + + public void writeRoster(Roster roster) { + Account account = roster.getAccount(); + SQLiteDatabase db = this.getWritableDatabase(); + for (Contact contact : roster.getContacts()) { + if (contact.getOption(Contact.Options.IN_ROSTER)) { + db.insert(Contact.TABLENAME, null, contact.getContentValues()); + } else { + String where = Contact.ACCOUNT + "=? AND " + Contact.JID + "=?"; + String[] whereArgs = { account.getUuid(), contact.getJid() }; + db.delete(Contact.TABLENAME, where, whereArgs); + } + } + account.setRosterVersion(roster.getVersion()); + updateAccount(account); + } + + public void deleteMessage(Message message) { + SQLiteDatabase db = this.getWritableDatabase(); + String[] args = { message.getUuid() }; + db.delete(Message.TABLENAME, Message.UUID + "=?", args); + } + + public void deleteMessagesInConversation(Conversation conversation) { + SQLiteDatabase db = this.getWritableDatabase(); + String[] args = { conversation.getUuid() }; + db.delete(Message.TABLENAME, Message.CONVERSATION + "=?", args); + } + + public Conversation findConversationByUuid(String conversationUuid) { + SQLiteDatabase db = this.getReadableDatabase(); + String[] selectionArgs = { conversationUuid }; + Cursor cursor = db.query(Conversation.TABLENAME, null, + Conversation.UUID + "=?", selectionArgs, null, null, null); + if (cursor.getCount() == 0) { + return null; + } + cursor.moveToFirst(); + return Conversation.fromCursor(cursor); + } + + public Message findMessageByUuid(String messageUuid) { + SQLiteDatabase db = this.getReadableDatabase(); + String[] selectionArgs = { messageUuid }; + Cursor cursor = db.query(Message.TABLENAME, null, Message.UUID + "=?", + selectionArgs, null, null, null); + if (cursor.getCount() == 0) { + return null; + } + cursor.moveToFirst(); + return Message.fromCursor(cursor); + } + + public Account findAccountByUuid(String accountUuid) { + SQLiteDatabase db = this.getReadableDatabase(); + String[] selectionArgs = { accountUuid }; + Cursor cursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", + selectionArgs, null, null, null); + if (cursor.getCount() == 0) { + return null; + } + cursor.moveToFirst(); + return Account.fromCursor(cursor); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/conversations/src/main/java/eu/siacs/conversations/persistance/FileBackend.java new file mode 100644 index 000000000..b891e9ef5 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -0,0 +1,480 @@ +package eu.siacs.conversations.persistance; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; +import android.util.Base64; +import android.util.Base64OutputStream; +import android.util.Log; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.xmpp.pep.Avatar; + +public class FileBackend { + + private static int IMAGE_SIZE = 1920; + + private SimpleDateFormat imageDateFormat = new SimpleDateFormat( + "yyyyMMdd_HHmmssSSS", Locale.US); + + private XmppConnectionService mXmppConnectionService; + + public FileBackend(XmppConnectionService service) { + this.mXmppConnectionService = service; + } + + public DownloadableFile getFile(Message message) { + return getFile(message, true); + } + + public DownloadableFile getFile(Message message, boolean decrypted) { + StringBuilder filename = new StringBuilder(); + filename.append(getConversationsDirectory()); + filename.append(message.getUuid()); + if ((decrypted) || (message.getEncryption() == Message.ENCRYPTION_NONE)) { + filename.append(".webp"); + } else { + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + filename.append(".webp"); + } else { + filename.append(".webp.pgp"); + } + } + return new DownloadableFile(filename.toString()); + } + + public static String getConversationsDirectory() { + return Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES).getAbsolutePath() + + "/Conversations/"; + } + + public Bitmap resize(Bitmap originalBitmap, int size) { + int w = originalBitmap.getWidth(); + int h = originalBitmap.getHeight(); + if (Math.max(w, h) > size) { + int scalledW; + int scalledH; + if (w <= h) { + scalledW = (int) (w / ((double) h / size)); + scalledH = size; + } else { + scalledW = size; + scalledH = (int) (h / ((double) w / size)); + } + Bitmap scalledBitmap = Bitmap.createScaledBitmap(originalBitmap, + scalledW, scalledH, true); + return scalledBitmap; + } else { + return originalBitmap; + } + } + + public Bitmap rotate(Bitmap bitmap, int degree) { + int w = bitmap.getWidth(); + int h = bitmap.getHeight(); + Matrix mtx = new Matrix(); + mtx.postRotate(degree); + return Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true); + } + + public DownloadableFile copyImageToPrivateStorage(Message message, Uri image) + throws ImageCopyException { + return this.copyImageToPrivateStorage(message, image, 0); + } + + private DownloadableFile copyImageToPrivateStorage(Message message, + Uri image, int sampleSize) throws ImageCopyException { + try { + InputStream is = mXmppConnectionService.getContentResolver() + .openInputStream(image); + DownloadableFile file = getFile(message); + file.getParentFile().mkdirs(); + file.createNewFile(); + Bitmap originalBitmap; + BitmapFactory.Options options = new BitmapFactory.Options(); + int inSampleSize = (int) Math.pow(2, sampleSize); + Log.d(Config.LOGTAG, "reading bitmap with sample size " + + inSampleSize); + options.inSampleSize = inSampleSize; + originalBitmap = BitmapFactory.decodeStream(is, null, options); + is.close(); + if (originalBitmap == null) { + throw new ImageCopyException(R.string.error_not_an_image_file); + } + Bitmap scalledBitmap = resize(originalBitmap, IMAGE_SIZE); + originalBitmap = null; + int rotation = getRotation(image); + if (rotation > 0) { + scalledBitmap = rotate(scalledBitmap, rotation); + } + OutputStream os = new FileOutputStream(file); + boolean success = scalledBitmap.compress( + Bitmap.CompressFormat.WEBP, 75, os); + if (!success) { + throw new ImageCopyException(R.string.error_compressing_image); + } + os.flush(); + os.close(); + long size = file.getSize(); + int width = scalledBitmap.getWidth(); + int height = scalledBitmap.getHeight(); + message.setBody(Long.toString(size) + ',' + width + ',' + height); + return file; + } catch (FileNotFoundException e) { + throw new ImageCopyException(R.string.error_file_not_found); + } catch (IOException e) { + throw new ImageCopyException(R.string.error_io_exception); + } catch (SecurityException e) { + throw new ImageCopyException( + R.string.error_security_exception_during_image_copy); + } catch (OutOfMemoryError e) { + ++sampleSize; + if (sampleSize <= 3) { + return copyImageToPrivateStorage(message, image, sampleSize); + } else { + throw new ImageCopyException(R.string.error_out_of_memory); + } + } + } + + private int getRotation(Uri image) { + if ("content".equals(image.getScheme())) { + try { + Cursor cursor = mXmppConnectionService + .getContentResolver() + .query(image, + new String[] { MediaStore.Images.ImageColumns.ORIENTATION }, + null, null, null); + if (cursor.getCount() != 1) { + return -1; + } + cursor.moveToFirst(); + return cursor.getInt(0); + } catch (IllegalArgumentException e) { + return -1; + } + } else { + ExifInterface exif; + try { + exif = new ExifInterface(image.toString()); + if (exif.getAttribute(ExifInterface.TAG_ORIENTATION) + .equalsIgnoreCase("6")) { + return 90; + } else if (exif.getAttribute(ExifInterface.TAG_ORIENTATION) + .equalsIgnoreCase("8")) { + return 270; + } else if (exif.getAttribute(ExifInterface.TAG_ORIENTATION) + .equalsIgnoreCase("3")) { + return 180; + } else { + return 0; + } + } catch (IOException e) { + return -1; + } + } + } + + public Bitmap getImageFromMessage(Message message) { + return BitmapFactory.decodeFile(getFile(message).getAbsolutePath()); + } + + public Bitmap getThumbnail(Message message, int size, boolean cacheOnly) + throws FileNotFoundException { + Bitmap thumbnail = mXmppConnectionService.getBitmapCache().get( + message.getUuid()); + if ((thumbnail == null) && (!cacheOnly)) { + File file = getFile(message); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = calcSampleSize(file, size); + Bitmap fullsize = BitmapFactory.decodeFile(file.getAbsolutePath(), + options); + if (fullsize == null) { + throw new FileNotFoundException(); + } + thumbnail = resize(fullsize, size); + this.mXmppConnectionService.getBitmapCache().put(message.getUuid(), + thumbnail); + } + return thumbnail; + } + + public void removeFiles(Conversation conversation) { + String prefix = mXmppConnectionService.getFilesDir().getAbsolutePath(); + String path = prefix + "/" + conversation.getAccount().getJid() + "/" + + conversation.getContactJid(); + File file = new File(path); + try { + this.deleteFile(file); + } catch (IOException e) { + Log.d(Config.LOGTAG, + "error deleting file: " + file.getAbsolutePath()); + } + } + + private void deleteFile(File f) throws IOException { + if (f.isDirectory()) { + for (File c : f.listFiles()) + deleteFile(c); + } + f.delete(); + } + + public Uri getTakePhotoUri() { + StringBuilder pathBuilder = new StringBuilder(); + pathBuilder.append(Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)); + pathBuilder.append('/'); + pathBuilder.append("Camera"); + pathBuilder.append('/'); + pathBuilder.append("IMG_" + this.imageDateFormat.format(new Date()) + + ".jpg"); + Uri uri = Uri.parse("file://" + pathBuilder.toString()); + File file = new File(uri.toString()); + file.getParentFile().mkdirs(); + return uri; + } + + public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) { + try { + Avatar avatar = new Avatar(); + Bitmap bm = cropCenterSquare(image, size); + if (bm == null) { + return null; + } + ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream(); + Base64OutputStream mBase64OutputSttream = new Base64OutputStream( + mByteArrayOutputStream, Base64.DEFAULT); + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + DigestOutputStream mDigestOutputStream = new DigestOutputStream( + mBase64OutputSttream, digest); + if (!bm.compress(format, 75, mDigestOutputStream)) { + return null; + } + mDigestOutputStream.flush(); + mDigestOutputStream.close(); + avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest()); + avatar.image = new String(mByteArrayOutputStream.toByteArray()); + return avatar; + } catch (NoSuchAlgorithmException e) { + return null; + } catch (IOException e) { + return null; + } + } + + public boolean isAvatarCached(Avatar avatar) { + File file = new File(getAvatarPath(avatar.getFilename())); + return file.exists(); + } + + public boolean save(Avatar avatar) { + if (isAvatarCached(avatar)) { + return true; + } + String filename = getAvatarPath(avatar.getFilename()); + File file = new File(filename + ".tmp"); + file.getParentFile().mkdirs(); + try { + file.createNewFile(); + FileOutputStream mFileOutputStream = new FileOutputStream(file); + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.reset(); + DigestOutputStream mDigestOutputStream = new DigestOutputStream( + mFileOutputStream, digest); + mDigestOutputStream.write(avatar.getImageAsBytes()); + mDigestOutputStream.flush(); + mDigestOutputStream.close(); + avatar.size = file.length(); + String sha1sum = CryptoHelper.bytesToHex(digest.digest()); + if (sha1sum.equals(avatar.sha1sum)) { + file.renameTo(new File(filename)); + return true; + } else { + Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner); + file.delete(); + return false; + } + } catch (FileNotFoundException e) { + return false; + } catch (IOException e) { + return false; + } catch (NoSuchAlgorithmException e) { + return false; + } + } + + public String getAvatarPath(String avatar) { + return mXmppConnectionService.getFilesDir().getAbsolutePath() + + "/avatars/" + avatar; + } + + public Uri getAvatarUri(String avatar) { + return Uri.parse("file:" + getAvatarPath(avatar)); + } + + public Bitmap cropCenterSquare(Uri image, int size) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = calcSampleSize(image, size); + InputStream is = mXmppConnectionService.getContentResolver() + .openInputStream(image); + Bitmap input = BitmapFactory.decodeStream(is, null, options); + if (input == null) { + return null; + } else { + int rotation = getRotation(image); + if (rotation > 0) { + input = rotate(input, rotation); + } + return cropCenterSquare(input, size); + } + } catch (FileNotFoundException e) { + return null; + } + } + + public Bitmap cropCenter(Uri image, int newHeight, int newWidth) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = calcSampleSize(image, + Math.max(newHeight, newWidth)); + InputStream is = mXmppConnectionService.getContentResolver() + .openInputStream(image); + Bitmap source = BitmapFactory.decodeStream(is, null, options); + + int sourceWidth = source.getWidth(); + int sourceHeight = source.getHeight(); + float xScale = (float) newWidth / sourceWidth; + float yScale = (float) newHeight / sourceHeight; + float scale = Math.max(xScale, yScale); + float scaledWidth = scale * sourceWidth; + float scaledHeight = scale * sourceHeight; + float left = (newWidth - scaledWidth) / 2; + float top = (newHeight - scaledHeight) / 2; + + RectF targetRect = new RectF(left, top, left + scaledWidth, top + + scaledHeight); + Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, + source.getConfig()); + Canvas canvas = new Canvas(dest); + canvas.drawBitmap(source, null, targetRect, null); + + return dest; + } catch (FileNotFoundException e) { + return null; + } + + } + + public Bitmap cropCenterSquare(Bitmap input, int size) { + int w = input.getWidth(); + int h = input.getHeight(); + + float scale = Math.max((float) size / h, (float) size / w); + + float outWidth = scale * w; + float outHeight = scale * h; + float left = (size - outWidth) / 2; + float top = (size - outHeight) / 2; + RectF target = new RectF(left, top, left + outWidth, top + outHeight); + + Bitmap output = Bitmap.createBitmap(size, size, input.getConfig()); + Canvas canvas = new Canvas(output); + canvas.drawBitmap(input, null, target, null); + return output; + } + + private int calcSampleSize(Uri image, int size) + throws FileNotFoundException { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver() + .openInputStream(image), null, options); + return calcSampleSize(options, size); + } + + private int calcSampleSize(File image, int size) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(image.getAbsolutePath(), options); + return calcSampleSize(options, size); + } + + private int calcSampleSize(BitmapFactory.Options options, int size) { + int height = options.outHeight; + int width = options.outWidth; + int inSampleSize = 1; + + if (height > size || width > size) { + int halfHeight = height / 2; + int halfWidth = width / 2; + + while ((halfHeight / inSampleSize) > size + && (halfWidth / inSampleSize) > size) { + inSampleSize *= 2; + } + } + return inSampleSize; + } + + public Uri getJingleFileUri(Message message) { + File file = getFile(message); + return Uri.parse("file://" + file.getAbsolutePath()); + } + + public class ImageCopyException extends Exception { + private static final long serialVersionUID = -1010013599132881427L; + private int resId; + + public ImageCopyException(int resId) { + this.resId = resId; + } + + public int getResId() { + return resId; + } + } + + public Bitmap getAvatar(String avatar, int size) { + if (avatar == null) { + return null; + } + Bitmap bm = cropCenter(getAvatarUri(avatar), size, size); + if (bm == null) { + return null; + } + return bm; + } + + public boolean isFileAvailable(Message message) { + return getFile(message).exists(); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/persistance/OnPhoneContactsMerged.java b/conversations/src/main/java/eu/siacs/conversations/persistance/OnPhoneContactsMerged.java new file mode 100644 index 000000000..6a457b17f --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/persistance/OnPhoneContactsMerged.java @@ -0,0 +1,5 @@ +package eu.siacs.conversations.persistance; + +public interface OnPhoneContactsMerged { + public void phoneContactsMerged(); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/services/AbstractConnectionManager.java b/conversations/src/main/java/eu/siacs/conversations/services/AbstractConnectionManager.java new file mode 100644 index 000000000..676a09c97 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/services/AbstractConnectionManager.java @@ -0,0 +1,23 @@ +package eu.siacs.conversations.services; + +public class AbstractConnectionManager { + protected XmppConnectionService mXmppConnectionService; + + public AbstractConnectionManager(XmppConnectionService service) { + this.mXmppConnectionService = service; + } + + public XmppConnectionService getXmppConnectionService() { + return this.mXmppConnectionService; + } + + public long getAutoAcceptFileSize() { + String config = this.mXmppConnectionService.getPreferences().getString( + "auto_accept_file_size", "524288"); + try { + return Long.parseLong(config); + } catch (NumberFormatException e) { + return 524288; + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/services/AvatarService.java b/conversations/src/main/java/eu/siacs/conversations/services/AvatarService.java new file mode 100644 index 000000000..c0668a193 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/services/AvatarService.java @@ -0,0 +1,298 @@ +package eu.siacs.conversations.services; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Bookmark; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.ListItem; +import eu.siacs.conversations.entities.MucOptions; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.net.Uri; +import android.util.Log; + +public class AvatarService { + + private static final int FG_COLOR = 0xFFFAFAFA; + private static final int TRANSPARENT = 0x00000000; + + private static final String PREFIX_CONTACT = "contact"; + private static final String PREFIX_CONVERSATION = "conversation"; + private static final String PREFIX_ACCOUNT = "account"; + private static final String PREFIX_GENERIC = "generic"; + + private ArrayList sizes = new ArrayList(); + + protected XmppConnectionService mXmppConnectionService = null; + + public AvatarService(XmppConnectionService service) { + this.mXmppConnectionService = service; + } + + public Bitmap get(Contact contact, int size) { + final String KEY = key(contact, size); + Bitmap avatar = this.mXmppConnectionService.getBitmapCache().get(KEY); + if (avatar != null) { + return avatar; + } + Log.d(Config.LOGTAG, "no cache hit for " + KEY); + avatar = mXmppConnectionService.getFileBackend().getAvatar( + contact.getAvatar(), size); + if (avatar == null) { + if (contact.getProfilePhoto() != null) { + avatar = mXmppConnectionService.getFileBackend() + .cropCenterSquare(Uri.parse(contact.getProfilePhoto()), + size); + if (avatar == null) { + avatar = get(contact.getDisplayName(), size); + } + } else { + avatar = get(contact.getDisplayName(), size); + } + } + this.mXmppConnectionService.getBitmapCache().put(KEY, avatar); + return avatar; + } + + public void clear(Contact contact) { + for (Integer size : sizes) { + this.mXmppConnectionService.getBitmapCache().remove( + key(contact, size)); + } + } + + private String key(Contact contact, int size) { + synchronized (this.sizes) { + if (!this.sizes.contains(size)) { + this.sizes.add(size); + } + } + return PREFIX_CONTACT + "_" + contact.getAccount().getJid() + "_" + + contact.getJid() + "_" + String.valueOf(size); + } + + public Bitmap get(ListItem item, int size) { + if (item instanceof Contact) { + return get((Contact) item, size); + } else if (item instanceof Bookmark) { + Bookmark bookmark = (Bookmark) item; + if (bookmark.getConversation() != null) { + return get(bookmark.getConversation(), size); + } else { + return get(bookmark.getDisplayName(), size); + } + } else { + return get(item.getDisplayName(), size); + } + } + + public Bitmap get(Conversation conversation, int size) { + if (conversation.getMode() == Conversation.MODE_SINGLE) { + return get(conversation.getContact(), size); + } else { + return get(conversation.getMucOptions(), size); + } + } + + public void clear(Conversation conversation) { + if (conversation.getMode() == Conversation.MODE_SINGLE) { + clear(conversation.getContact()); + } else { + clear(conversation.getMucOptions()); + } + } + + public Bitmap get(MucOptions mucOptions, int size) { + final String KEY = key(mucOptions, size); + Bitmap bitmap = this.mXmppConnectionService.getBitmapCache().get(KEY); + if (bitmap != null) { + return bitmap; + } + Log.d(Config.LOGTAG, "no cache hit for " + KEY); + List users = mucOptions.getUsers(); + int count = users.size(); + bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + bitmap.eraseColor(TRANSPARENT); + + if (count == 0) { + String name = mucOptions.getConversation().getName(); + String letter = name.substring(0, 1); + int color = this.getColorForName(name); + drawTile(canvas, letter, color, 0, 0, size, size); + } else if (count == 1) { + drawTile(canvas, users.get(0), 0, 0, size, size); + } else if (count == 2) { + drawTile(canvas, users.get(0), 0, 0, size / 2 - 1, size); + drawTile(canvas, users.get(1), size / 2 + 1, 0, size, size); + } else if (count == 3) { + drawTile(canvas, users.get(0), 0, 0, size / 2 - 1, size); + drawTile(canvas, users.get(1), size / 2 + 1, 0, size, size / 2 - 1); + drawTile(canvas, users.get(2), size / 2 + 1, size / 2 + 1, size, + size); + } else if (count == 4) { + drawTile(canvas, users.get(0), 0, 0, size / 2 - 1, size / 2 - 1); + drawTile(canvas, users.get(1), 0, size / 2 + 1, size / 2 - 1, size); + drawTile(canvas, users.get(2), size / 2 + 1, 0, size, size / 2 - 1); + drawTile(canvas, users.get(3), size / 2 + 1, size / 2 + 1, size, + size); + } else { + drawTile(canvas, users.get(0), 0, 0, size / 2 - 1, size / 2 - 1); + drawTile(canvas, users.get(1), 0, size / 2 + 1, size / 2 - 1, size); + drawTile(canvas, users.get(2), size / 2 + 1, 0, size, size / 2 - 1); + drawTile(canvas, "\u2026", 0xFF202020, size / 2 + 1, size / 2 + 1, + size, size); + } + this.mXmppConnectionService.getBitmapCache().put(KEY, bitmap); + return bitmap; + } + + public void clear(MucOptions options) { + for (Integer size : sizes) { + this.mXmppConnectionService.getBitmapCache().remove( + key(options, size)); + } + } + + private String key(MucOptions options, int size) { + synchronized (this.sizes) { + if (!this.sizes.contains(size)) { + this.sizes.add(size); + } + } + return PREFIX_CONVERSATION + "_" + options.getConversation().getUuid() + + "_" + String.valueOf(size); + } + + public Bitmap get(Account account, int size) { + final String KEY = key(account, size); + Bitmap avatar = mXmppConnectionService.getBitmapCache().get(KEY); + if (avatar != null) { + return avatar; + } + Log.d(Config.LOGTAG, "no cache hit for " + KEY); + avatar = mXmppConnectionService.getFileBackend().getAvatar( + account.getAvatar(), size); + if (avatar == null) { + avatar = get(account.getJid(), size); + } + mXmppConnectionService.getBitmapCache().put(KEY, avatar); + return avatar; + } + + public void clear(Account account) { + for (Integer size : sizes) { + this.mXmppConnectionService.getBitmapCache().remove( + key(account, size)); + } + } + + private String key(Account account, int size) { + synchronized (this.sizes) { + if (!this.sizes.contains(size)) { + this.sizes.add(size); + } + } + return PREFIX_ACCOUNT + "_" + account.getUuid() + "_" + + String.valueOf(size); + } + + public Bitmap get(String name, int size) { + final String KEY = key(name, size); + Bitmap bitmap = mXmppConnectionService.getBitmapCache().get(KEY); + if (bitmap != null) { + return bitmap; + } + Log.d(Config.LOGTAG, "no cache hit for " + KEY); + bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + String letter = name.substring(0, 1); + int color = this.getColorForName(name); + drawTile(canvas, letter, color, 0, 0, size, size); + mXmppConnectionService.getBitmapCache().put(KEY, bitmap); + return bitmap; + } + + private String key(String name, int size) { + synchronized (this.sizes) { + if (!this.sizes.contains(size)) { + this.sizes.add(size); + } + } + return PREFIX_GENERIC + "_" + name + "_" + String.valueOf(size); + } + + private void drawTile(Canvas canvas, String letter, int tileColor, + int left, int top, int right, int bottom) { + letter = letter.toUpperCase(Locale.getDefault()); + Paint tilePaint = new Paint(), textPaint = new Paint(); + tilePaint.setColor(tileColor); + textPaint.setFlags(Paint.ANTI_ALIAS_FLAG); + textPaint.setColor(FG_COLOR); + textPaint.setTypeface(Typeface.create("sans-serif-light", + Typeface.NORMAL)); + textPaint.setTextSize((float) ((right - left) * 0.8)); + Rect rect = new Rect(); + + canvas.drawRect(new Rect(left, top, right, bottom), tilePaint); + textPaint.getTextBounds(letter, 0, 1, rect); + float width = textPaint.measureText(letter); + canvas.drawText(letter, (right + left) / 2 - width / 2, (top + bottom) + / 2 + rect.height() / 2, textPaint); + } + + private void drawTile(Canvas canvas, MucOptions.User user, int left, + int top, int right, int bottom) { + Contact contact = user.getContact(); + if (contact != null) { + Uri uri = null; + if (contact.getAvatar() != null) { + uri = mXmppConnectionService.getFileBackend().getAvatarUri( + contact.getAvatar()); + } else if (contact.getProfilePhoto() != null) { + uri = Uri.parse(contact.getProfilePhoto()); + } + if (uri != null) { + Bitmap bitmap = mXmppConnectionService.getFileBackend() + .cropCenter(uri, bottom - top, right - left); + if (bitmap != null) { + drawTile(canvas, bitmap, left, top, right, bottom); + } else { + String letter = user.getName().substring(0, 1); + int color = this.getColorForName(user.getName()); + drawTile(canvas, letter, color, left, top, right, bottom); + } + } else { + String letter = user.getName().substring(0, 1); + int color = this.getColorForName(user.getName()); + drawTile(canvas, letter, color, left, top, right, bottom); + } + } else { + String letter = user.getName().substring(0, 1); + int color = this.getColorForName(user.getName()); + drawTile(canvas, letter, color, left, top, right, bottom); + } + } + + private void drawTile(Canvas canvas, Bitmap bm, int dstleft, int dsttop, + int dstright, int dstbottom) { + Rect dst = new Rect(dstleft, dsttop, dstright, dstbottom); + canvas.drawBitmap(bm, null, dst, null); + } + + private int getColorForName(String name) { + int holoColors[] = { 0xFFe91e63, 0xFF9c27b0, 0xFF673ab7, 0xFF3f51b5, + 0xFF5677fc, 0xFF03a9f4, 0xFF00bcd4, 0xFF009688, 0xFFff5722, + 0xFF795548, 0xFF607d8b }; + return holoColors[(int) ((name.hashCode() & 0xffffffffl) % holoColors.length)]; + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/services/EventReceiver.java b/conversations/src/main/java/eu/siacs/conversations/services/EventReceiver.java new file mode 100644 index 000000000..dfbe9db76 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/services/EventReceiver.java @@ -0,0 +1,24 @@ +package eu.siacs.conversations.services; + +import eu.siacs.conversations.persistance.DatabaseBackend; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public class EventReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Intent mIntentForService = new Intent(context, + XmppConnectionService.class); + if (intent.getAction() != null) { + mIntentForService.setAction(intent.getAction()); + } else { + mIntentForService.setAction("other"); + } + if (intent.getAction().equals("ui") + || DatabaseBackend.getInstance(context).hasEnabledAccounts()) { + context.startService(mIntentForService); + } + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/services/NotificationService.java b/conversations/src/main/java/eu/siacs/conversations/services/NotificationService.java new file mode 100644 index 000000000..00765deb7 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/services/NotificationService.java @@ -0,0 +1,237 @@ +package eu.siacs.conversations.services; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.PowerManager; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.TaskStackBuilder; +import android.text.Html; +import android.util.DisplayMetrics; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.ui.ConversationActivity; + +public class NotificationService { + + private XmppConnectionService mXmppConnectionService; + + private LinkedHashMap> notifications = new LinkedHashMap>(); + + public int NOTIFICATION_ID = 0x2342; + private Conversation mOpenConversation; + private boolean mIsInForeground; + + public NotificationService(XmppConnectionService service) { + this.mXmppConnectionService = service; + } + + public void push(Message message) { + PowerManager pm = (PowerManager) mXmppConnectionService + .getSystemService(Context.POWER_SERVICE); + boolean isScreenOn = pm.isScreenOn(); + + if (this.mIsInForeground && isScreenOn + && this.mOpenConversation == message.getConversation()) { + return; + } + synchronized (notifications) { + String conversationUuid = message.getConversationUuid(); + if (notifications.containsKey(conversationUuid)) { + notifications.get(conversationUuid).add(message); + } else { + ArrayList mList = new ArrayList(); + mList.add(message); + notifications.put(conversationUuid, mList); + } + Account account = message.getConversation().getAccount(); + updateNotification((!(this.mIsInForeground && this.mOpenConversation == null) || !isScreenOn) + && !account.inGracePeriod()); + } + + } + + public void clear() { + synchronized (notifications) { + notifications.clear(); + updateNotification(false); + } + } + + public void clear(Conversation conversation) { + synchronized (notifications) { + notifications.remove(conversation.getUuid()); + updateNotification(false); + } + } + + private void updateNotification(boolean notify) { + NotificationManager notificationManager = (NotificationManager) mXmppConnectionService + .getSystemService(Context.NOTIFICATION_SERVICE); + SharedPreferences preferences = mXmppConnectionService.getPreferences(); + + String ringtone = preferences.getString("notification_ringtone", null); + boolean vibrate = preferences.getBoolean("vibrate_on_notification", + true); + + if (notifications.size() == 0) { + notificationManager.cancel(NOTIFICATION_ID); + } else { + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder( + mXmppConnectionService); + mBuilder.setSmallIcon(R.drawable.ic_notification); + if (notifications.size() == 1) { + ArrayList messages = notifications.values().iterator() + .next(); + if (messages.size() >= 1) { + Conversation conversation = messages.get(0) + .getConversation(); + mBuilder.setLargeIcon(mXmppConnectionService + .getAvatarService().get(conversation, getPixel(64))); + mBuilder.setContentTitle(conversation.getName()); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < messages.size(); ++i) { + text.append(messages.get(i).getReadableBody( + mXmppConnectionService)); + if (i != messages.size() - 1) { + text.append("\n"); + } + } + mBuilder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(text.toString())); + mBuilder.setContentText(messages.get(0).getReadableBody( + mXmppConnectionService)); + if (notify) { + mBuilder.setTicker(messages.get(messages.size() - 1) + .getReadableBody(mXmppConnectionService)); + } + mBuilder.setContentIntent(createContentIntent(conversation + .getUuid())); + } else { + notificationManager.cancel(NOTIFICATION_ID); + return; + } + } else { + NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle(); + style.setBigContentTitle(notifications.size() + + " " + + mXmppConnectionService + .getString(R.string.unread_conversations)); + StringBuilder names = new StringBuilder(); + Conversation conversation = null; + for (ArrayList messages : notifications.values()) { + if (messages.size() > 0) { + conversation = messages.get(0).getConversation(); + String name = conversation.getName(); + style.addLine(Html.fromHtml("" + + name + + " " + + messages.get(0).getReadableBody( + mXmppConnectionService))); + names.append(name); + names.append(", "); + } + } + if (names.length() >= 2) { + names.delete(names.length() - 2, names.length()); + } + mBuilder.setContentTitle(notifications.size() + + " " + + mXmppConnectionService + .getString(R.string.unread_conversations)); + mBuilder.setContentText(names.toString()); + mBuilder.setStyle(style); + if (conversation != null) { + mBuilder.setContentIntent(createContentIntent(conversation + .getUuid())); + } + } + if (notify) { + if (vibrate) { + int dat = 70; + long[] pattern = { 0, 3 * dat, dat, dat }; + mBuilder.setVibrate(pattern); + } + if (ringtone != null) { + mBuilder.setSound(Uri.parse(ringtone)); + } + } + mBuilder.setDeleteIntent(createDeleteIntent()); + mBuilder.setLights(0xffffffff, 2000, 4000); + Notification notification = mBuilder.build(); + notificationManager.notify(NOTIFICATION_ID, notification); + } + } + + private PendingIntent createContentIntent(String conversationUuid) { + TaskStackBuilder stackBuilder = TaskStackBuilder + .create(mXmppConnectionService); + stackBuilder.addParentStack(ConversationActivity.class); + + Intent viewConversationIntent = new Intent(mXmppConnectionService, + ConversationActivity.class); + viewConversationIntent.setAction(Intent.ACTION_VIEW); + viewConversationIntent.putExtra(ConversationActivity.CONVERSATION, + conversationUuid); + viewConversationIntent.setType(ConversationActivity.VIEW_CONVERSATION); + + stackBuilder.addNextIntent(viewConversationIntent); + + PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, + PendingIntent.FLAG_UPDATE_CURRENT); + return resultPendingIntent; + } + + private PendingIntent createDeleteIntent() { + Intent intent = new Intent(mXmppConnectionService, + XmppConnectionService.class); + intent.setAction("clear_notification"); + return PendingIntent.getService(mXmppConnectionService, 0, intent, 0); + } + + public static boolean wasHighlightedOrPrivate(Message message) { + String nick = message.getConversation().getMucOptions().getActualNick(); + Pattern highlight = generateNickHighlightPattern(nick); + if (message.getBody() == null || nick == null) { + return false; + } + Matcher m = highlight.matcher(message.getBody()); + return (m.find() || message.getType() == Message.TYPE_PRIVATE); + } + + private static Pattern generateNickHighlightPattern(String nick) { + // We expect a word boundary, i.e. space or start of string, followed by + // the + // nick (matched in case-insensitive manner), followed by optional + // punctuation (for example "bob: i disagree" or "how are you alice?"), + // followed by another word boundary. + return Pattern.compile("\\b" + nick + "\\p{Punct}?\\b", + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + } + + public void setOpenConversation(Conversation conversation) { + this.mOpenConversation = conversation; + } + + public void setIsInForeground(boolean foreground) { + this.mIsInForeground = foreground; + } + + private int getPixel(int dp) { + DisplayMetrics metrics = mXmppConnectionService.getResources() + .getDisplayMetrics(); + return ((int) (dp * metrics.density)); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/conversations/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java new file mode 100644 index 000000000..37e334eb6 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -0,0 +1,1927 @@ +package eu.siacs.conversations.services; + +import java.security.SecureRandom; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.Hashtable; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.openintents.openpgp.util.OpenPgpApi; +import org.openintents.openpgp.util.OpenPgpServiceConnection; + +import de.duenndns.ssl.MemorizingTrustManager; + +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionStatus; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.PgpEngine; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Bookmark; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Downloadable; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.MucOptions; +import eu.siacs.conversations.entities.MucOptions.OnRenameListener; +import eu.siacs.conversations.entities.Presences; +import eu.siacs.conversations.generator.IqGenerator; +import eu.siacs.conversations.generator.MessageGenerator; +import eu.siacs.conversations.generator.PresenceGenerator; +import eu.siacs.conversations.http.HttpConnectionManager; +import eu.siacs.conversations.parser.IqParser; +import eu.siacs.conversations.parser.MessageParser; +import eu.siacs.conversations.parser.PresenceParser; +import eu.siacs.conversations.persistance.DatabaseBackend; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.ui.UiCallback; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.utils.ExceptionHelper; +import eu.siacs.conversations.utils.OnPhoneContactsLoadedListener; +import eu.siacs.conversations.utils.PRNGFixes; +import eu.siacs.conversations.utils.PhoneHelper; +import eu.siacs.conversations.utils.UIHelper; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnBindListener; +import eu.siacs.conversations.xmpp.OnContactStatusChanged; +import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.OnMessageAcknowledged; +import eu.siacs.conversations.xmpp.OnStatusChanged; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager; +import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.pep.Avatar; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; +import eu.siacs.conversations.xmpp.stanzas.PresencePacket; +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.graphics.Bitmap; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Binder; +import android.os.Bundle; +import android.os.FileObserver; +import android.os.IBinder; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.os.SystemClock; +import android.preference.PreferenceManager; +import android.provider.ContactsContract; +import android.util.Log; +import android.util.LruCache; + +public class XmppConnectionService extends Service { + + public DatabaseBackend databaseBackend; + private FileBackend fileBackend = new FileBackend(this); + + public long startDate; + + private static String ACTION_MERGE_PHONE_CONTACTS = "merge_phone_contacts"; + public static String ACTION_CLEAR_NOTIFICATION = "clear_notification"; + + private MemorizingTrustManager mMemorizingTrustManager; + + private NotificationService mNotificationService = new NotificationService( + this); + + private MessageParser mMessageParser = new MessageParser(this); + private PresenceParser mPresenceParser = new PresenceParser(this); + private IqParser mIqParser = new IqParser(this); + private MessageGenerator mMessageGenerator = new MessageGenerator(this); + private PresenceGenerator mPresenceGenerator = new PresenceGenerator(this); + + private List accounts; + private CopyOnWriteArrayList conversations = null; + private JingleConnectionManager mJingleConnectionManager = new JingleConnectionManager( + this); + private HttpConnectionManager mHttpConnectionManager = new HttpConnectionManager( + this); + private AvatarService mAvatarService = new AvatarService(this); + + private OnConversationUpdate mOnConversationUpdate = null; + private Integer convChangedListenerCount = 0; + private OnAccountUpdate mOnAccountUpdate = null; + private Integer accountChangedListenerCount = 0; + private OnRosterUpdate mOnRosterUpdate = null; + private Integer rosterChangedListenerCount = 0; + public OnContactStatusChanged onContactStatusChanged = new OnContactStatusChanged() { + + @Override + public void onContactStatusChanged(Contact contact, boolean online) { + Conversation conversation = find(getConversations(), contact); + if (conversation != null) { + conversation.endOtrIfNeeded(); + if (online && (contact.getPresences().size() == 1)) { + sendUnsendMessages(conversation); + } + } + } + }; + + private SecureRandom mRandom; + + private ContentObserver contactObserver = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + super.onChange(selfChange); + Intent intent = new Intent(getApplicationContext(), + XmppConnectionService.class); + intent.setAction(ACTION_MERGE_PHONE_CONTACTS); + startService(intent); + } + }; + + private FileObserver fileObserver = new FileObserver( + FileBackend.getConversationsDirectory()) { + + @Override + public void onEvent(int event, String path) { + if (event == FileObserver.DELETE) { + markFileDeleted(path.split("\\.")[0]); + } + } + }; + + private final IBinder mBinder = new XmppConnectionBinder(); + private OnStatusChanged statusListener = new OnStatusChanged() { + + @Override + public void onStatusChanged(Account account) { + XmppConnection connection = account.getXmppConnection(); + if (mOnAccountUpdate != null) { + mOnAccountUpdate.onAccountUpdate(); + ; + } + if (account.getStatus() == Account.STATUS_ONLINE) { + for (Conversation conversation : account.pendingConferenceLeaves) { + leaveMuc(conversation); + } + for (Conversation conversation : account.pendingConferenceJoins) { + joinMuc(conversation); + } + mJingleConnectionManager.cancelInTransmission(); + List conversations = getConversations(); + for (int i = 0; i < conversations.size(); ++i) { + if (conversations.get(i).getAccount() == account) { + conversations.get(i).startOtrIfNeeded(); + sendUnsendMessages(conversations.get(i)); + } + } + if (connection != null && connection.getFeatures().csi()) { + if (checkListeners()) { + Log.d(Config.LOGTAG, account.getJid() + + " sending csi//inactive"); + connection.sendInactive(); + } else { + Log.d(Config.LOGTAG, account.getJid() + + " sending csi//active"); + connection.sendActive(); + } + } + syncDirtyContacts(account); + scheduleWakeupCall(Config.PING_MAX_INTERVAL, true); + } else if (account.getStatus() == Account.STATUS_OFFLINE) { + resetSendingToWaiting(account); + if (!account.isOptionSet(Account.OPTION_DISABLED)) { + int timeToReconnect = mRandom.nextInt(50) + 10; + scheduleWakeupCall(timeToReconnect, false); + } + } else if (account.getStatus() == Account.STATUS_REGISTRATION_SUCCESSFULL) { + databaseBackend.updateAccount(account); + reconnectAccount(account, true); + } else if ((account.getStatus() != Account.STATUS_CONNECTING) + && (account.getStatus() != Account.STATUS_NO_INTERNET)) { + if (connection != null) { + int next = connection.getTimeToNextAttempt(); + Log.d(Config.LOGTAG, account.getJid() + + ": error connecting account. try again in " + + next + "s for the " + + (connection.getAttempt() + 1) + " time"); + scheduleWakeupCall((int) (next * 1.2), false); + } + } + UIHelper.showErrorNotification(getApplicationContext(), + getAccounts()); + } + }; + + private OnJinglePacketReceived jingleListener = new OnJinglePacketReceived() { + + @Override + public void onJinglePacketReceived(Account account, JinglePacket packet) { + mJingleConnectionManager.deliverPacket(account, packet); + } + }; + + private OpenPgpServiceConnection pgpServiceConnection; + private PgpEngine mPgpEngine = null; + private Intent pingIntent; + private PendingIntent pendingPingIntent = null; + private WakeLock wakeLock; + private PowerManager pm; + private OnBindListener mOnBindListener = new OnBindListener() { + + @Override + public void onBind(final Account account) { + account.getRoster().clearPresences(); + account.clearPresences(); // self presences + account.pendingConferenceJoins.clear(); + account.pendingConferenceLeaves.clear(); + fetchRosterFromServer(account); + fetchBookmarks(account); + sendPresencePacket(account, + mPresenceGenerator.sendPresence(account)); + connectMultiModeConversations(account); + updateConversationUi(); + } + }; + + private OnMessageAcknowledged mOnMessageAcknowledgedListener = new OnMessageAcknowledged() { + + @Override + public void onMessageAcknowledged(Account account, String uuid) { + for (Conversation conversation : getConversations()) { + if (conversation.getAccount() == account) { + for (Message message : conversation.getMessages()) { + if ((message.getStatus() == Message.STATUS_UNSEND || message + .getStatus() == Message.STATUS_WAITING) + && message.getUuid().equals(uuid)) { + markMessage(message, Message.STATUS_SEND); + return; + } + } + } + } + } + }; + private LruCache mBitmapCache; + + public PgpEngine getPgpEngine() { + if (pgpServiceConnection.isBound()) { + if (this.mPgpEngine == null) { + this.mPgpEngine = new PgpEngine(new OpenPgpApi( + getApplicationContext(), + pgpServiceConnection.getService()), this); + } + return mPgpEngine; + } else { + return null; + } + + } + + public FileBackend getFileBackend() { + return this.fileBackend; + } + + public AvatarService getAvatarService() { + return this.mAvatarService; + } + + public Message attachImageToConversation(final Conversation conversation, + final Uri uri, final UiCallback callback) { + final Message message; + if (conversation.getNextEncryption(forceEncryption()) == Message.ENCRYPTION_PGP) { + message = new Message(conversation, "", + Message.ENCRYPTION_DECRYPTED); + } else { + message = new Message(conversation, "", + conversation.getNextEncryption(forceEncryption())); + } + message.setPresence(conversation.getNextPresence()); + message.setType(Message.TYPE_IMAGE); + message.setStatus(Message.STATUS_OFFERED); + new Thread(new Runnable() { + + @Override + public void run() { + try { + getFileBackend().copyImageToPrivateStorage(message, uri); + if (conversation.getNextEncryption(forceEncryption()) == Message.ENCRYPTION_PGP) { + getPgpEngine().encrypt(message, callback); + } else { + callback.success(message); + } + } catch (FileBackend.ImageCopyException e) { + callback.error(e.getResId(), message); + } + } + }).start(); + return message; + } + + public Conversation find(Bookmark bookmark) { + return find(bookmark.getAccount(), bookmark.getJid()); + } + + public Conversation find(Account account, String jid) { + return find(getConversations(), account, jid); + } + + public class XmppConnectionBinder extends Binder { + public XmppConnectionService getService() { + return XmppConnectionService.this; + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && intent.getAction() != null) { + if (intent.getAction().equals(ACTION_MERGE_PHONE_CONTACTS)) { + mergePhoneContactsWithRoster(); + return START_STICKY; + } else if (intent.getAction().equals(Intent.ACTION_SHUTDOWN)) { + logoutAndSave(); + return START_NOT_STICKY; + } else if (intent.getAction().equals(ACTION_CLEAR_NOTIFICATION)) { + mNotificationService.clear(); + } + } + this.wakeLock.acquire(); + + for (Account account : accounts) { + if (!account.isOptionSet(Account.OPTION_DISABLED)) { + if (!hasInternetConnection()) { + account.setStatus(Account.STATUS_NO_INTERNET); + if (statusListener != null) { + statusListener.onStatusChanged(account); + } + } else { + if (account.getStatus() == Account.STATUS_NO_INTERNET) { + account.setStatus(Account.STATUS_OFFLINE); + if (statusListener != null) { + statusListener.onStatusChanged(account); + } + } + if (account.getStatus() == Account.STATUS_ONLINE) { + long lastReceived = account.getXmppConnection() + .getLastPacketReceived(); + long lastSent = account.getXmppConnection() + .getLastPingSent(); + if (lastSent - lastReceived >= Config.PING_TIMEOUT * 1000) { + Log.d(Config.LOGTAG, account.getJid() + + ": ping timeout"); + this.reconnectAccount(account, true); + } else if (SystemClock.elapsedRealtime() - lastReceived >= Config.PING_MIN_INTERVAL * 1000) { + account.getXmppConnection().sendPing(); + this.scheduleWakeupCall(2, false); + } + } else if (account.getStatus() == Account.STATUS_OFFLINE) { + if (account.getXmppConnection() == null) { + account.setXmppConnection(this + .createConnection(account)); + } + new Thread(account.getXmppConnection()).start(); + } else if ((account.getStatus() == Account.STATUS_CONNECTING) + && ((SystemClock.elapsedRealtime() - account + .getXmppConnection().getLastConnect()) / 1000 >= Config.CONNECT_TIMEOUT)) { + Log.d(Config.LOGTAG, account.getJid() + + ": time out during connect reconnecting"); + reconnectAccount(account, true); + } else { + if (account.getXmppConnection().getTimeToNextAttempt() <= 0) { + reconnectAccount(account, true); + } + } + // in any case. reschedule wakup call + this.scheduleWakeupCall(Config.PING_MAX_INTERVAL, true); + } + if (mOnAccountUpdate != null) { + mOnAccountUpdate.onAccountUpdate(); + } + } + } + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + return START_STICKY; + } + + public boolean hasInternetConnection() { + ConnectivityManager cm = (ConnectivityManager) getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + return activeNetwork != null && activeNetwork.isConnected(); + } + + @SuppressLint("TrulyRandom") + @Override + public void onCreate() { + ExceptionHelper.init(getApplicationContext()); + PRNGFixes.apply(); + this.mRandom = new SecureRandom(); + this.mMemorizingTrustManager = new MemorizingTrustManager( + getApplicationContext()); + + int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + int cacheSize = maxMemory / 8; + this.mBitmapCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + return bitmap.getByteCount() / 1024; + } + }; + + this.databaseBackend = DatabaseBackend + .getInstance(getApplicationContext()); + this.accounts = databaseBackend.getAccounts(); + + for (Account account : this.accounts) { + this.databaseBackend.readRoster(account.getRoster()); + } + this.mergePhoneContactsWithRoster(); + this.getConversations(); + + getContentResolver().registerContentObserver( + ContactsContract.Contacts.CONTENT_URI, true, contactObserver); + this.fileObserver.startWatching(); + this.pgpServiceConnection = new OpenPgpServiceConnection( + getApplicationContext(), "org.sufficientlysecure.keychain"); + this.pgpServiceConnection.bindToService(); + + this.pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + this.wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + "XmppConnectionService"); + } + + @Override + public void onDestroy() { + super.onDestroy(); + this.logoutAndSave(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + this.logoutAndSave(); + } + + private void logoutAndSave() { + for (Account account : accounts) { + databaseBackend.writeRoster(account.getRoster()); + if (account.getXmppConnection() != null) { + disconnect(account, false); + } + } + Context context = getApplicationContext(); + AlarmManager alarmManager = (AlarmManager) context + .getSystemService(Context.ALARM_SERVICE); + Intent intent = new Intent(context, EventReceiver.class); + alarmManager.cancel(PendingIntent.getBroadcast(context, 0, intent, 0)); + Log.d(Config.LOGTAG, "good bye"); + stopSelf(); + } + + protected void scheduleWakeupCall(int seconds, boolean ping) { + long timeToWake = SystemClock.elapsedRealtime() + seconds * 1000; + Context context = getApplicationContext(); + AlarmManager alarmManager = (AlarmManager) context + .getSystemService(Context.ALARM_SERVICE); + + if (ping) { + if (this.pingIntent == null) { + this.pingIntent = new Intent(context, EventReceiver.class); + this.pingIntent.setAction("ping"); + this.pingIntent.putExtra("time", timeToWake); + this.pendingPingIntent = PendingIntent.getBroadcast(context, 0, + this.pingIntent, 0); + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + timeToWake, pendingPingIntent); + } else { + long scheduledTime = this.pingIntent.getLongExtra("time", 0); + if (scheduledTime < SystemClock.elapsedRealtime() + || (scheduledTime > timeToWake)) { + this.pingIntent.putExtra("time", timeToWake); + alarmManager.cancel(this.pendingPingIntent); + this.pendingPingIntent = PendingIntent.getBroadcast( + context, 0, this.pingIntent, 0); + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + timeToWake, pendingPingIntent); + } + } + } else { + Intent intent = new Intent(context, EventReceiver.class); + intent.setAction("ping_check"); + PendingIntent alarmIntent = PendingIntent.getBroadcast(context, 0, + intent, 0); + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, timeToWake, + alarmIntent); + } + + } + + public XmppConnection createConnection(Account account) { + SharedPreferences sharedPref = getPreferences(); + account.setResource(sharedPref.getString("resource", "mobile") + .toLowerCase(Locale.getDefault())); + XmppConnection connection = new XmppConnection(account, this); + connection.setOnMessagePacketReceivedListener(this.mMessageParser); + connection.setOnStatusChangedListener(this.statusListener); + connection.setOnPresencePacketReceivedListener(this.mPresenceParser); + connection.setOnUnregisteredIqPacketReceivedListener(this.mIqParser); + connection.setOnJinglePacketReceivedListener(this.jingleListener); + connection.setOnBindListener(this.mOnBindListener); + connection + .setOnMessageAcknowledgeListener(this.mOnMessageAcknowledgedListener); + return connection; + } + + public void sendMessage(Message message) { + Account account = message.getConversation().getAccount(); + account.deactivateGracePeriod(); + Conversation conv = message.getConversation(); + MessagePacket packet = null; + boolean saveInDb = true; + boolean send = false; + if (account.getStatus() == Account.STATUS_ONLINE + && account.getXmppConnection() != null) { + if (message.getType() == Message.TYPE_IMAGE) { + if (message.getPresence() != null) { + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + if (!conv.hasValidOtrSession() + && (message.getPresence() != null)) { + conv.startOtrSession(this, message.getPresence(), + true); + message.setStatus(Message.STATUS_WAITING); + } else if (conv.hasValidOtrSession() + && conv.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED) { + mJingleConnectionManager + .createNewConnection(message); + } else if (message.getPresence() == null) { + message.setStatus(Message.STATUS_WAITING); + } + } else { + mJingleConnectionManager.createNewConnection(message); + } + } else { + message.setStatus(Message.STATUS_WAITING); + } + } else { + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + if (!conv.hasValidOtrSession() + && (message.getPresence() != null)) { + conv.startOtrSession(this, message.getPresence(), true); + message.setStatus(Message.STATUS_WAITING); + } else if (conv.hasValidOtrSession() + && conv.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED) { + message.setPresence(conv.getOtrSession().getSessionID() + .getUserID()); + packet = mMessageGenerator.generateOtrChat(message); + send = true; + + } else if (message.getPresence() == null) { + message.setStatus(Message.STATUS_WAITING); + } + } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { + message.getConversation().endOtrIfNeeded(); + failWaitingOtrMessages(message.getConversation()); + packet = mMessageGenerator.generatePgpChat(message); + send = true; + } else { + message.getConversation().endOtrIfNeeded(); + failWaitingOtrMessages(message.getConversation()); + packet = mMessageGenerator.generateChat(message); + send = true; + } + } + if (!account.getXmppConnection().getFeatures().sm() + && conv.getMode() != Conversation.MODE_MULTI) { + message.setStatus(Message.STATUS_SEND); + } + } else { + message.setStatus(Message.STATUS_WAITING); + if (message.getType() == Message.TYPE_TEXT) { + if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { + String pgpBody = message.getEncryptedBody(); + String decryptedBody = message.getBody(); + message.setBody(pgpBody); + message.setEncryption(Message.ENCRYPTION_PGP); + databaseBackend.createMessage(message); + saveInDb = false; + message.setBody(decryptedBody); + message.setEncryption(Message.ENCRYPTION_DECRYPTED); + } else if (message.getEncryption() == Message.ENCRYPTION_OTR) { + if (conv.hasValidOtrSession()) { + message.setPresence(conv.getOtrSession().getSessionID() + .getUserID()); + } else if (!conv.hasValidOtrSession() + && message.getPresence() != null) { + conv.startOtrSession(this, message.getPresence(), false); + } + } + } + + } + conv.add(message); + if (saveInDb) { + if (message.getEncryption() == Message.ENCRYPTION_NONE + || saveEncryptedMessages()) { + databaseBackend.createMessage(message); + } + } + if ((send) && (packet != null)) { + sendMessagePacket(account, packet); + } + updateConversationUi(); + } + + private void sendUnsendMessages(Conversation conversation) { + for (int i = 0; i < conversation.getMessages().size(); ++i) { + int status = conversation.getMessages().get(i).getStatus(); + if (status == Message.STATUS_WAITING) { + resendMessage(conversation.getMessages().get(i)); + } + } + } + + private void resendMessage(Message message) { + Account account = message.getConversation().getAccount(); + MessagePacket packet = null; + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + Presences presences = message.getConversation().getContact() + .getPresences(); + if (!message.getConversation().hasValidOtrSession()) { + if ((message.getPresence() != null) + && (presences.has(message.getPresence()))) { + message.getConversation().startOtrSession(this, + message.getPresence(), true); + } else { + if (presences.size() == 1) { + String presence = presences.asStringArray()[0]; + message.getConversation().startOtrSession(this, + presence, true); + } + } + } else { + if (message.getConversation().getOtrSession() + .getSessionStatus() == SessionStatus.ENCRYPTED) { + if (message.getType() == Message.TYPE_TEXT) { + packet = mMessageGenerator.generateOtrChat(message, + true); + } else if (message.getType() == Message.TYPE_IMAGE) { + mJingleConnectionManager.createNewConnection(message); + } + } + } + } else if (message.getType() == Message.TYPE_TEXT) { + if (message.getEncryption() == Message.ENCRYPTION_NONE) { + packet = mMessageGenerator.generateChat(message, true); + } else if ((message.getEncryption() == Message.ENCRYPTION_DECRYPTED) + || (message.getEncryption() == Message.ENCRYPTION_PGP)) { + packet = mMessageGenerator.generatePgpChat(message, true); + } + } else if (message.getType() == Message.TYPE_IMAGE) { + Presences presences = message.getConversation().getContact() + .getPresences(); + if ((message.getPresence() != null) + && (presences.has(message.getPresence()))) { + markMessage(message, Message.STATUS_OFFERED); + mJingleConnectionManager.createNewConnection(message); + } else { + if (presences.size() == 1) { + String presence = presences.asStringArray()[0]; + message.setPresence(presence); + markMessage(message, Message.STATUS_OFFERED); + mJingleConnectionManager.createNewConnection(message); + } + } + } + if (packet != null) { + if (!account.getXmppConnection().getFeatures().sm() + && message.getConversation().getMode() != Conversation.MODE_MULTI) { + markMessage(message, Message.STATUS_SEND); + } else { + markMessage(message, Message.STATUS_UNSEND); + } + sendMessagePacket(account, packet); + } + } + + public void fetchRosterFromServer(Account account) { + IqPacket iqPacket = new IqPacket(IqPacket.TYPE_GET); + if (!"".equals(account.getRosterVersion())) { + Log.d(Config.LOGTAG, account.getJid() + + ": fetching roster version " + account.getRosterVersion()); + } else { + Log.d(Config.LOGTAG, account.getJid() + ": fetching roster"); + } + iqPacket.query("jabber:iq:roster").setAttribute("ver", + account.getRosterVersion()); + account.getXmppConnection().sendIqPacket(iqPacket, + new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(final Account account, + IqPacket packet) { + Element query = packet.findChild("query"); + if (query != null) { + account.getRoster().markAllAsNotInRoster(); + mIqParser.rosterItems(account, query); + } + } + }); + } + + public void fetchBookmarks(Account account) { + IqPacket iqPacket = new IqPacket(IqPacket.TYPE_GET); + Element query = iqPacket.query("jabber:iq:private"); + query.addChild("storage", "storage:bookmarks"); + OnIqPacketReceived callback = new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + Element query = packet.query(); + List bookmarks = new CopyOnWriteArrayList(); + Element storage = query.findChild("storage", + "storage:bookmarks"); + if (storage != null) { + for (Element item : storage.getChildren()) { + if (item.getName().equals("conference")) { + Bookmark bookmark = Bookmark.parse(item, account); + bookmarks.add(bookmark); + Conversation conversation = find(bookmark); + if (conversation != null) { + conversation.setBookmark(bookmark); + } else { + if (bookmark.autojoin()) { + conversation = findOrCreateConversation( + account, bookmark.getJid(), true); + conversation.setBookmark(bookmark); + joinMuc(conversation); + } + } + } + } + } + account.setBookmarks(bookmarks); + } + }; + sendIqPacket(account, iqPacket, callback); + + } + + public void pushBookmarks(Account account) { + IqPacket iqPacket = new IqPacket(IqPacket.TYPE_SET); + Element query = iqPacket.query("jabber:iq:private"); + Element storage = query.addChild("storage", "storage:bookmarks"); + for (Bookmark bookmark : account.getBookmarks()) { + storage.addChild(bookmark); + } + sendIqPacket(account, iqPacket, null); + } + + private void mergePhoneContactsWithRoster() { + PhoneHelper.loadPhoneContacts(getApplicationContext(), + new OnPhoneContactsLoadedListener() { + @Override + public void onPhoneContactsLoaded(List phoneContacts) { + for (Account account : accounts) { + account.getRoster().clearSystemAccounts(); + } + for (Bundle phoneContact : phoneContacts) { + for (Account account : accounts) { + String jid = phoneContact.getString("jid"); + Contact contact = account.getRoster() + .getContact(jid); + String systemAccount = phoneContact + .getInt("phoneid") + + "#" + + phoneContact.getString("lookup"); + contact.setSystemAccount(systemAccount); + contact.setPhotoUri(phoneContact + .getString("photouri")); + contact.setSystemName(phoneContact + .getString("displayname")); + getAvatarService().clear(contact); + } + } + } + }); + } + + public List getConversations() { + if (this.conversations == null) { + Hashtable accountLookupTable = new Hashtable(); + for (Account account : this.accounts) { + accountLookupTable.put(account.getUuid(), account); + } + this.conversations = databaseBackend + .getConversations(Conversation.STATUS_AVAILABLE); + for (Conversation conv : this.conversations) { + Account account = accountLookupTable.get(conv.getAccountUuid()); + conv.setAccount(account); + conv.setMessages(databaseBackend.getMessages(conv, 50)); + checkDeletedFiles(conv); + } + } + return this.conversations; + } + + private void checkDeletedFiles(Conversation conversation) { + for (Message message : conversation.getMessages()) { + if (message.getType() == Message.TYPE_IMAGE + && message.getEncryption() != Message.ENCRYPTION_PGP) { + if (!getFileBackend().isFileAvailable(message)) { + message.setDownloadable(new DeletedDownloadable()); + } + } + } + } + + private void markFileDeleted(String uuid) { + for (Conversation conversation : getConversations()) { + for (Message message : conversation.getMessages()) { + if (message.getType() == Message.TYPE_IMAGE + && message.getEncryption() != Message.ENCRYPTION_PGP + && message.getUuid().equals(uuid)) { + if (!getFileBackend().isFileAvailable(message)) { + message.setDownloadable(new DeletedDownloadable()); + updateConversationUi(); + } + return; + } + } + } + } + + public void populateWithOrderedConversations(List list) { + populateWithOrderedConversations(list, true); + } + + public void populateWithOrderedConversations(List list, + boolean includeConferences) { + list.clear(); + if (includeConferences) { + list.addAll(getConversations()); + } else { + for (Conversation conversation : getConversations()) { + if (conversation.getMode() == Conversation.MODE_SINGLE) { + list.add(conversation); + } + } + } + Collections.sort(list, new Comparator() { + @Override + public int compare(Conversation lhs, Conversation rhs) { + Message left = lhs.getLatestMessage(); + Message right = rhs.getLatestMessage(); + if (left.getTimeSent() > right.getTimeSent()) { + return -1; + } else if (left.getTimeSent() < right.getTimeSent()) { + return 1; + } else { + return 0; + } + } + }); + } + + public int loadMoreMessages(Conversation conversation, long timestamp) { + List messages = databaseBackend.getMessages(conversation, 50, + timestamp); + for (Message message : messages) { + message.setConversation(conversation); + } + conversation.addAll(0, messages); + return messages.size(); + } + + public List getAccounts() { + return this.accounts; + } + + public Conversation find(List haystack, Contact contact) { + for (Conversation conversation : haystack) { + if (conversation.getContact() == contact) { + return conversation; + } + } + return null; + } + + public Conversation find(List haystack, Account account, + String jid) { + for (Conversation conversation : haystack) { + if ((account == null || conversation.getAccount().equals(account)) + && (conversation.getContactJid().split("/", 2)[0] + .equals(jid))) { + return conversation; + } + } + return null; + } + + public Conversation findOrCreateConversation(Account account, String jid, + boolean muc) { + Conversation conversation = find(account, jid); + if (conversation != null) { + return conversation; + } + conversation = databaseBackend.findConversation(account, jid); + if (conversation != null) { + conversation.setStatus(Conversation.STATUS_AVAILABLE); + conversation.setAccount(account); + if (muc) { + conversation.setMode(Conversation.MODE_MULTI); + } else { + conversation.setMode(Conversation.MODE_SINGLE); + } + conversation.setMessages(databaseBackend.getMessages(conversation, + 50)); + this.databaseBackend.updateConversation(conversation); + } else { + String conversationName; + Contact contact = account.getRoster().getContact(jid); + if (contact != null) { + conversationName = contact.getDisplayName(); + } else { + conversationName = jid.split("@")[0]; + } + if (muc) { + conversation = new Conversation(conversationName, account, jid, + Conversation.MODE_MULTI); + } else { + conversation = new Conversation(conversationName, account, jid, + Conversation.MODE_SINGLE); + } + this.databaseBackend.createConversation(conversation); + } + this.conversations.add(conversation); + updateConversationUi(); + return conversation; + } + + public void archiveConversation(Conversation conversation) { + if (conversation.getMode() == Conversation.MODE_MULTI) { + if (conversation.getAccount().getStatus() == Account.STATUS_ONLINE) { + Bookmark bookmark = conversation.getBookmark(); + if (bookmark != null && bookmark.autojoin()) { + bookmark.setAutojoin(false); + pushBookmarks(bookmark.getAccount()); + } + } + leaveMuc(conversation); + } else { + conversation.endOtrIfNeeded(); + } + this.databaseBackend.updateConversation(conversation); + this.conversations.remove(conversation); + updateConversationUi(); + } + + public void clearConversationHistory(Conversation conversation) { + this.databaseBackend.deleteMessagesInConversation(conversation); + this.fileBackend.removeFiles(conversation); + conversation.getMessages().clear(); + updateConversationUi(); + } + + public int getConversationCount() { + return this.databaseBackend.getConversationCount(); + } + + public void createAccount(Account account) { + databaseBackend.createAccount(account); + this.accounts.add(account); + this.reconnectAccount(account, false); + updateAccountUi(); + } + + public void updateAccount(Account account) { + this.statusListener.onStatusChanged(account); + databaseBackend.updateAccount(account); + reconnectAccount(account, false); + updateAccountUi(); + UIHelper.showErrorNotification(getApplicationContext(), getAccounts()); + } + + public void deleteAccount(Account account) { + for (Conversation conversation : conversations) { + if (conversation.getAccount() == account) { + if (conversation.getMode() == Conversation.MODE_MULTI) { + leaveMuc(conversation); + } else if (conversation.getMode() == Conversation.MODE_SINGLE) { + conversation.endOtrIfNeeded(); + } + conversations.remove(conversation); + } + } + if (account.getXmppConnection() != null) { + this.disconnect(account, true); + } + databaseBackend.deleteAccount(account); + this.accounts.remove(account); + updateAccountUi(); + UIHelper.showErrorNotification(getApplicationContext(), getAccounts()); + } + + public void setOnConversationListChangedListener( + OnConversationUpdate listener) { + if (!isScreenOn()) { + Log.d(Config.LOGTAG, + "ignoring setOnConversationListChangedListener"); + return; + } + synchronized (this.convChangedListenerCount) { + if (checkListeners()) { + switchToForeground(); + } + this.mOnConversationUpdate = listener; + this.mNotificationService.setIsInForeground(true); + this.convChangedListenerCount++; + } + } + + public void removeOnConversationListChangedListener() { + synchronized (this.convChangedListenerCount) { + this.convChangedListenerCount--; + if (this.convChangedListenerCount <= 0) { + this.convChangedListenerCount = 0; + this.mOnConversationUpdate = null; + this.mNotificationService.setIsInForeground(false); + if (checkListeners()) { + switchToBackground(); + } + } + } + } + + public void setOnAccountListChangedListener(OnAccountUpdate listener) { + if (!isScreenOn()) { + Log.d(Config.LOGTAG, "ignoring setOnAccountListChangedListener"); + return; + } + synchronized (this.accountChangedListenerCount) { + if (checkListeners()) { + switchToForeground(); + } + this.mOnAccountUpdate = listener; + this.accountChangedListenerCount++; + } + } + + public void removeOnAccountListChangedListener() { + synchronized (this.accountChangedListenerCount) { + this.accountChangedListenerCount--; + if (this.accountChangedListenerCount <= 0) { + this.mOnAccountUpdate = null; + this.accountChangedListenerCount = 0; + if (checkListeners()) { + switchToBackground(); + } + } + } + } + + public void setOnRosterUpdateListener(OnRosterUpdate listener) { + if (!isScreenOn()) { + Log.d(Config.LOGTAG, "ignoring setOnRosterUpdateListener"); + return; + } + synchronized (this.rosterChangedListenerCount) { + if (checkListeners()) { + switchToForeground(); + } + this.mOnRosterUpdate = listener; + this.rosterChangedListenerCount++; + } + } + + public void removeOnRosterUpdateListener() { + synchronized (this.rosterChangedListenerCount) { + this.rosterChangedListenerCount--; + if (this.rosterChangedListenerCount <= 0) { + this.rosterChangedListenerCount = 0; + this.mOnRosterUpdate = null; + if (checkListeners()) { + switchToBackground(); + } + } + } + } + + private boolean checkListeners() { + return (this.mOnAccountUpdate == null + && this.mOnConversationUpdate == null && this.mOnRosterUpdate == null); + } + + private void switchToForeground() { + for (Account account : getAccounts()) { + if (account.getStatus() == Account.STATUS_ONLINE) { + XmppConnection connection = account.getXmppConnection(); + if (connection != null && connection.getFeatures().csi()) { + connection.sendActive(); + } + } + } + Log.d(Config.LOGTAG, "app switched into foreground"); + } + + private void switchToBackground() { + for (Account account : getAccounts()) { + if (account.getStatus() == Account.STATUS_ONLINE) { + XmppConnection connection = account.getXmppConnection(); + if (connection != null && connection.getFeatures().csi()) { + connection.sendInactive(); + } + } + } + this.mNotificationService.setIsInForeground(false); + Log.d(Config.LOGTAG, "app switched into background"); + } + + private boolean isScreenOn() { + PowerManager pm = (PowerManager) this + .getSystemService(Context.POWER_SERVICE); + return pm.isScreenOn(); + } + + public void connectMultiModeConversations(Account account) { + List conversations = getConversations(); + for (int i = 0; i < conversations.size(); i++) { + Conversation conversation = conversations.get(i); + if ((conversation.getMode() == Conversation.MODE_MULTI) + && (conversation.getAccount() == account)) { + joinMuc(conversation); + } + } + } + + public void joinMuc(Conversation conversation) { + Account account = conversation.getAccount(); + account.pendingConferenceJoins.remove(conversation); + account.pendingConferenceLeaves.remove(conversation); + if (account.getStatus() == Account.STATUS_ONLINE) { + Log.d(Config.LOGTAG, + "joining conversation " + conversation.getContactJid()); + String nick = conversation.getMucOptions().getProposedNick(); + conversation.getMucOptions().setJoinNick(nick); + PresencePacket packet = new PresencePacket(); + String joinJid = conversation.getMucOptions().getJoinJid(); + packet.setAttribute("to", conversation.getMucOptions().getJoinJid()); + Element x = new Element("x"); + x.setAttribute("xmlns", "http://jabber.org/protocol/muc"); + if (conversation.getMucOptions().getPassword() != null) { + Element password = x.addChild("password"); + password.setContent(conversation.getMucOptions().getPassword()); + } + String sig = account.getPgpSignature(); + if (sig != null) { + packet.addChild("status").setContent("online"); + packet.addChild("x", "jabber:x:signed").setContent(sig); + } + if (conversation.getMessages().size() != 0) { + final SimpleDateFormat mDateFormat = new SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + Date date = new Date(conversation.getLatestMessage() + .getTimeSent() + 1000); + x.addChild("history").setAttribute("since", + mDateFormat.format(date)); + } + packet.addChild(x); + sendPresencePacket(account, packet); + if (!joinJid.equals(conversation.getContactJid())) { + conversation.setContactJid(joinJid); + databaseBackend.updateConversation(conversation); + } + } else { + account.pendingConferenceJoins.add(conversation); + } + } + + private OnRenameListener renameListener = null; + private IqGenerator mIqGenerator = new IqGenerator(this); + + public void setOnRenameListener(OnRenameListener listener) { + this.renameListener = listener; + } + + public void providePasswordForMuc(Conversation conversation, String password) { + if (conversation.getMode() == Conversation.MODE_MULTI) { + conversation.getMucOptions().setPassword(password); + if (conversation.getBookmark() != null) { + conversation.getBookmark().setAutojoin(true); + pushBookmarks(conversation.getAccount()); + } + databaseBackend.updateConversation(conversation); + joinMuc(conversation); + } + } + + public void renameInMuc(final Conversation conversation, final String nick) { + final MucOptions options = conversation.getMucOptions(); + options.setJoinNick(nick); + if (options.online()) { + Account account = conversation.getAccount(); + options.setOnRenameListener(new OnRenameListener() { + + @Override + public void onRename(boolean success) { + if (renameListener != null) { + renameListener.onRename(success); + } + if (success) { + conversation.setContactJid(conversation.getMucOptions() + .getJoinJid()); + databaseBackend.updateConversation(conversation); + Bookmark bookmark = conversation.getBookmark(); + if (bookmark != null) { + bookmark.setNick(nick); + pushBookmarks(bookmark.getAccount()); + } + } + } + }); + options.flagAboutToRename(); + PresencePacket packet = new PresencePacket(); + packet.setAttribute("to", options.getJoinJid()); + packet.setAttribute("from", conversation.getAccount().getFullJid()); + + String sig = account.getPgpSignature(); + if (sig != null) { + packet.addChild("status").setContent("online"); + packet.addChild("x", "jabber:x:signed").setContent(sig); + } + sendPresencePacket(account, packet); + } else { + conversation.setContactJid(options.getJoinJid()); + databaseBackend.updateConversation(conversation); + if (conversation.getAccount().getStatus() == Account.STATUS_ONLINE) { + Bookmark bookmark = conversation.getBookmark(); + if (bookmark != null) { + bookmark.setNick(nick); + pushBookmarks(bookmark.getAccount()); + } + joinMuc(conversation); + } + } + } + + public void leaveMuc(Conversation conversation) { + Account account = conversation.getAccount(); + account.pendingConferenceJoins.remove(conversation); + account.pendingConferenceLeaves.remove(conversation); + if (account.getStatus() == Account.STATUS_ONLINE) { + PresencePacket packet = new PresencePacket(); + packet.setAttribute("to", conversation.getMucOptions().getJoinJid()); + packet.setAttribute("from", conversation.getAccount().getFullJid()); + packet.setAttribute("type", "unavailable"); + sendPresencePacket(conversation.getAccount(), packet); + conversation.getMucOptions().setOffline(); + conversation.deregisterWithBookmark(); + Log.d(Config.LOGTAG, conversation.getAccount().getJid() + + ": leaving muc " + conversation.getContactJid()); + } else { + account.pendingConferenceLeaves.add(conversation); + } + } + + public void disconnect(Account account, boolean force) { + if ((account.getStatus() == Account.STATUS_ONLINE) + || (account.getStatus() == Account.STATUS_DISABLED)) { + if (!force) { + List conversations = getConversations(); + for (int i = 0; i < conversations.size(); i++) { + Conversation conversation = conversations.get(i); + if (conversation.getAccount() == account) { + if (conversation.getMode() == Conversation.MODE_MULTI) { + leaveMuc(conversation); + } else { + if (conversation.endOtrIfNeeded()) { + Log.d(Config.LOGTAG, account.getJid() + + ": ended otr session with " + + conversation.getContactJid()); + } + } + } + } + } + account.getXmppConnection().disconnect(force); + } + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + public void updateMessage(Message message) { + databaseBackend.updateMessage(message); + updateConversationUi(); + } + + protected void syncDirtyContacts(Account account) { + for (Contact contact : account.getRoster().getContacts()) { + if (contact.getOption(Contact.Options.DIRTY_PUSH)) { + pushContactToServer(contact); + } + if (contact.getOption(Contact.Options.DIRTY_DELETE)) { + deleteContactOnServer(contact); + } + } + } + + public void createContact(Contact contact) { + SharedPreferences sharedPref = getPreferences(); + boolean autoGrant = sharedPref.getBoolean("grant_new_contacts", true); + if (autoGrant) { + contact.setOption(Contact.Options.PREEMPTIVE_GRANT); + contact.setOption(Contact.Options.ASKING); + } + pushContactToServer(contact); + } + + public void onOtrSessionEstablished(Conversation conversation) { + Account account = conversation.getAccount(); + List messages = conversation.getMessages(); + Session otrSession = conversation.getOtrSession(); + Log.d(Config.LOGTAG, + account.getJid() + " otr session established with " + + conversation.getContactJid() + "/" + + otrSession.getSessionID().getUserID()); + for (int i = 0; i < messages.size(); ++i) { + Message msg = messages.get(i); + if ((msg.getStatus() == Message.STATUS_UNSEND || msg.getStatus() == Message.STATUS_WAITING) + && (msg.getEncryption() == Message.ENCRYPTION_OTR)) { + msg.setPresence(otrSession.getSessionID().getUserID()); + if (msg.getType() == Message.TYPE_TEXT) { + MessagePacket outPacket = mMessageGenerator + .generateOtrChat(msg, true); + if (outPacket != null) { + msg.setStatus(Message.STATUS_SEND); + databaseBackend.updateMessage(msg); + sendMessagePacket(account, outPacket); + } + } else if (msg.getType() == Message.TYPE_IMAGE) { + mJingleConnectionManager.createNewConnection(msg); + } + } + } + updateConversationUi(); + } + + public boolean renewSymmetricKey(Conversation conversation) { + Account account = conversation.getAccount(); + byte[] symmetricKey = new byte[32]; + this.mRandom.nextBytes(symmetricKey); + Session otrSession = conversation.getOtrSession(); + if (otrSession != null) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_CHAT); + packet.setFrom(account.getFullJid()); + packet.addChild("private", "urn:xmpp:carbons:2"); + packet.addChild("no-copy", "urn:xmpp:hints"); + packet.setTo(otrSession.getSessionID().getAccountID() + "/" + + otrSession.getSessionID().getUserID()); + try { + packet.setBody(otrSession + .transformSending(CryptoHelper.FILETRANSFER + + CryptoHelper.bytesToHex(symmetricKey))); + sendMessagePacket(account, packet); + conversation.setSymmetricKey(symmetricKey); + return true; + } catch (OtrException e) { + return false; + } + } + return false; + } + + public void pushContactToServer(Contact contact) { + contact.resetOption(Contact.Options.DIRTY_DELETE); + contact.setOption(Contact.Options.DIRTY_PUSH); + Account account = contact.getAccount(); + if (account.getStatus() == Account.STATUS_ONLINE) { + boolean ask = contact.getOption(Contact.Options.ASKING); + boolean sendUpdates = contact + .getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST) + && contact.getOption(Contact.Options.PREEMPTIVE_GRANT); + IqPacket iq = new IqPacket(IqPacket.TYPE_SET); + iq.query("jabber:iq:roster").addChild(contact.asElement()); + account.getXmppConnection().sendIqPacket(iq, null); + if (sendUpdates) { + sendPresencePacket(account, + mPresenceGenerator.sendPresenceUpdatesTo(contact)); + } + if (ask) { + sendPresencePacket(account, + mPresenceGenerator.requestPresenceUpdatesFrom(contact)); + } + } + } + + public void publishAvatar(Account account, Uri image, + final UiCallback callback) { + final Bitmap.CompressFormat format = Config.AVATAR_FORMAT; + final int size = Config.AVATAR_SIZE; + final Avatar avatar = getFileBackend() + .getPepAvatar(image, size, format); + if (avatar != null) { + avatar.height = size; + avatar.width = size; + if (format.equals(Bitmap.CompressFormat.WEBP)) { + avatar.type = "image/webp"; + } else if (format.equals(Bitmap.CompressFormat.JPEG)) { + avatar.type = "image/jpeg"; + } else if (format.equals(Bitmap.CompressFormat.PNG)) { + avatar.type = "image/png"; + } + if (!getFileBackend().save(avatar)) { + callback.error(R.string.error_saving_avatar, avatar); + return; + } + IqPacket packet = this.mIqGenerator.publishAvatar(avatar); + this.sendIqPacket(account, packet, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket result) { + if (result.getType() == IqPacket.TYPE_RESULT) { + IqPacket packet = XmppConnectionService.this.mIqGenerator + .publishAvatarMetadata(avatar); + sendIqPacket(account, packet, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, + IqPacket result) { + if (result.getType() == IqPacket.TYPE_RESULT) { + if (account.setAvatar(avatar.getFilename())) { + databaseBackend.updateAccount(account); + } + callback.success(avatar); + } else { + callback.error( + R.string.error_publish_avatar_server_reject, + avatar); + } + } + }); + } else { + callback.error( + R.string.error_publish_avatar_server_reject, + avatar); + } + } + }); + } else { + callback.error(R.string.error_publish_avatar_converting, null); + } + } + + public void fetchAvatar(Account account, Avatar avatar) { + fetchAvatar(account, avatar, null); + } + + public void fetchAvatar(Account account, final Avatar avatar, + final UiCallback callback) { + IqPacket packet = this.mIqGenerator.retrieveAvatar(avatar); + sendIqPacket(account, packet, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket result) { + final String ERROR = account.getJid() + + ": fetching avatar for " + avatar.owner + " failed "; + if (result.getType() == IqPacket.TYPE_RESULT) { + avatar.image = mIqParser.avatarData(result); + if (avatar.image != null) { + if (getFileBackend().save(avatar)) { + if (account.getJid().equals(avatar.owner)) { + if (account.setAvatar(avatar.getFilename())) { + databaseBackend.updateAccount(account); + } + getAvatarService().clear(account); + updateConversationUi(); + updateAccountUi(); + } else { + Contact contact = account.getRoster() + .getContact(avatar.owner); + contact.setAvatar(avatar.getFilename()); + getAvatarService().clear(contact); + updateConversationUi(); + updateRosterUi(); + } + if (callback != null) { + callback.success(avatar); + } + Log.d(Config.LOGTAG, account.getJid() + + ": succesfully fetched avatar for " + + avatar.owner); + return; + } + } else { + + Log.d(Config.LOGTAG, ERROR + "(parsing error)"); + } + } else { + Element error = result.findChild("error"); + if (error == null) { + Log.d(Config.LOGTAG, ERROR + "(server error)"); + } else { + Log.d(Config.LOGTAG, ERROR + error.toString()); + } + } + if (callback != null) { + callback.error(0, null); + } + + } + }); + } + + public void checkForAvatar(Account account, + final UiCallback callback) { + IqPacket packet = this.mIqGenerator.retrieveAvatarMetaData(null); + this.sendIqPacket(account, packet, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + if (packet.getType() == IqPacket.TYPE_RESULT) { + Element pubsub = packet.findChild("pubsub", + "http://jabber.org/protocol/pubsub"); + if (pubsub != null) { + Element items = pubsub.findChild("items"); + if (items != null) { + Avatar avatar = Avatar.parseMetadata(items); + if (avatar != null) { + avatar.owner = account.getJid(); + if (fileBackend.isAvatarCached(avatar)) { + if (account.setAvatar(avatar.getFilename())) { + databaseBackend.updateAccount(account); + } + getAvatarService().clear(account); + callback.success(avatar); + } else { + fetchAvatar(account, avatar, callback); + } + return; + } + } + } + } + callback.error(0, null); + } + }); + } + + public void deleteContactOnServer(Contact contact) { + contact.resetOption(Contact.Options.PREEMPTIVE_GRANT); + contact.resetOption(Contact.Options.DIRTY_PUSH); + contact.setOption(Contact.Options.DIRTY_DELETE); + Account account = contact.getAccount(); + if (account.getStatus() == Account.STATUS_ONLINE) { + IqPacket iq = new IqPacket(IqPacket.TYPE_SET); + Element item = iq.query("jabber:iq:roster").addChild("item"); + item.setAttribute("jid", contact.getJid()); + item.setAttribute("subscription", "remove"); + account.getXmppConnection().sendIqPacket(iq, null); + } + } + + public void updateConversation(Conversation conversation) { + this.databaseBackend.updateConversation(conversation); + } + + public void reconnectAccount(final Account account, final boolean force) { + new Thread(new Runnable() { + + @Override + public void run() { + if (account.getXmppConnection() != null) { + disconnect(account, force); + } + if (!account.isOptionSet(Account.OPTION_DISABLED)) { + if (account.getXmppConnection() == null) { + account.setXmppConnection(createConnection(account)); + } + Thread thread = new Thread(account.getXmppConnection()); + thread.start(); + scheduleWakeupCall((int) (Config.CONNECT_TIMEOUT * 1.2), + false); + } else { + account.getRoster().clearPresences(); + account.setXmppConnection(null); + } + } + }).start(); + } + + public void invite(Conversation conversation, String contact) { + MessagePacket packet = mMessageGenerator.invite(conversation, contact); + sendMessagePacket(conversation.getAccount(), packet); + } + + public void resetSendingToWaiting(Account account) { + for (Conversation conversation : getConversations()) { + if (conversation.getAccount() == account) { + for (Message message : conversation.getMessages()) { + if (message.getType() != Message.TYPE_IMAGE + && message.getStatus() == Message.STATUS_UNSEND) { + markMessage(message, Message.STATUS_WAITING); + } + } + } + } + } + + public boolean markMessage(Account account, String recipient, String uuid, + int status) { + if (uuid == null) { + return false; + } else { + for (Conversation conversation : getConversations()) { + if (conversation.getContactJid().equals(recipient) + && conversation.getAccount().equals(account)) { + return markMessage(conversation, uuid, status); + } + } + return false; + } + } + + public boolean markMessage(Conversation conversation, String uuid, + int status) { + if (uuid == null) { + return false; + } else { + for (Message message : conversation.getMessages()) { + if (uuid.equals(message.getUuid()) + || (message.getStatus() >= Message.STATUS_SEND && uuid + .equals(message.getRemoteMsgId()))) { + markMessage(message, status); + return true; + } + } + return false; + } + } + + public void markMessage(Message message, int status) { + if (status == Message.STATUS_SEND_FAILED + && (message.getStatus() == Message.STATUS_SEND_RECEIVED || message + .getStatus() == Message.STATUS_SEND_DISPLAYED)) { + return; + } + message.setStatus(status); + databaseBackend.updateMessage(message); + updateConversationUi(); + } + + public SharedPreferences getPreferences() { + return PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()); + } + + public boolean forceEncryption() { + return getPreferences().getBoolean("force_encryption", false); + } + + public boolean confirmMessages() { + return getPreferences().getBoolean("confirm_messages", true); + } + + public boolean saveEncryptedMessages() { + return !getPreferences().getBoolean("dont_save_encrypted", false); + } + + public boolean indicateReceived() { + return getPreferences().getBoolean("indicate_received", false); + } + + public void updateConversationUi() { + if (mOnConversationUpdate != null) { + mOnConversationUpdate.onConversationUpdate(); + } + } + + public void updateAccountUi() { + if (mOnAccountUpdate != null) { + mOnAccountUpdate.onAccountUpdate(); + } + } + + public void updateRosterUi() { + if (mOnRosterUpdate != null) { + mOnRosterUpdate.onRosterUpdate(); + } + } + + public Account findAccountByJid(String accountJid) { + for (Account account : this.accounts) { + if (account.getJid().equals(accountJid)) { + return account; + } + } + return null; + } + + public Conversation findConversationByUuid(String uuid) { + for (Conversation conversation : getConversations()) { + if (conversation.getUuid().equals(uuid)) { + return conversation; + } + } + return null; + } + + public void markRead(Conversation conversation, boolean calledByUi) { + mNotificationService.clear(conversation); + String id = conversation.getLatestMarkableMessageId(); + conversation.markRead(); + if (confirmMessages() && id != null && calledByUi) { + Log.d(Config.LOGTAG, conversation.getAccount().getJid() + + ": sending read marker for " + conversation.getName()); + Account account = conversation.getAccount(); + String to = conversation.getContactJid(); + this.sendMessagePacket(conversation.getAccount(), + mMessageGenerator.confirm(account, to, id)); + } + if (!calledByUi) { + updateConversationUi(); + } + } + + public void failWaitingOtrMessages(Conversation conversation) { + for (Message message : conversation.getMessages()) { + if (message.getEncryption() == Message.ENCRYPTION_OTR + && message.getStatus() == Message.STATUS_WAITING) { + markMessage(message, Message.STATUS_SEND_FAILED); + } + } + } + + public SecureRandom getRNG() { + return this.mRandom; + } + + public MemorizingTrustManager getMemorizingTrustManager() { + return this.mMemorizingTrustManager; + } + + public PowerManager getPowerManager() { + return this.pm; + } + + public LruCache getBitmapCache() { + return this.mBitmapCache; + } + + public void replyWithNotAcceptable(Account account, MessagePacket packet) { + if (account.getStatus() == Account.STATUS_ONLINE) { + MessagePacket error = this.mMessageGenerator + .generateNotAcceptable(packet); + sendMessagePacket(account, error); + } + } + + public void syncRosterToDisk(final Account account) { + new Thread(new Runnable() { + + @Override + public void run() { + databaseBackend.writeRoster(account.getRoster()); + } + }).start(); + + } + + public List getKnownHosts() { + List hosts = new ArrayList(); + for (Account account : getAccounts()) { + if (!hosts.contains(account.getServer())) { + hosts.add(account.getServer()); + } + for (Contact contact : account.getRoster().getContacts()) { + if (contact.showInRoster()) { + String server = contact.getServer(); + if (server != null && !hosts.contains(server)) { + hosts.add(server); + } + } + } + } + return hosts; + } + + public List getKnownConferenceHosts() { + ArrayList mucServers = new ArrayList(); + for (Account account : accounts) { + if (account.getXmppConnection() != null) { + String server = account.getXmppConnection().getMucServer(); + if (server != null && !mucServers.contains(server)) { + mucServers.add(server); + } + } + } + return mucServers; + } + + public void sendMessagePacket(Account account, MessagePacket packet) { + XmppConnection connection = account.getXmppConnection(); + if (connection != null) { + connection.sendMessagePacket(packet); + } + } + + public void sendPresencePacket(Account account, PresencePacket packet) { + XmppConnection connection = account.getXmppConnection(); + if (connection != null) { + connection.sendPresencePacket(packet); + } + } + + public void sendIqPacket(Account account, IqPacket packet, + OnIqPacketReceived callback) { + XmppConnection connection = account.getXmppConnection(); + if (connection != null) { + connection.sendIqPacket(packet, callback); + } + } + + public MessageGenerator getMessageGenerator() { + return this.mMessageGenerator; + } + + public PresenceGenerator getPresenceGenerator() { + return this.mPresenceGenerator; + } + + public IqGenerator getIqGenerator() { + return this.mIqGenerator; + } + + public JingleConnectionManager getJingleConnectionManager() { + return this.mJingleConnectionManager; + } + + public interface OnConversationUpdate { + public void onConversationUpdate(); + } + + public interface OnAccountUpdate { + public void onAccountUpdate(); + } + + public interface OnRosterUpdate { + public void onRosterUpdate(); + } + + public List findContacts(String jid) { + ArrayList contacts = new ArrayList(); + for (Account account : getAccounts()) { + if (!account.isOptionSet(Account.OPTION_DISABLED)) { + Contact contact = account.getRoster().getContactFromRoster(jid); + if (contact != null) { + contacts.add(contact); + } + } + } + return contacts; + } + + public NotificationService getNotificationService() { + return this.mNotificationService; + } + + public HttpConnectionManager getHttpConnectionManager() { + return this.mHttpConnectionManager; + } + + private class DeletedDownloadable implements Downloadable { + + @Override + public boolean start() { + return false; + } + + @Override + public int getStatus() { + return Downloadable.STATUS_DELETED; + } + + @Override + public long getFileSize() { + return 0; + } + + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java new file mode 100644 index 000000000..62a2cbe15 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ChooseContactActivity.java @@ -0,0 +1,145 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.Collections; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ListView; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.ListItem; +import eu.siacs.conversations.ui.adapter.ListItemAdapter; + +public class ChooseContactActivity extends XmppActivity { + + private ListView mListView; + private ArrayList contacts = new ArrayList(); + private ArrayAdapter mContactsAdapter; + + private EditText mSearchEditText; + + private TextWatcher mSearchTextWatcher = new TextWatcher() { + + @Override + public void afterTextChanged(Editable editable) { + filterContacts(editable.toString()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, + int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, + int count) { + } + }; + + private MenuItem.OnActionExpandListener mOnActionExpandListener = new MenuItem.OnActionExpandListener() { + + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + mSearchEditText.post(new Runnable() { + + @Override + public void run() { + mSearchEditText.requestFocus(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mSearchEditText, + InputMethodManager.SHOW_IMPLICIT); + } + }); + + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mSearchEditText.getWindowToken(), + InputMethodManager.HIDE_IMPLICIT_ONLY); + mSearchEditText.setText(""); + filterContacts(null); + return true; + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_choose_contact); + mListView = (ListView) findViewById(R.id.choose_contact_list); + mListView.setFastScrollEnabled(true); + mContactsAdapter = new ListItemAdapter(this, contacts); + mListView.setAdapter(mContactsAdapter); + mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + + @Override + public void onItemClick(AdapterView arg0, View arg1, + int position, long arg3) { + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mSearchEditText.getWindowToken(), + InputMethodManager.HIDE_IMPLICIT_ONLY); + Intent request = getIntent(); + Intent data = new Intent(); + ListItem mListItem = contacts.get(position); + data.putExtra("contact", mListItem.getJid()); + String account = request.getStringExtra("account"); + if (account == null && mListItem instanceof Contact) { + account = ((Contact) mListItem).getAccount().getJid(); + } + data.putExtra("account", account); + data.putExtra("conversation", + request.getStringExtra("conversation")); + setResult(RESULT_OK, data); + finish(); + } + }); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.choose_contact, menu); + MenuItem menuSearchView = (MenuItem) menu.findItem(R.id.action_search); + View mSearchView = menuSearchView.getActionView(); + mSearchEditText = (EditText) mSearchView + .findViewById(R.id.search_field); + mSearchEditText.addTextChangedListener(mSearchTextWatcher); + menuSearchView.setOnActionExpandListener(mOnActionExpandListener); + return true; + } + + @Override + void onBackendConnected() { + filterContacts(null); + } + + protected void filterContacts(String needle) { + this.contacts.clear(); + for (Account account : xmppConnectionService.getAccounts()) { + if (account.getStatus() != Account.STATUS_DISABLED) { + for (Contact contact : account.getRoster().getContacts()) { + if (contact.showInRoster() && contact.match(needle)) { + this.contacts.add(contact); + } + } + } + } + Collections.sort(this.contacts); + mContactsAdapter.notifyDataSetChanged(); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java new file mode 100644 index 000000000..6b4642cbe --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java @@ -0,0 +1,280 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.List; + +import org.openintents.openpgp.util.OpenPgpUtils; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.PgpEngine; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.MucOptions; +import eu.siacs.conversations.entities.MucOptions.OnRenameListener; +import eu.siacs.conversations.entities.MucOptions.User; +import eu.siacs.conversations.services.XmppConnectionService.OnConversationUpdate; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; +import android.app.PendingIntent; +import android.content.Context; +import android.content.IntentSender.SendIntentException; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +public class ConferenceDetailsActivity extends XmppActivity { + public static final String ACTION_VIEW_MUC = "view_muc"; + private Conversation conversation; + private TextView mYourNick; + private ImageView mYourPhoto; + private ImageButton mEditNickButton; + private TextView mRoleAffiliaton; + private TextView mFullJid; + private TextView mAccountJid; + private LinearLayout membersView; + private LinearLayout mMoreDetails; + private Button mInviteButton; + private String uuid = null; + + private OnClickListener inviteListener = new OnClickListener() { + + @Override + public void onClick(View v) { + inviteToConversation(conversation); + } + }; + + private List users = new ArrayList(); + private OnConversationUpdate onConvChanged = new OnConversationUpdate() { + + @Override + public void onConversationUpdate() { + runOnUiThread(new Runnable() { + + @Override + public void run() { + populateView(); + } + }); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_muc_details); + mYourNick = (TextView) findViewById(R.id.muc_your_nick); + mYourPhoto = (ImageView) findViewById(R.id.your_photo); + mEditNickButton = (ImageButton) findViewById(R.id.edit_nick_button); + mFullJid = (TextView) findViewById(R.id.muc_jabberid); + membersView = (LinearLayout) findViewById(R.id.muc_members); + mAccountJid = (TextView) findViewById(R.id.details_account); + mMoreDetails = (LinearLayout) findViewById(R.id.muc_more_details); + mMoreDetails.setVisibility(View.GONE); + mInviteButton = (Button) findViewById(R.id.invite); + mInviteButton.setOnClickListener(inviteListener); + getActionBar().setHomeButtonEnabled(true); + getActionBar().setDisplayHomeAsUpEnabled(true); + mEditNickButton.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + quickEdit(conversation.getMucOptions().getActualNick(), + new OnValueEdited() { + + @Override + public void onValueEdited(String value) { + xmppConnectionService.renameInMuc(conversation, + value); + } + }); + } + }); + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + break; + case R.id.action_edit_subject: + if (conversation != null) { + quickEdit(conversation.getName(), new OnValueEdited() { + + @Override + public void onValueEdited(String value) { + MessagePacket packet = xmppConnectionService + .getMessageGenerator().conferenceSubject( + conversation, value); + xmppConnectionService.sendMessagePacket( + conversation.getAccount(), packet); + } + }); + } + break; + } + return super.onOptionsItemSelected(menuItem); + } + + public String getReadableRole(int role) { + switch (role) { + case User.ROLE_MODERATOR: + return getString(R.string.moderator); + case User.ROLE_PARTICIPANT: + return getString(R.string.participant); + case User.ROLE_VISITOR: + return getString(R.string.visitor); + default: + return ""; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.muc_details, menu); + return true; + } + + @Override + void onBackendConnected() { + registerListener(); + if (getIntent().getAction().equals(ACTION_VIEW_MUC)) { + this.uuid = getIntent().getExtras().getString("uuid"); + } + if (uuid != null) { + this.conversation = xmppConnectionService + .findConversationByUuid(uuid); + if (this.conversation != null) { + populateView(); + } + } + } + + @Override + protected void onStop() { + if (xmppConnectionServiceBound) { + xmppConnectionService.removeOnConversationListChangedListener(); + } + super.onStop(); + } + + protected void registerListener() { + xmppConnectionService + .setOnConversationListChangedListener(this.onConvChanged); + xmppConnectionService.setOnRenameListener(new OnRenameListener() { + + @Override + public void onRename(final boolean success) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + populateView(); + if (success) { + Toast.makeText( + ConferenceDetailsActivity.this, + getString(R.string.your_nick_has_been_changed), + Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(ConferenceDetailsActivity.this, + getString(R.string.nick_in_use), + Toast.LENGTH_SHORT).show(); + } + } + }); + } + }); + } + + private void populateView() { + mAccountJid.setText(getString(R.string.using_account, conversation + .getAccount().getJid())); + mYourPhoto.setImageBitmap(avatarService().get( + conversation.getAccount(), getPixel(48))); + setTitle(conversation.getName()); + mFullJid.setText(conversation.getContactJid().split("/", 2)[0]); + mYourNick.setText(conversation.getMucOptions().getActualNick()); + mRoleAffiliaton = (TextView) findViewById(R.id.muc_role); + if (conversation.getMucOptions().online()) { + mMoreDetails.setVisibility(View.VISIBLE); + User self = conversation.getMucOptions().getSelf(); + switch (self.getAffiliation()) { + case User.AFFILIATION_ADMIN: + mRoleAffiliaton.setText(getReadableRole(self.getRole()) + " (" + + getString(R.string.admin) + ")"); + break; + case User.AFFILIATION_OWNER: + mRoleAffiliaton.setText(getReadableRole(self.getRole()) + " (" + + getString(R.string.owner) + ")"); + break; + default: + mRoleAffiliaton.setText(getReadableRole(self.getRole())); + break; + } + } + this.users.clear(); + this.users.addAll(conversation.getMucOptions().getUsers()); + LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + membersView.removeAllViews(); + for (final User user : conversation.getMucOptions().getUsers()) { + View view = (View) inflater.inflate(R.layout.contact, membersView, + false); + TextView name = (TextView) view + .findViewById(R.id.contact_display_name); + TextView key = (TextView) view.findViewById(R.id.key); + TextView role = (TextView) view.findViewById(R.id.contact_jid); + if (user.getPgpKeyId() != 0) { + key.setVisibility(View.VISIBLE); + key.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + viewPgpKey(user); + } + }); + key.setText(OpenPgpUtils.convertKeyIdToHex(user.getPgpKeyId())); + } + Bitmap bm; + Contact contact = user.getContact(); + if (contact != null) { + bm = avatarService().get(contact, getPixel(48)); + name.setText(contact.getDisplayName()); + role.setText(user.getName() + " \u2022 " + + getReadableRole(user.getRole())); + } else { + bm = avatarService().get(user.getName(), getPixel(48)); + name.setText(user.getName()); + role.setText(getReadableRole(user.getRole())); + } + ImageView iv = (ImageView) view.findViewById(R.id.contact_photo); + iv.setImageBitmap(bm); + membersView.addView(view); + } + } + + private void viewPgpKey(User user) { + PgpEngine pgp = xmppConnectionService.getPgpEngine(); + if (pgp != null) { + PendingIntent intent = pgp.getIntentForKey( + conversation.getAccount(), user.getPgpKeyId()); + if (intent != null) { + try { + startIntentSenderForResult(intent.getIntentSender(), 0, + null, 0, 0, 0); + } catch (SendIntentException e) { + + } + } + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java new file mode 100644 index 000000000..ae26466e3 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -0,0 +1,436 @@ +package eu.siacs.conversations.ui; + +import java.util.Iterator; + +import org.openintents.openpgp.util.OpenPgpUtils; + +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentSender.SendIntentException; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract.CommonDataKinds; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Intents; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.CheckBox; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.CompoundButton; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.QuickContactBadge; +import android.widget.TextView; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.PgpEngine; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Presences; +import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; +import eu.siacs.conversations.services.XmppConnectionService.OnRosterUpdate; +import eu.siacs.conversations.utils.UIHelper; + +public class ContactDetailsActivity extends XmppActivity { + public static final String ACTION_VIEW_CONTACT = "view_contact"; + + private Contact contact; + + private String accountJid; + private String contactJid; + + private TextView contactJidTv; + private TextView accountJidTv; + private TextView status; + private TextView lastseen; + private CheckBox send; + private CheckBox receive; + private QuickContactBadge badge; + + private DialogInterface.OnClickListener removeFromRoster = new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + ContactDetailsActivity.this.xmppConnectionService + .deleteContactOnServer(contact); + ContactDetailsActivity.this.finish(); + } + }; + + private DialogInterface.OnClickListener addToPhonebook = new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + intent.setType(Contacts.CONTENT_ITEM_TYPE); + intent.putExtra(Intents.Insert.IM_HANDLE, contact.getJid()); + intent.putExtra(Intents.Insert.IM_PROTOCOL, + CommonDataKinds.Im.PROTOCOL_JABBER); + intent.putExtra("finishActivityOnSaveCompleted", true); + ContactDetailsActivity.this.startActivityForResult(intent, 0); + } + }; + private OnClickListener onBadgeClick = new OnClickListener() { + + @Override + public void onClick(View v) { + AlertDialog.Builder builder = new AlertDialog.Builder( + ContactDetailsActivity.this); + builder.setTitle(getString(R.string.action_add_phone_book)); + builder.setMessage(getString(R.string.add_phone_book_text, + contact.getJid())); + builder.setNegativeButton(getString(R.string.cancel), null); + builder.setPositiveButton(getString(R.string.add), addToPhonebook); + builder.create().show(); + } + }; + + private LinearLayout keys; + + private OnRosterUpdate rosterUpdate = new OnRosterUpdate() { + + @Override + public void onRosterUpdate() { + runOnUiThread(new Runnable() { + + @Override + public void run() { + populateView(); + } + }); + } + }; + + private OnCheckedChangeListener mOnSendCheckedChange = new OnCheckedChangeListener() { + + @Override + public void onCheckedChanged(CompoundButton buttonView, + boolean isChecked) { + if (isChecked) { + if (contact + .getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) { + xmppConnectionService.sendPresencePacket(contact + .getAccount(), + xmppConnectionService.getPresenceGenerator() + .sendPresenceUpdatesTo(contact)); + } else { + contact.setOption(Contact.Options.PREEMPTIVE_GRANT); + } + } else { + contact.resetOption(Contact.Options.PREEMPTIVE_GRANT); + xmppConnectionService.sendPresencePacket(contact.getAccount(), + xmppConnectionService.getPresenceGenerator() + .stopPresenceUpdatesTo(contact)); + } + } + }; + + private OnCheckedChangeListener mOnReceiveCheckedChange = new OnCheckedChangeListener() { + + @Override + public void onCheckedChanged(CompoundButton buttonView, + boolean isChecked) { + if (isChecked) { + xmppConnectionService.sendPresencePacket(contact.getAccount(), + xmppConnectionService.getPresenceGenerator() + .requestPresenceUpdatesFrom(contact)); + } else { + xmppConnectionService.sendPresencePacket(contact.getAccount(), + xmppConnectionService.getPresenceGenerator() + .stopPresenceUpdatesFrom(contact)); + } + } + }; + + private OnAccountUpdate accountUpdate = new OnAccountUpdate() { + + @Override + public void onAccountUpdate() { + runOnUiThread(new Runnable() { + + @Override + public void run() { + populateView(); + } + }); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getIntent().getAction().equals(ACTION_VIEW_CONTACT)) { + this.accountJid = getIntent().getExtras().getString("account"); + this.contactJid = getIntent().getExtras().getString("contact"); + } + setContentView(R.layout.activity_contact_details); + + contactJidTv = (TextView) findViewById(R.id.details_contactjid); + accountJidTv = (TextView) findViewById(R.id.details_account); + status = (TextView) findViewById(R.id.details_contactstatus); + lastseen = (TextView) findViewById(R.id.details_lastseen); + send = (CheckBox) findViewById(R.id.details_send_presence); + receive = (CheckBox) findViewById(R.id.details_receive_presence); + badge = (QuickContactBadge) findViewById(R.id.details_contact_badge); + keys = (LinearLayout) findViewById(R.id.details_contact_keys); + getActionBar().setHomeButtonEnabled(true); + getActionBar().setDisplayHomeAsUpEnabled(true); + + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setNegativeButton(getString(R.string.cancel), null); + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + break; + case R.id.action_delete_contact: + builder.setTitle(getString(R.string.action_delete_contact)) + .setMessage( + getString(R.string.remove_contact_text, + contact.getJid())) + .setPositiveButton(getString(R.string.delete), + removeFromRoster).create().show(); + break; + case R.id.action_edit_contact: + if (contact.getSystemAccount() == null) { + quickEdit(contact.getDisplayName(), new OnValueEdited() { + + @Override + public void onValueEdited(String value) { + contact.setServerName(value); + ContactDetailsActivity.this.xmppConnectionService + .pushContactToServer(contact); + populateView(); + } + }); + } else { + Intent intent = new Intent(Intent.ACTION_EDIT); + String[] systemAccount = contact.getSystemAccount().split("#"); + long id = Long.parseLong(systemAccount[0]); + Uri uri = Contacts.getLookupUri(id, systemAccount[1]); + intent.setDataAndType(uri, Contacts.CONTENT_ITEM_TYPE); + intent.putExtra("finishActivityOnSaveCompleted", true); + startActivity(intent); + } + break; + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.contact_details, menu); + return true; + } + + private void populateView() { + send.setOnCheckedChangeListener(null); + receive.setOnCheckedChangeListener(null); + setTitle(contact.getDisplayName()); + if (contact.getOption(Contact.Options.FROM)) { + send.setText(R.string.send_presence_updates); + send.setChecked(true); + } else if (contact + .getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) { + send.setChecked(false); + send.setText(R.string.send_presence_updates); + } else { + send.setText(R.string.preemptively_grant); + if (contact.getOption(Contact.Options.PREEMPTIVE_GRANT)) { + send.setChecked(true); + } else { + send.setChecked(false); + } + } + if (contact.getOption(Contact.Options.TO)) { + receive.setText(R.string.receive_presence_updates); + receive.setChecked(true); + } else { + receive.setText(R.string.ask_for_presence_updates); + if (contact.getOption(Contact.Options.ASKING)) { + receive.setChecked(true); + } else { + receive.setChecked(false); + } + } + if (contact.getAccount().getStatus() == Account.STATUS_ONLINE) { + receive.setEnabled(true); + send.setEnabled(true); + } else { + receive.setEnabled(false); + send.setEnabled(false); + } + + send.setOnCheckedChangeListener(this.mOnSendCheckedChange); + receive.setOnCheckedChangeListener(this.mOnReceiveCheckedChange); + + lastseen.setText(UIHelper.lastseen(getApplicationContext(), + contact.lastseen.time)); + + switch (contact.getMostAvailableStatus()) { + case Presences.CHAT: + status.setText(R.string.contact_status_free_to_chat); + status.setTextColor(mColorGreen); + break; + case Presences.ONLINE: + status.setText(R.string.contact_status_online); + status.setTextColor(mColorGreen); + break; + case Presences.AWAY: + status.setText(R.string.contact_status_away); + status.setTextColor(mColorOrange); + break; + case Presences.XA: + status.setText(R.string.contact_status_extended_away); + status.setTextColor(mColorOrange); + break; + case Presences.DND: + status.setText(R.string.contact_status_do_not_disturb); + status.setTextColor(mColorRed); + break; + case Presences.OFFLINE: + status.setText(R.string.contact_status_offline); + status.setTextColor(mSecondaryTextColor); + break; + default: + status.setText(R.string.contact_status_offline); + status.setTextColor(mSecondaryTextColor); + break; + } + if (contact.getPresences().size() > 1) { + contactJidTv.setText(contact.getJid() + " (" + + contact.getPresences().size() + ")"); + } else { + contactJidTv.setText(contact.getJid()); + } + accountJidTv.setText(getString(R.string.using_account, contact + .getAccount().getJid())); + prepareContactBadge(badge, contact); + if (contact.getSystemAccount() == null) { + badge.setOnClickListener(onBadgeClick); + } + + keys.removeAllViews(); + boolean hasKeys = false; + LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + for (Iterator iterator = contact.getOtrFingerprints() + .iterator(); iterator.hasNext();) { + hasKeys = true; + final String otrFingerprint = iterator.next(); + View view = (View) inflater.inflate(R.layout.contact_key, keys, + false); + TextView key = (TextView) view.findViewById(R.id.key); + TextView keyType = (TextView) view.findViewById(R.id.key_type); + ImageButton remove = (ImageButton) view + .findViewById(R.id.button_remove); + remove.setVisibility(View.VISIBLE); + keyType.setText("OTR Fingerprint"); + key.setText(otrFingerprint); + keys.addView(view); + remove.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + confirmToDeleteFingerprint(otrFingerprint); + } + }); + } + if (contact.getPgpKeyId() != 0) { + hasKeys = true; + View view = (View) inflater.inflate(R.layout.contact_key, keys, + false); + TextView key = (TextView) view.findViewById(R.id.key); + TextView keyType = (TextView) view.findViewById(R.id.key_type); + keyType.setText("PGP Key ID"); + key.setText(OpenPgpUtils.convertKeyIdToHex(contact.getPgpKeyId())); + view.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + PgpEngine pgp = ContactDetailsActivity.this.xmppConnectionService + .getPgpEngine(); + if (pgp != null) { + PendingIntent intent = pgp.getIntentForKey(contact); + if (intent != null) { + try { + startIntentSenderForResult( + intent.getIntentSender(), 0, null, 0, + 0, 0); + } catch (SendIntentException e) { + + } + } + } + } + }); + keys.addView(view); + } + if (hasKeys) { + keys.setVisibility(View.VISIBLE); + } else { + keys.setVisibility(View.GONE); + } + } + + private void prepareContactBadge(QuickContactBadge badge, Contact contact) { + if (contact.getSystemAccount() != null) { + String[] systemAccount = contact.getSystemAccount().split("#"); + long id = Long.parseLong(systemAccount[0]); + badge.assignContactUri(Contacts.getLookupUri(id, systemAccount[1])); + } + badge.setImageBitmap(avatarService().get(contact, getPixel(72))); + } + + protected void confirmToDeleteFingerprint(final String fingerprint) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.delete_fingerprint); + builder.setMessage(R.string.sure_delete_fingerprint); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.delete, + new android.content.DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + if (contact.deleteOtrFingerprint(fingerprint)) { + populateView(); + xmppConnectionService.syncRosterToDisk(contact + .getAccount()); + } + } + + }); + builder.create().show(); + } + + @Override + public void onBackendConnected() { + xmppConnectionService.setOnRosterUpdateListener(this.rosterUpdate); + xmppConnectionService + .setOnAccountListChangedListener(this.accountUpdate); + if ((accountJid != null) && (contactJid != null)) { + Account account = xmppConnectionService + .findAccountByJid(accountJid); + if (account == null) { + return; + } + this.contact = account.getRoster().getContact(contactJid); + populateView(); + } + } + + @Override + protected void onStop() { + super.onStop(); + xmppConnectionService.removeOnRosterUpdateListener(); + xmppConnectionService.removeOnAccountListChangedListener(); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ConversationActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/ConversationActivity.java new file mode 100644 index 000000000..91e1c81f9 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ConversationActivity.java @@ -0,0 +1,947 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; +import eu.siacs.conversations.services.XmppConnectionService.OnConversationUpdate; +import eu.siacs.conversations.services.XmppConnectionService.OnRosterUpdate; +import eu.siacs.conversations.ui.adapter.ConversationAdapter; +import eu.siacs.conversations.utils.ExceptionHelper; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.provider.MediaStore; +import android.annotation.SuppressLint; +import android.app.ActionBar; +import android.app.AlertDialog; +import android.app.FragmentTransaction; +import android.app.PendingIntent; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.IntentSender.SendIntentException; +import android.content.Intent; +import android.support.v4.widget.SlidingPaneLayout; +import android.support.v4.widget.SlidingPaneLayout.PanelSlideListener; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.ListView; +import android.widget.PopupMenu; +import android.widget.PopupMenu.OnMenuItemClickListener; +import android.widget.Toast; + +public class ConversationActivity extends XmppActivity implements + OnAccountUpdate, OnConversationUpdate, OnRosterUpdate { + + public static final String VIEW_CONVERSATION = "viewConversation"; + public static final String CONVERSATION = "conversationUuid"; + public static final String TEXT = "text"; + public static final String PRESENCE = "eu.siacs.conversations.presence"; + + public static final int REQUEST_SEND_MESSAGE = 0x0201; + public static final int REQUEST_DECRYPT_PGP = 0x0202; + private static final int REQUEST_ATTACH_FILE_DIALOG = 0x0203; + private static final int REQUEST_IMAGE_CAPTURE = 0x0204; + private static final int REQUEST_RECORD_AUDIO = 0x0205; + private static final int REQUEST_SEND_PGP_IMAGE = 0x0206; + public static final int REQUEST_ENCRYPT_MESSAGE = 0x0207; + + private static final int ATTACHMENT_CHOICE_CHOOSE_IMAGE = 0x0301; + private static final int ATTACHMENT_CHOICE_TAKE_PHOTO = 0x0302; + private static final int ATTACHMENT_CHOICE_RECORD_VOICE = 0x0303; + private static final String STATE_OPEN_CONVERSATION = "state_open_conversation"; + private static final String STATE_PANEL_OPEN = "state_panel_open"; + + private String mOpenConverstaion = null; + private boolean mPanelOpen = true; + + private View mContentView; + + private List conversationList = new ArrayList(); + private Conversation selectedConversation = null; + private ListView listView; + + private boolean paneShouldBeOpen = true; + private ArrayAdapter listAdapter; + + private Toast prepareImageToast; + + private Uri pendingImageUri = null; + + public List getConversationList() { + return this.conversationList; + } + + public Conversation getSelectedConversation() { + return this.selectedConversation; + } + + public void setSelectedConversation(Conversation conversation) { + this.selectedConversation = conversation; + } + + public ListView getConversationListView() { + return this.listView; + } + + public boolean shouldPaneBeOpen() { + return paneShouldBeOpen; + } + + public void showConversationsOverview() { + if (mContentView instanceof SlidingPaneLayout) { + SlidingPaneLayout mSlidingPaneLayout = (SlidingPaneLayout) mContentView; + mSlidingPaneLayout.openPane(); + } + } + + public void hideConversationsOverview() { + if (mContentView instanceof SlidingPaneLayout) { + SlidingPaneLayout mSlidingPaneLayout = (SlidingPaneLayout) mContentView; + mSlidingPaneLayout.closePane(); + } + } + + public boolean isConversationsOverviewHideable() { + if (mContentView instanceof SlidingPaneLayout) { + SlidingPaneLayout mSlidingPaneLayout = (SlidingPaneLayout) mContentView; + return mSlidingPaneLayout.isSlideable(); + } else { + return false; + } + } + + public boolean isConversationsOverviewVisable() { + if (mContentView instanceof SlidingPaneLayout) { + SlidingPaneLayout mSlidingPaneLayout = (SlidingPaneLayout) mContentView; + return mSlidingPaneLayout.isOpen(); + } else { + return true; + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState != null) { + mOpenConverstaion = savedInstanceState.getString( + STATE_OPEN_CONVERSATION, null); + mPanelOpen = savedInstanceState.getBoolean(STATE_PANEL_OPEN, true); + } + + setContentView(R.layout.fragment_conversations_overview); + + listView = (ListView) findViewById(R.id.list); + + getActionBar().setDisplayHomeAsUpEnabled(false); + getActionBar().setHomeButtonEnabled(false); + + this.listAdapter = new ConversationAdapter(this, conversationList); + listView.setAdapter(this.listAdapter); + + listView.setOnItemClickListener(new OnItemClickListener() { + + @Override + public void onItemClick(AdapterView arg0, View clickedView, + int position, long arg3) { + paneShouldBeOpen = false; + if (getSelectedConversation() != conversationList.get(position)) { + setSelectedConversation(conversationList.get(position)); + swapConversationFragment(); + } else { + hideConversationsOverview(); + } + } + }); + mContentView = findViewById(R.id.content_view_spl); + if (mContentView == null) { + mContentView = findViewById(R.id.content_view_ll); + } + if (mContentView instanceof SlidingPaneLayout) { + SlidingPaneLayout mSlidingPaneLayout = (SlidingPaneLayout) mContentView; + mSlidingPaneLayout.setParallaxDistance(150); + mSlidingPaneLayout + .setShadowResource(R.drawable.es_slidingpane_shadow); + mSlidingPaneLayout.setSliderFadeColor(0); + mSlidingPaneLayout.setPanelSlideListener(new PanelSlideListener() { + + @Override + public void onPanelOpened(View arg0) { + paneShouldBeOpen = true; + ActionBar ab = getActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(false); + ab.setHomeButtonEnabled(false); + ab.setTitle(R.string.app_name); + } + invalidateOptionsMenu(); + hideKeyboard(); + if (xmppConnectionServiceBound) { + xmppConnectionService.getNotificationService() + .setOpenConversation(null); + } + } + + @Override + public void onPanelClosed(View arg0) { + paneShouldBeOpen = false; + if ((conversationList.size() > 0) + && (getSelectedConversation() != null)) { + openConversation(getSelectedConversation()); + if (!getSelectedConversation().isRead()) { + xmppConnectionService.markRead( + getSelectedConversation(), true); + listView.invalidateViews(); + } + } + } + + @Override + public void onPanelSlide(View arg0, float arg1) { + // TODO Auto-generated method stub + + } + }); + } + } + + public void openConversation(Conversation conversation) { + ActionBar ab = getActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + ab.setHomeButtonEnabled(true); + if (getSelectedConversation().getMode() == Conversation.MODE_SINGLE + || ConversationActivity.this + .useSubjectToIdentifyConference()) { + ab.setTitle(getSelectedConversation().getName()); + } else { + ab.setTitle(getSelectedConversation().getContactJid() + .split("/")[0]); + } + } + invalidateOptionsMenu(); + if (xmppConnectionServiceBound) { + xmppConnectionService.getNotificationService().setOpenConversation( + conversation); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.conversations, menu); + MenuItem menuSecure = (MenuItem) menu.findItem(R.id.action_security); + MenuItem menuArchive = (MenuItem) menu.findItem(R.id.action_archive); + MenuItem menuMucDetails = (MenuItem) menu + .findItem(R.id.action_muc_details); + MenuItem menuContactDetails = (MenuItem) menu + .findItem(R.id.action_contact_details); + MenuItem menuAttach = (MenuItem) menu.findItem(R.id.action_attach_file); + MenuItem menuClearHistory = (MenuItem) menu + .findItem(R.id.action_clear_history); + MenuItem menuAdd = (MenuItem) menu.findItem(R.id.action_add); + MenuItem menuInviteContact = (MenuItem) menu + .findItem(R.id.action_invite); + MenuItem menuMute = (MenuItem) menu.findItem(R.id.action_mute); + + if (isConversationsOverviewVisable() + && isConversationsOverviewHideable()) { + menuArchive.setVisible(false); + menuMucDetails.setVisible(false); + menuContactDetails.setVisible(false); + menuSecure.setVisible(false); + menuInviteContact.setVisible(false); + menuAttach.setVisible(false); + menuClearHistory.setVisible(false); + menuMute.setVisible(false); + } else { + menuAdd.setVisible(!isConversationsOverviewHideable()); + if (this.getSelectedConversation() != null) { + if (this.getSelectedConversation().getLatestMessage() + .getEncryption() != Message.ENCRYPTION_NONE) { + menuSecure.setIcon(R.drawable.ic_action_secure); + } + if (this.getSelectedConversation().getMode() == Conversation.MODE_MULTI) { + menuContactDetails.setVisible(false); + menuAttach.setVisible(false); + } else { + menuMucDetails.setVisible(false); + menuInviteContact.setVisible(false); + } + } + } + return true; + } + + private void selectPresenceToAttachFile(final int attachmentChoice) { + selectPresence(getSelectedConversation(), new OnPresenceSelected() { + + @Override + public void onPresenceSelected() { + if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO) { + pendingImageUri = xmppConnectionService.getFileBackend() + .getTakePhotoUri(); + Intent takePictureIntent = new Intent( + MediaStore.ACTION_IMAGE_CAPTURE); + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, + pendingImageUri); + if (takePictureIntent.resolveActivity(getPackageManager()) != null) { + startActivityForResult(takePictureIntent, + REQUEST_IMAGE_CAPTURE); + } + } else if (attachmentChoice == ATTACHMENT_CHOICE_CHOOSE_IMAGE) { + Intent attachFileIntent = new Intent(); + attachFileIntent.setType("image/*"); + attachFileIntent.setAction(Intent.ACTION_GET_CONTENT); + Intent chooser = Intent.createChooser(attachFileIntent, + getString(R.string.attach_file)); + startActivityForResult(chooser, REQUEST_ATTACH_FILE_DIALOG); + } else if (attachmentChoice == ATTACHMENT_CHOICE_RECORD_VOICE) { + Intent intent = new Intent( + MediaStore.Audio.Media.RECORD_SOUND_ACTION); + startActivityForResult(intent, REQUEST_RECORD_AUDIO); + } + } + }); + } + + private void attachFile(final int attachmentChoice) { + final Conversation conversation = getSelectedConversation(); + if (conversation.getNextEncryption(forceEncryption()) == Message.ENCRYPTION_PGP) { + if (hasPgp()) { + if (conversation.getContact().getPgpKeyId() != 0) { + xmppConnectionService.getPgpEngine().hasKey( + conversation.getContact(), + new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, + Contact contact) { + ConversationActivity.this.runIntent(pi, + attachmentChoice); + } + + @Override + public void success(Contact contact) { + selectPresenceToAttachFile(attachmentChoice); + } + + @Override + public void error(int error, Contact contact) { + displayErrorDialog(error); + } + }); + } else { + final ConversationFragment fragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (fragment != null) { + fragment.showNoPGPKeyDialog(false, + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + conversation + .setNextEncryption(Message.ENCRYPTION_NONE); + xmppConnectionService.databaseBackend + .updateConversation(conversation); + selectPresenceToAttachFile(attachmentChoice); + } + }); + } + } + } else { + showInstallPgpDialog(); + } + } else if (getSelectedConversation().getNextEncryption( + forceEncryption()) == Message.ENCRYPTION_NONE) { + selectPresenceToAttachFile(attachmentChoice); + } else { + selectPresenceToAttachFile(attachmentChoice); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + showConversationsOverview(); + return true; + } else if (item.getItemId() == R.id.action_add) { + startActivity(new Intent(this, StartConversationActivity.class)); + return true; + } else if (getSelectedConversation() != null) { + switch (item.getItemId()) { + case R.id.action_attach_file: + attachFileDialog(); + break; + case R.id.action_archive: + this.endConversation(getSelectedConversation()); + break; + case R.id.action_contact_details: + Contact contact = this.getSelectedConversation().getContact(); + if (contact.showInRoster()) { + switchToContactDetails(contact); + } else { + showAddToRosterDialog(getSelectedConversation()); + } + break; + case R.id.action_muc_details: + Intent intent = new Intent(this, + ConferenceDetailsActivity.class); + intent.setAction(ConferenceDetailsActivity.ACTION_VIEW_MUC); + intent.putExtra("uuid", getSelectedConversation().getUuid()); + startActivity(intent); + break; + case R.id.action_invite: + inviteToConversation(getSelectedConversation()); + break; + case R.id.action_security: + selectEncryptionDialog(getSelectedConversation()); + break; + case R.id.action_clear_history: + clearHistoryDialog(getSelectedConversation()); + break; + case R.id.action_mute: + muteConversationDialog(getSelectedConversation()); + break; + default: + break; + } + return super.onOptionsItemSelected(item); + } else { + return super.onOptionsItemSelected(item); + } + } + + public void endConversation(Conversation conversation) { + conversation.setStatus(Conversation.STATUS_ARCHIVED); + paneShouldBeOpen = true; + showConversationsOverview(); + xmppConnectionService.archiveConversation(conversation); + if (conversationList.size() > 0) { + setSelectedConversation(conversationList.get(0)); + } else { + setSelectedConversation(null); + } + } + + @SuppressLint("InflateParams") + protected void clearHistoryDialog(final Conversation conversation) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.clear_conversation_history)); + View dialogView = getLayoutInflater().inflate( + R.layout.dialog_clear_history, null); + final CheckBox endConversationCheckBox = (CheckBox) dialogView + .findViewById(R.id.end_conversation_checkbox); + builder.setView(dialogView); + builder.setNegativeButton(getString(R.string.cancel), null); + builder.setPositiveButton(getString(R.string.delete_messages), + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + ConversationActivity.this.xmppConnectionService + .clearConversationHistory(conversation); + if (endConversationCheckBox.isChecked()) { + endConversation(conversation); + } + } + }); + builder.create().show(); + } + + protected void attachFileDialog() { + View menuAttachFile = findViewById(R.id.action_attach_file); + if (menuAttachFile == null) { + return; + } + PopupMenu attachFilePopup = new PopupMenu(this, menuAttachFile); + attachFilePopup.inflate(R.menu.attachment_choices); + attachFilePopup + .setOnMenuItemClickListener(new OnMenuItemClickListener() { + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.attach_choose_picture: + attachFile(ATTACHMENT_CHOICE_CHOOSE_IMAGE); + break; + case R.id.attach_take_picture: + attachFile(ATTACHMENT_CHOICE_TAKE_PHOTO); + break; + case R.id.attach_record_voice: + attachFile(ATTACHMENT_CHOICE_RECORD_VOICE); + break; + } + return false; + } + }); + attachFilePopup.show(); + } + + protected void selectEncryptionDialog(final Conversation conversation) { + View menuItemView = findViewById(R.id.action_security); + if (menuItemView == null) { + return; + } + PopupMenu popup = new PopupMenu(this, menuItemView); + final ConversationFragment fragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (fragment != null) { + popup.setOnMenuItemClickListener(new OnMenuItemClickListener() { + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.encryption_choice_none: + conversation.setNextEncryption(Message.ENCRYPTION_NONE); + item.setChecked(true); + break; + case R.id.encryption_choice_otr: + conversation.setNextEncryption(Message.ENCRYPTION_OTR); + item.setChecked(true); + break; + case R.id.encryption_choice_pgp: + if (hasPgp()) { + if (conversation.getAccount().getKeys() + .has("pgp_signature")) { + conversation + .setNextEncryption(Message.ENCRYPTION_PGP); + item.setChecked(true); + } else { + announcePgp(conversation.getAccount(), + conversation); + } + } else { + showInstallPgpDialog(); + } + break; + default: + conversation.setNextEncryption(Message.ENCRYPTION_NONE); + break; + } + xmppConnectionService.databaseBackend + .updateConversation(conversation); + fragment.updateChatMsgHint(); + return true; + } + }); + popup.inflate(R.menu.encryption_choices); + MenuItem otr = popup.getMenu().findItem(R.id.encryption_choice_otr); + MenuItem none = popup.getMenu().findItem( + R.id.encryption_choice_none); + if (conversation.getMode() == Conversation.MODE_MULTI) { + otr.setEnabled(false); + } else { + if (forceEncryption()) { + none.setVisible(false); + } + } + switch (conversation.getNextEncryption(forceEncryption())) { + case Message.ENCRYPTION_NONE: + none.setChecked(true); + break; + case Message.ENCRYPTION_OTR: + otr.setChecked(true); + break; + case Message.ENCRYPTION_PGP: + popup.getMenu().findItem(R.id.encryption_choice_pgp) + .setChecked(true); + break; + default: + popup.getMenu().findItem(R.id.encryption_choice_none) + .setChecked(true); + break; + } + popup.show(); + } + } + + protected void muteConversationDialog(final Conversation conversation) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.disable_notifications_for_this_conversation); + final int[] durations = getResources().getIntArray( + R.array.mute_options_durations); + builder.setItems(R.array.mute_options_descriptions, + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + long till; + if (durations[which] == -1) { + till = Long.MAX_VALUE; + } else { + till = SystemClock.elapsedRealtime() + + (durations[which] * 1000); + } + conversation.setMutedTill(till); + ConversationActivity.this.xmppConnectionService.databaseBackend + .updateConversation(conversation); + ConversationFragment selectedFragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (selectedFragment != null) { + selectedFragment.updateMessages(); + } + } + }); + builder.create().show(); + } + + protected ConversationFragment swapConversationFragment() { + ConversationFragment selectedFragment = new ConversationFragment(); + if (!isFinishing()) { + + FragmentTransaction transaction = getFragmentManager() + .beginTransaction(); + transaction.replace(R.id.selected_conversation, selectedFragment, + "conversation"); + try { + transaction.commitAllowingStateLoss(); + } catch (IllegalStateException e) { + return selectedFragment; + } + } + return selectedFragment; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (!isConversationsOverviewVisable()) { + showConversationsOverview(); + return false; + } + } + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onNewIntent(Intent intent) { + if (xmppConnectionServiceBound) { + if ((Intent.ACTION_VIEW.equals(intent.getAction()) && (VIEW_CONVERSATION + .equals(intent.getType())))) { + String convToView = (String) intent.getExtras().get( + CONVERSATION); + updateConversationList(); + for (int i = 0; i < conversationList.size(); ++i) { + if (conversationList.get(i).getUuid().equals(convToView)) { + setSelectedConversation(conversationList.get(i)); + break; + } + } + paneShouldBeOpen = false; + String text = intent.getExtras().getString(TEXT, null); + swapConversationFragment().setText(text); + } + } else { + handledViewIntent = false; + setIntent(intent); + } + } + + @Override + public void onStart() { + super.onStart(); + if (this.xmppConnectionServiceBound) { + this.onBackendConnected(); + } + if (conversationList.size() >= 1) { + this.onConversationUpdate(); + } + } + + @Override + protected void onStop() { + if (xmppConnectionServiceBound) { + xmppConnectionService.removeOnConversationListChangedListener(); + xmppConnectionService.removeOnAccountListChangedListener(); + xmppConnectionService.removeOnRosterUpdateListener(); + xmppConnectionService.getNotificationService().setOpenConversation( + null); + } + super.onStop(); + } + + @Override + public void onSaveInstanceState(Bundle savedInstanceState) { + Conversation conversation = getSelectedConversation(); + if (conversation != null) { + savedInstanceState.putString(STATE_OPEN_CONVERSATION, + conversation.getUuid()); + } + savedInstanceState.putBoolean(STATE_PANEL_OPEN, + isConversationsOverviewVisable()); + super.onSaveInstanceState(savedInstanceState); + } + + @Override + void onBackendConnected() { + this.registerListener(); + updateConversationList(); + + if (xmppConnectionService.getAccounts().size() == 0) { + startActivity(new Intent(this, EditAccountActivity.class)); + } else if (conversationList.size() <= 0) { + startActivity(new Intent(this, StartConversationActivity.class)); + finish(); + } else if (mOpenConverstaion != null) { + selectConversationByUuid(mOpenConverstaion); + paneShouldBeOpen = mPanelOpen; + if (paneShouldBeOpen) { + showConversationsOverview(); + } + swapConversationFragment(); + mOpenConverstaion = null; + } else if (getIntent() != null + && VIEW_CONVERSATION.equals(getIntent().getType())) { + String uuid = (String) getIntent().getExtras().get(CONVERSATION); + String text = getIntent().getExtras().getString(TEXT, null); + selectConversationByUuid(uuid); + paneShouldBeOpen = false; + swapConversationFragment().setText(text); + setIntent(null); + } else { + showConversationsOverview(); + ConversationFragment selectedFragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (selectedFragment != null) { + selectedFragment.onBackendConnected(); + } else { + pendingImageUri = null; + setSelectedConversation(conversationList.get(0)); + swapConversationFragment(); + } + } + + if (pendingImageUri != null) { + attachImageToConversation(getSelectedConversation(), + pendingImageUri); + pendingImageUri = null; + } + ExceptionHelper.checkForCrash(this, this.xmppConnectionService); + } + + private void selectConversationByUuid(String uuid) { + for (int i = 0; i < conversationList.size(); ++i) { + if (conversationList.get(i).getUuid().equals(uuid)) { + setSelectedConversation(conversationList.get(i)); + } + } + } + + public void registerListener() { + xmppConnectionService.setOnConversationListChangedListener(this); + xmppConnectionService.setOnAccountListChangedListener(this); + xmppConnectionService.setOnRosterUpdateListener(this); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, + final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + if (requestCode == REQUEST_DECRYPT_PGP) { + ConversationFragment selectedFragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (selectedFragment != null) { + selectedFragment.hideSnackbar(); + selectedFragment.updateMessages(); + } + } else if (requestCode == REQUEST_ATTACH_FILE_DIALOG) { + pendingImageUri = data.getData(); + if (xmppConnectionServiceBound) { + attachImageToConversation(getSelectedConversation(), + pendingImageUri); + pendingImageUri = null; + } + } else if (requestCode == REQUEST_SEND_PGP_IMAGE) { + + } else if (requestCode == ATTACHMENT_CHOICE_CHOOSE_IMAGE) { + attachFile(ATTACHMENT_CHOICE_CHOOSE_IMAGE); + } else if (requestCode == ATTACHMENT_CHOICE_TAKE_PHOTO) { + attachFile(ATTACHMENT_CHOICE_TAKE_PHOTO); + } else if (requestCode == REQUEST_ANNOUNCE_PGP) { + announcePgp(getSelectedConversation().getAccount(), + getSelectedConversation()); + } else if (requestCode == REQUEST_ENCRYPT_MESSAGE) { + // encryptTextMessage(); + } else if (requestCode == REQUEST_IMAGE_CAPTURE) { + if (xmppConnectionServiceBound) { + attachImageToConversation(getSelectedConversation(), + pendingImageUri); + pendingImageUri = null; + } + Intent intent = new Intent( + Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + intent.setData(pendingImageUri); + sendBroadcast(intent); + } else if (requestCode == REQUEST_RECORD_AUDIO) { + attachAudioToConversation(getSelectedConversation(), + data.getData()); + } + } else { + if (requestCode == REQUEST_IMAGE_CAPTURE) { + pendingImageUri = null; + } + } + } + + private void attachAudioToConversation(Conversation conversation, Uri uri) { + + } + + private void attachImageToConversation(Conversation conversation, Uri uri) { + prepareImageToast = Toast.makeText(getApplicationContext(), + getText(R.string.preparing_image), Toast.LENGTH_LONG); + prepareImageToast.show(); + xmppConnectionService.attachImageToConversation(conversation, uri, + new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, + Message object) { + hidePrepareImageToast(); + ConversationActivity.this.runIntent(pi, + ConversationActivity.REQUEST_SEND_PGP_IMAGE); + } + + @Override + public void success(Message message) { + xmppConnectionService.sendMessage(message); + } + + @Override + public void error(int error, Message message) { + hidePrepareImageToast(); + displayErrorDialog(error); + } + }); + } + + private void hidePrepareImageToast() { + if (prepareImageToast != null) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + prepareImageToast.cancel(); + } + }); + } + } + + public void updateConversationList() { + xmppConnectionService + .populateWithOrderedConversations(conversationList); + listAdapter.notifyDataSetChanged(); + } + + public void runIntent(PendingIntent pi, int requestCode) { + try { + this.startIntentSenderForResult(pi.getIntentSender(), requestCode, + null, 0, 0, 0); + } catch (SendIntentException e1) { + } + } + + public void encryptTextMessage(Message message) { + xmppConnectionService.getPgpEngine().encrypt(message, + new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, + Message message) { + ConversationActivity.this.runIntent(pi, + ConversationActivity.REQUEST_SEND_MESSAGE); + } + + @Override + public void success(Message message) { + message.setEncryption(Message.ENCRYPTION_DECRYPTED); + xmppConnectionService.sendMessage(message); + } + + @Override + public void error(int error, Message message) { + + } + }); + } + + public boolean forceEncryption() { + return getPreferences().getBoolean("force_encryption", false); + } + + public boolean useSendButtonToIndicateStatus() { + return getPreferences().getBoolean("send_button_status", false); + } + + public boolean indicateReceived() { + return getPreferences().getBoolean("indicate_received", false); + } + + @Override + public void onAccountUpdate() { + final ConversationFragment fragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (fragment != null) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + fragment.updateMessages(); + } + }); + } + } + + @Override + public void onConversationUpdate() { + runOnUiThread(new Runnable() { + + @Override + public void run() { + updateConversationList(); + if (paneShouldBeOpen) { + if (conversationList.size() >= 1) { + swapConversationFragment(); + } else { + startActivity(new Intent(getApplicationContext(), + StartConversationActivity.class)); + finish(); + } + } + ConversationFragment selectedFragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (selectedFragment != null) { + selectedFragment.updateMessages(); + } + } + }); + } + + @Override + public void onRosterUpdate() { + final ConversationFragment fragment = (ConversationFragment) getFragmentManager() + .findFragmentByTag("conversation"); + if (fragment != null) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + fragment.updateMessages(); + } + }); + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/conversations/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java new file mode 100644 index 000000000..0e71801bd --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -0,0 +1,781 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; + +import net.java.otr4j.session.SessionStatus; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.PgpEngine; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.MucOptions; +import eu.siacs.conversations.entities.Presences; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.EditMessage.OnEnterPressed; +import eu.siacs.conversations.ui.XmppActivity.OnPresenceSelected; +import eu.siacs.conversations.ui.XmppActivity.OnValueEdited; +import eu.siacs.conversations.ui.adapter.MessageAdapter; +import eu.siacs.conversations.ui.adapter.MessageAdapter.OnContactPictureClicked; +import eu.siacs.conversations.ui.adapter.MessageAdapter.OnContactPictureLongClicked; +import eu.siacs.conversations.utils.UIHelper; +import android.app.AlertDialog; +import android.app.Fragment; +import android.app.PendingIntent; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentSender; +import android.content.IntentSender.SendIntentException; +import android.os.Bundle; +import android.text.Editable; +import android.text.Selection; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.AbsListView.OnScrollListener; +import android.widget.TextView.OnEditorActionListener; +import android.widget.AbsListView; + +import android.widget.ListView; +import android.widget.ImageButton; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +public class ConversationFragment extends Fragment { + + protected Conversation conversation; + protected ListView messagesView; + protected LayoutInflater inflater; + protected List messageList = new ArrayList(); + protected MessageAdapter messageListAdapter; + protected Contact contact; + + protected String queuedPqpMessage = null; + + private EditMessage mEditMessage; + private ImageButton mSendButton; + private String pastedText = null; + private RelativeLayout snackbar; + private TextView snackbarMessage; + private TextView snackbarAction; + + private boolean messagesLoaded = false; + + private IntentSender askForPassphraseIntent = null; + + private ConcurrentLinkedQueue mEncryptedMessages = new ConcurrentLinkedQueue(); + private boolean mDecryptJobRunning = false; + + private OnEditorActionListener mEditorActionListener = new OnEditorActionListener() { + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEND) { + InputMethodManager imm = (InputMethodManager) v.getContext() + .getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(v.getWindowToken(), 0); + sendMessage(); + return true; + } else { + return false; + } + } + }; + + private OnClickListener mSendButtonListener = new OnClickListener() { + + @Override + public void onClick(View v) { + sendMessage(); + } + }; + protected OnClickListener clickToDecryptListener = new OnClickListener() { + + @Override + public void onClick(View v) { + if (activity.hasPgp() && askForPassphraseIntent != null) { + try { + getActivity().startIntentSenderForResult( + askForPassphraseIntent, + ConversationActivity.REQUEST_DECRYPT_PGP, null, 0, + 0, 0); + } catch (SendIntentException e) { + // + } + } + } + }; + + private OnClickListener clickToMuc = new OnClickListener() { + + @Override + public void onClick(View v) { + Intent intent = new Intent(getActivity(), + ConferenceDetailsActivity.class); + intent.setAction(ConferenceDetailsActivity.ACTION_VIEW_MUC); + intent.putExtra("uuid", conversation.getUuid()); + startActivity(intent); + } + }; + + private OnClickListener leaveMuc = new OnClickListener() { + + @Override + public void onClick(View v) { + activity.endConversation(conversation); + } + }; + + private OnClickListener joinMuc = new OnClickListener() { + + @Override + public void onClick(View v) { + activity.xmppConnectionService.joinMuc(conversation); + } + }; + + private OnClickListener enterPassword = new OnClickListener() { + + @Override + public void onClick(View v) { + MucOptions muc = conversation.getMucOptions(); + String password = muc.getPassword(); + if (password == null) { + password = ""; + } + activity.quickPasswordEdit(password, new OnValueEdited() { + + @Override + public void onValueEdited(String value) { + activity.xmppConnectionService.providePasswordForMuc( + conversation, value); + } + }); + } + }; + + private OnScrollListener mOnScrollListener = new OnScrollListener() { + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + // TODO Auto-generated method stub + + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, + int visibleItemCount, int totalItemCount) { + if (firstVisibleItem == 0 && messagesLoaded) { + long timestamp = messageList.get(0).getTimeSent(); + messagesLoaded = false; + int size = activity.xmppConnectionService.loadMoreMessages( + conversation, timestamp); + messageList.clear(); + messageList.addAll(conversation.getMessages()); + updateStatusMessages(); + messageListAdapter.notifyDataSetChanged(); + if (size != 0) { + messagesLoaded = true; + } + messagesView.setSelectionFromTop(size + 1, 0); + } + } + }; + + private ConversationActivity activity; + + private void sendMessage() { + if (this.conversation == null) { + return; + } + if (mEditMessage.getText().length() < 1) { + if (this.conversation.getMode() == Conversation.MODE_MULTI) { + conversation.setNextPresence(null); + updateChatMsgHint(); + } + return; + } + Message message = new Message(conversation, mEditMessage.getText() + .toString(), conversation.getNextEncryption(activity + .forceEncryption())); + if (conversation.getMode() == Conversation.MODE_MULTI) { + if (conversation.getNextPresence() != null) { + message.setPresence(conversation.getNextPresence()); + message.setType(Message.TYPE_PRIVATE); + conversation.setNextPresence(null); + } + } + if (conversation.getNextEncryption(activity.forceEncryption()) == Message.ENCRYPTION_OTR) { + sendOtrMessage(message); + } else if (conversation.getNextEncryption(activity.forceEncryption()) == Message.ENCRYPTION_PGP) { + sendPgpMessage(message); + } else { + sendPlainTextMessage(message); + } + } + + public void updateChatMsgHint() { + if (conversation.getMode() == Conversation.MODE_MULTI + && conversation.getNextPresence() != null) { + this.mEditMessage.setHint(getString( + R.string.send_private_message_to, + conversation.getNextPresence())); + } else { + switch (conversation.getNextEncryption(activity.forceEncryption())) { + case Message.ENCRYPTION_NONE: + mEditMessage + .setHint(getString(R.string.send_plain_text_message)); + break; + case Message.ENCRYPTION_OTR: + mEditMessage.setHint(getString(R.string.send_otr_message)); + break; + case Message.ENCRYPTION_PGP: + mEditMessage.setHint(getString(R.string.send_pgp_message)); + break; + default: + break; + } + } + } + + @Override + public View onCreateView(final LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.fragment_conversation, + container, false); + mEditMessage = (EditMessage) view.findViewById(R.id.textinput); + mEditMessage.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + activity.hideConversationsOverview(); + } + }); + mEditMessage.setOnEditorActionListener(mEditorActionListener); + mEditMessage.setOnEnterPressedListener(new OnEnterPressed() { + + @Override + public void onEnterPressed() { + sendMessage(); + } + }); + + mSendButton = (ImageButton) view.findViewById(R.id.textSendButton); + mSendButton.setOnClickListener(this.mSendButtonListener); + + snackbar = (RelativeLayout) view.findViewById(R.id.snackbar); + snackbarMessage = (TextView) view.findViewById(R.id.snackbar_message); + snackbarAction = (TextView) view.findViewById(R.id.snackbar_action); + + messagesView = (ListView) view.findViewById(R.id.messages_view); + messagesView.setOnScrollListener(mOnScrollListener); + messagesView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL); + messageListAdapter = new MessageAdapter( + (ConversationActivity) getActivity(), this.messageList); + messageListAdapter + .setOnContactPictureClicked(new OnContactPictureClicked() { + + @Override + public void onContactPictureClicked(Message message) { + if (message.getStatus() <= Message.STATUS_RECEIVED) { + if (message.getConversation().getMode() == Conversation.MODE_MULTI) { + if (message.getPresence() != null) { + highlightInConference(message.getPresence()); + } else { + highlightInConference(message + .getCounterpart()); + } + } else { + Contact contact = message.getConversation() + .getContact(); + if (contact.showInRoster()) { + activity.switchToContactDetails(contact); + } else { + activity.showAddToRosterDialog(message + .getConversation()); + } + } + } + } + }); + messageListAdapter + .setOnContactPictureLongClicked(new OnContactPictureLongClicked() { + + @Override + public void onContactPictureLongClicked(Message message) { + if (message.getStatus() <= Message.STATUS_RECEIVED) { + if (message.getConversation().getMode() == Conversation.MODE_MULTI) { + if (message.getPresence() != null) { + privateMessageWith(message.getPresence()); + } else { + privateMessageWith(message.getCounterpart()); + } + } + } + } + }); + messagesView.setAdapter(messageListAdapter); + + return view; + } + + protected void privateMessageWith(String counterpart) { + this.mEditMessage.setText(""); + this.conversation.setNextPresence(counterpart); + updateChatMsgHint(); + } + + protected void highlightInConference(String nick) { + String oldString = mEditMessage.getText().toString().trim(); + if (oldString.isEmpty() || mEditMessage.getSelectionStart() == 0) { + mEditMessage.getText().insert(0, nick + ": "); + } else { + if (mEditMessage.getText().charAt( + mEditMessage.getSelectionStart() - 1) != ' ') { + nick = " " + nick; + } + mEditMessage.getText().insert(mEditMessage.getSelectionStart(), + nick + " "); + } + } + + @Override + public void onStart() { + super.onStart(); + this.activity = (ConversationActivity) getActivity(); + if (activity.xmppConnectionServiceBound) { + this.onBackendConnected(); + } + } + + @Override + public void onStop() { + mDecryptJobRunning = false; + super.onStop(); + if (this.conversation != null) { + this.conversation.setNextMessage(mEditMessage.getText().toString()); + } + } + + public void onBackendConnected() { + this.activity = (ConversationActivity) getActivity(); + this.conversation = activity.getSelectedConversation(); + if (this.conversation == null) { + return; + } + String oldString = conversation.getNextMessage().trim(); + if (this.pastedText == null) { + this.mEditMessage.setText(oldString); + } else { + + if (oldString.isEmpty()) { + mEditMessage.setText(pastedText); + } else { + mEditMessage.setText(oldString + " " + pastedText); + } + pastedText = null; + } + int position = mEditMessage.length(); + Editable etext = mEditMessage.getText(); + Selection.setSelection(etext, position); + if (activity.isConversationsOverviewHideable()) { + if (!activity.shouldPaneBeOpen()) { + activity.hideConversationsOverview(); + activity.openConversation(conversation); + } + } + if (this.conversation.getMode() == Conversation.MODE_MULTI) { + conversation.setNextPresence(null); + } + updateMessages(); + } + + public void updateMessages() { + if (getView() == null) { + return; + } + hideSnackbar(); + final ConversationActivity activity = (ConversationActivity) getActivity(); + if (this.conversation != null) { + final Contact contact = this.conversation.getContact(); + if (this.conversation.isMuted()) { + showSnackbar(R.string.notifications_disabled, R.string.enable, + new OnClickListener() { + + @Override + public void onClick(View v) { + conversation.setMutedTill(0); + activity.xmppConnectionService.databaseBackend + .updateConversation(conversation); + updateMessages(); + } + }); + } else if (!contact.showInRoster() + && contact + .getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) { + showSnackbar(R.string.contact_added_you, R.string.add_back, + new OnClickListener() { + + @Override + public void onClick(View v) { + activity.xmppConnectionService + .createContact(contact); + activity.switchToContactDetails(contact); + } + }); + } + for (Message message : this.conversation.getMessages()) { + if ((message.getEncryption() == Message.ENCRYPTION_PGP) + && ((message.getStatus() == Message.STATUS_RECEIVED) || (message + .getStatus() == Message.STATUS_SEND))) { + if (!mEncryptedMessages.contains(message)) { + mEncryptedMessages.add(message); + } + } + } + decryptNext(); + this.messageList.clear(); + if (this.conversation.getMessages().size() == 0) { + messagesLoaded = false; + } else { + this.messageList.addAll(this.conversation.getMessages()); + messagesLoaded = true; + updateStatusMessages(); + } + this.messageListAdapter.notifyDataSetChanged(); + if (conversation.getMode() == Conversation.MODE_SINGLE) { + if (messageList.size() >= 1) { + makeFingerprintWarning(conversation.getLatestEncryption()); + } + } else { + if (!conversation.getMucOptions().online() + && conversation.getAccount().getStatus() == Account.STATUS_ONLINE) { + int error = conversation.getMucOptions().getError(); + switch (error) { + case MucOptions.ERROR_NICK_IN_USE: + showSnackbar(R.string.nick_in_use, R.string.edit, + clickToMuc); + break; + case MucOptions.ERROR_ROOM_NOT_FOUND: + showSnackbar(R.string.conference_not_found, + R.string.leave, leaveMuc); + break; + case MucOptions.ERROR_PASSWORD_REQUIRED: + showSnackbar(R.string.conference_requires_password, + R.string.enter_password, enterPassword); + break; + case MucOptions.ERROR_BANNED: + showSnackbar(R.string.conference_banned, + R.string.leave, leaveMuc); + break; + case MucOptions.ERROR_MEMBERS_ONLY: + showSnackbar(R.string.conference_members_only, + R.string.leave, leaveMuc); + break; + case MucOptions.KICKED_FROM_ROOM: + showSnackbar(R.string.conference_kicked, R.string.join, + joinMuc); + break; + default: + break; + } + } + } + getActivity().invalidateOptionsMenu(); + updateChatMsgHint(); + if (!activity.shouldPaneBeOpen()) { + activity.xmppConnectionService.markRead(conversation, true); + activity.updateConversationList(); + } + this.updateSendButton(); + } + } + + private void decryptNext() { + Message next = this.mEncryptedMessages.peek(); + PgpEngine engine = activity.xmppConnectionService.getPgpEngine(); + + if (next != null && engine != null && !mDecryptJobRunning) { + mDecryptJobRunning = true; + engine.decrypt(next, new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, Message message) { + mDecryptJobRunning = false; + askForPassphraseIntent = pi.getIntentSender(); + showSnackbar(R.string.openpgp_messages_found, + R.string.decrypt, clickToDecryptListener); + } + + @Override + public void success(Message message) { + mDecryptJobRunning = false; + mEncryptedMessages.remove(); + activity.xmppConnectionService.updateMessage(message); + } + + @Override + public void error(int error, Message message) { + message.setEncryption(Message.ENCRYPTION_DECRYPTION_FAILED); + mDecryptJobRunning = false; + mEncryptedMessages.remove(); + activity.xmppConnectionService.updateConversationUi(); + } + }); + } + } + + private void messageSent() { + int size = this.messageList.size(); + messagesView.setSelection(size - 1); + mEditMessage.setText(""); + updateChatMsgHint(); + } + + public void updateSendButton() { + Conversation c = this.conversation; + if (activity.useSendButtonToIndicateStatus() && c != null + && c.getAccount().getStatus() == Account.STATUS_ONLINE) { + if (c.getMode() == Conversation.MODE_SINGLE) { + switch (c.getContact().getMostAvailableStatus()) { + case Presences.CHAT: + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_online); + break; + case Presences.ONLINE: + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_online); + break; + case Presences.AWAY: + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_away); + break; + case Presences.XA: + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_away); + break; + case Presences.DND: + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_dnd); + break; + default: + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_offline); + break; + } + } else if (c.getMode() == Conversation.MODE_MULTI) { + if (c.getMucOptions().online()) { + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_online); + } else { + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_offline); + } + } else { + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_offline); + } + } else { + this.mSendButton + .setImageResource(R.drawable.ic_action_send_now_offline); + } + } + + protected void updateStatusMessages() { + if (conversation.getMode() == Conversation.MODE_SINGLE) { + for (int i = this.messageList.size() - 1; i >= 0; --i) { + if (this.messageList.get(i).getStatus() == Message.STATUS_RECEIVED) { + return; + } else { + if (this.messageList.get(i).getStatus() == Message.STATUS_SEND_DISPLAYED) { + this.messageList.add(i + 1, + Message.createStatusMessage(conversation)); + return; + } + } + } + } + } + + protected void makeFingerprintWarning(int latestEncryption) { + Set knownFingerprints = conversation.getContact() + .getOtrFingerprints(); + if ((latestEncryption == Message.ENCRYPTION_OTR) + && (conversation.hasValidOtrSession() + && (!conversation.isMuted()) + && (conversation.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED) && (!knownFingerprints + .contains(conversation.getOtrFingerprint())))) { + showSnackbar(R.string.unknown_otr_fingerprint, R.string.verify, + new OnClickListener() { + + @Override + public void onClick(View v) { + if (conversation.getOtrFingerprint() != null) { + AlertDialog dialog = UIHelper + .getVerifyFingerprintDialog( + (ConversationActivity) getActivity(), + conversation, snackbar); + dialog.show(); + } + } + }); + } + } + + protected void showSnackbar(int message, int action, + OnClickListener clickListener) { + snackbar.setVisibility(View.VISIBLE); + snackbar.setOnClickListener(null); + snackbarMessage.setText(message); + snackbarMessage.setOnClickListener(null); + snackbarAction.setText(action); + snackbarAction.setOnClickListener(clickListener); + } + + protected void hideSnackbar() { + snackbar.setVisibility(View.GONE); + } + + protected void sendPlainTextMessage(Message message) { + ConversationActivity activity = (ConversationActivity) getActivity(); + activity.xmppConnectionService.sendMessage(message); + messageSent(); + } + + protected void sendPgpMessage(final Message message) { + final ConversationActivity activity = (ConversationActivity) getActivity(); + final XmppConnectionService xmppService = activity.xmppConnectionService; + final Contact contact = message.getConversation().getContact(); + if (activity.hasPgp()) { + if (conversation.getMode() == Conversation.MODE_SINGLE) { + if (contact.getPgpKeyId() != 0) { + xmppService.getPgpEngine().hasKey(contact, + new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, + Contact contact) { + activity.runIntent( + pi, + ConversationActivity.REQUEST_ENCRYPT_MESSAGE); + } + + @Override + public void success(Contact contact) { + messageSent(); + activity.encryptTextMessage(message); + } + + @Override + public void error(int error, Contact contact) { + + } + }); + + } else { + showNoPGPKeyDialog(false, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + conversation + .setNextEncryption(Message.ENCRYPTION_NONE); + xmppService.databaseBackend + .updateConversation(conversation); + message.setEncryption(Message.ENCRYPTION_NONE); + xmppService.sendMessage(message); + messageSent(); + } + }); + } + } else { + if (conversation.getMucOptions().pgpKeysInUse()) { + if (!conversation.getMucOptions().everybodyHasKeys()) { + Toast warning = Toast + .makeText(getActivity(), + R.string.missing_public_keys, + Toast.LENGTH_LONG); + warning.setGravity(Gravity.CENTER_VERTICAL, 0, 0); + warning.show(); + } + activity.encryptTextMessage(message); + messageSent(); + } else { + showNoPGPKeyDialog(true, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + conversation + .setNextEncryption(Message.ENCRYPTION_NONE); + message.setEncryption(Message.ENCRYPTION_NONE); + xmppService.databaseBackend + .updateConversation(conversation); + xmppService.sendMessage(message); + messageSent(); + } + }); + } + } + } else { + activity.showInstallPgpDialog(); + } + } + + public void showNoPGPKeyDialog(boolean plural, + DialogInterface.OnClickListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIconAttribute(android.R.attr.alertDialogIcon); + if (plural) { + builder.setTitle(getString(R.string.no_pgp_keys)); + builder.setMessage(getText(R.string.contacts_have_no_pgp_keys)); + } else { + builder.setTitle(getString(R.string.no_pgp_key)); + builder.setMessage(getText(R.string.contact_has_no_pgp_key)); + } + builder.setNegativeButton(getString(R.string.cancel), null); + builder.setPositiveButton(getString(R.string.send_unencrypted), + listener); + builder.create().show(); + } + + protected void sendOtrMessage(final Message message) { + final ConversationActivity activity = (ConversationActivity) getActivity(); + final XmppConnectionService xmppService = activity.xmppConnectionService; + if (conversation.hasValidOtrSession()) { + activity.xmppConnectionService.sendMessage(message); + messageSent(); + } else { + activity.selectPresence(message.getConversation(), + new OnPresenceSelected() { + + @Override + public void onPresenceSelected() { + message.setPresence(conversation.getNextPresence()); + xmppService.sendMessage(message); + messageSent(); + } + }); + } + } + + public void setText(String text) { + this.pastedText = text; + } + + public void clearInputField() { + this.mEditMessage.setText(""); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java new file mode 100644 index 000000000..1543d7402 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java @@ -0,0 +1,423 @@ +package eu.siacs.conversations.ui; + +import android.app.PendingIntent; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; +import eu.siacs.conversations.ui.adapter.KnownHostsAdapter; +import eu.siacs.conversations.utils.UIHelper; +import eu.siacs.conversations.utils.Validator; +import eu.siacs.conversations.xmpp.XmppConnection.Features; +import eu.siacs.conversations.xmpp.pep.Avatar; + +public class EditAccountActivity extends XmppActivity { + + private AutoCompleteTextView mAccountJid; + private EditText mPassword; + private EditText mPasswordConfirm; + private CheckBox mRegisterNew; + private Button mCancelButton; + private Button mSaveButton; + + private LinearLayout mStats; + private TextView mServerInfoSm; + private TextView mServerInfoCarbons; + private TextView mServerInfoPep; + private TextView mSessionEst; + private TextView mOtrFingerprint; + private RelativeLayout mOtrFingerprintBox; + private ImageButton mOtrFingerprintToClipboardButton; + + private String jidToEdit; + private Account mAccount; + + private boolean mFetchingAvatar = false; + + private OnClickListener mSaveButtonClickListener = new OnClickListener() { + + @Override + public void onClick(View v) { + if (mAccount != null + && mAccount.getStatus() == Account.STATUS_DISABLED) { + mAccount.setOption(Account.OPTION_DISABLED, false); + xmppConnectionService.updateAccount(mAccount); + return; + } + if (!Validator.isValidJid(mAccountJid.getText().toString())) { + mAccountJid.setError(getString(R.string.invalid_jid)); + mAccountJid.requestFocus(); + return; + } + boolean registerNewAccount = mRegisterNew.isChecked(); + String[] jidParts = mAccountJid.getText().toString().split("@"); + String username = jidParts[0]; + String server; + if (jidParts.length >= 2) { + server = jidParts[1]; + } else { + server = ""; + } + String password = mPassword.getText().toString(); + String passwordConfirm = mPasswordConfirm.getText().toString(); + if (registerNewAccount) { + if (!password.equals(passwordConfirm)) { + mPasswordConfirm + .setError(getString(R.string.passwords_do_not_match)); + mPasswordConfirm.requestFocus(); + return; + } + } + if (mAccount != null) { + mAccount.setPassword(password); + mAccount.setUsername(username); + mAccount.setServer(server); + mAccount.setOption(Account.OPTION_REGISTER, registerNewAccount); + xmppConnectionService.updateAccount(mAccount); + } else { + if (xmppConnectionService.findAccountByJid(mAccountJid + .getText().toString()) != null) { + mAccountJid + .setError(getString(R.string.account_already_exists)); + mAccountJid.requestFocus(); + return; + } + mAccount = new Account(username, server, password); + mAccount.setOption(Account.OPTION_USETLS, true); + mAccount.setOption(Account.OPTION_USECOMPRESSION, true); + mAccount.setOption(Account.OPTION_REGISTER, registerNewAccount); + xmppConnectionService.createAccount(mAccount); + } + if (jidToEdit != null) { + finish(); + } else { + updateSaveButton(); + updateAccountInformation(); + } + + } + }; + private OnClickListener mCancelButtonClickListener = new OnClickListener() { + + @Override + public void onClick(View v) { + finish(); + } + }; + private OnAccountUpdate mOnAccountUpdateListener = new OnAccountUpdate() { + + @Override + public void onAccountUpdate() { + runOnUiThread(new Runnable() { + + @Override + public void run() { + if (mAccount != null + && mAccount.getStatus() != Account.STATUS_ONLINE + && mFetchingAvatar) { + startActivity(new Intent(getApplicationContext(), + ManageAccountActivity.class)); + finish(); + } else if (jidToEdit == null && mAccount != null + && mAccount.getStatus() == Account.STATUS_ONLINE) { + if (!mFetchingAvatar) { + mFetchingAvatar = true; + xmppConnectionService.checkForAvatar(mAccount, + mAvatarFetchCallback); + } + } else { + updateSaveButton(); + } + if (mAccount != null) { + updateAccountInformation(); + } + } + }); + } + }; + private UiCallback mAvatarFetchCallback = new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, Avatar avatar) { + finishInitialSetup(avatar); + } + + @Override + public void success(Avatar avatar) { + finishInitialSetup(avatar); + } + + @Override + public void error(int errorCode, Avatar avatar) { + finishInitialSetup(avatar); + } + }; + private KnownHostsAdapter mKnownHostsAdapter; + private TextWatcher mTextWatcher = new TextWatcher() { + + @Override + public void onTextChanged(CharSequence s, int start, int before, + int count) { + updateSaveButton(); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, + int after) { + + } + + @Override + public void afterTextChanged(Editable s) { + + } + }; + + protected void finishInitialSetup(final Avatar avatar) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + Intent intent; + if (avatar != null) { + intent = new Intent(getApplicationContext(), + StartConversationActivity.class); + } else { + intent = new Intent(getApplicationContext(), + PublishProfilePictureActivity.class); + intent.putExtra("account", mAccount.getJid()); + intent.putExtra("setup", true); + } + startActivity(intent); + finish(); + } + }); + } + + protected boolean inputDataDiffersFromAccount() { + if (mAccount == null) { + return true; + } else { + return (!mAccount.getJid().equals(mAccountJid.getText().toString())) + || (!mAccount.getPassword().equals( + mPassword.getText().toString()) || mAccount + .isOptionSet(Account.OPTION_REGISTER) != mRegisterNew + .isChecked()); + } + } + + protected void updateSaveButton() { + if (mAccount != null + && mAccount.getStatus() == Account.STATUS_CONNECTING) { + this.mSaveButton.setEnabled(false); + this.mSaveButton.setTextColor(getSecondaryTextColor()); + this.mSaveButton.setText(R.string.account_status_connecting); + } else if (mAccount != null + && mAccount.getStatus() == Account.STATUS_DISABLED) { + this.mSaveButton.setEnabled(true); + this.mSaveButton.setTextColor(getPrimaryTextColor()); + this.mSaveButton.setText(R.string.enable); + } else { + this.mSaveButton.setEnabled(true); + this.mSaveButton.setTextColor(getPrimaryTextColor()); + if (jidToEdit != null) { + if (mAccount != null + && mAccount.getStatus() == Account.STATUS_ONLINE) { + this.mSaveButton.setText(R.string.save); + if (!accountInfoEdited()) { + this.mSaveButton.setEnabled(false); + this.mSaveButton.setTextColor(getSecondaryTextColor()); + } + } else { + this.mSaveButton.setText(R.string.connect); + } + } else { + this.mSaveButton.setText(R.string.next); + } + } + } + + protected boolean accountInfoEdited() { + return (!this.mAccount.getJid().equals( + this.mAccountJid.getText().toString())) + || (!this.mAccount.getPassword().equals( + this.mPassword.getText().toString())); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_edit_account); + this.mAccountJid = (AutoCompleteTextView) findViewById(R.id.account_jid); + this.mAccountJid.addTextChangedListener(this.mTextWatcher); + this.mPassword = (EditText) findViewById(R.id.account_password); + this.mPassword.addTextChangedListener(this.mTextWatcher); + this.mPasswordConfirm = (EditText) findViewById(R.id.account_password_confirm); + this.mRegisterNew = (CheckBox) findViewById(R.id.account_register_new); + this.mStats = (LinearLayout) findViewById(R.id.stats); + this.mSessionEst = (TextView) findViewById(R.id.session_est); + this.mServerInfoCarbons = (TextView) findViewById(R.id.server_info_carbons); + this.mServerInfoSm = (TextView) findViewById(R.id.server_info_sm); + this.mServerInfoPep = (TextView) findViewById(R.id.server_info_pep); + this.mOtrFingerprint = (TextView) findViewById(R.id.otr_fingerprint); + this.mOtrFingerprintBox = (RelativeLayout) findViewById(R.id.otr_fingerprint_box); + this.mOtrFingerprintToClipboardButton = (ImageButton) findViewById(R.id.action_copy_to_clipboard); + this.mSaveButton = (Button) findViewById(R.id.save_button); + this.mCancelButton = (Button) findViewById(R.id.cancel_button); + this.mSaveButton.setOnClickListener(this.mSaveButtonClickListener); + this.mCancelButton.setOnClickListener(this.mCancelButtonClickListener); + this.mRegisterNew + .setOnCheckedChangeListener(new OnCheckedChangeListener() { + + @Override + public void onCheckedChanged(CompoundButton buttonView, + boolean isChecked) { + if (isChecked) { + mPasswordConfirm.setVisibility(View.VISIBLE); + } else { + mPasswordConfirm.setVisibility(View.GONE); + } + updateSaveButton(); + } + }); + } + + @Override + protected void onStart() { + super.onStart(); + if (getIntent() != null) { + this.jidToEdit = getIntent().getStringExtra("jid"); + if (this.jidToEdit != null) { + this.mRegisterNew.setVisibility(View.GONE); + getActionBar().setTitle(jidToEdit); + } else { + getActionBar().setTitle(R.string.action_add_account); + } + } + } + + @Override + protected void onStop() { + if (xmppConnectionServiceBound) { + xmppConnectionService.removeOnAccountListChangedListener(); + } + super.onStop(); + } + + @Override + protected void onBackendConnected() { + this.mKnownHostsAdapter = new KnownHostsAdapter(this, + android.R.layout.simple_list_item_1, + xmppConnectionService.getKnownHosts()); + this.xmppConnectionService + .setOnAccountListChangedListener(this.mOnAccountUpdateListener); + if (this.jidToEdit != null) { + this.mAccount = xmppConnectionService.findAccountByJid(jidToEdit); + updateAccountInformation(); + } else if (this.xmppConnectionService.getAccounts().size() == 0) { + getActionBar().setDisplayHomeAsUpEnabled(false); + getActionBar().setDisplayShowHomeEnabled(false); + this.mCancelButton.setEnabled(false); + this.mCancelButton.setTextColor(getSecondaryTextColor()); + } + this.mAccountJid.setAdapter(this.mKnownHostsAdapter); + updateSaveButton(); + } + + private void updateAccountInformation() { + this.mAccountJid.setText(this.mAccount.getJid()); + this.mPassword.setText(this.mAccount.getPassword()); + if (this.mAccount.isOptionSet(Account.OPTION_REGISTER)) { + this.mRegisterNew.setVisibility(View.VISIBLE); + this.mRegisterNew.setChecked(true); + this.mPasswordConfirm.setText(this.mAccount.getPassword()); + } else { + this.mRegisterNew.setVisibility(View.GONE); + this.mRegisterNew.setChecked(false); + } + if (this.mAccount.getStatus() == Account.STATUS_ONLINE + && !this.mFetchingAvatar) { + this.mStats.setVisibility(View.VISIBLE); + this.mSessionEst.setText(UIHelper.readableTimeDifference( + getApplicationContext(), this.mAccount.getXmppConnection() + .getLastSessionEstablished())); + Features features = this.mAccount.getXmppConnection().getFeatures(); + if (features.carbons()) { + this.mServerInfoCarbons.setText(R.string.server_info_available); + } else { + this.mServerInfoCarbons + .setText(R.string.server_info_unavailable); + } + if (features.sm()) { + this.mServerInfoSm.setText(R.string.server_info_available); + } else { + this.mServerInfoSm.setText(R.string.server_info_unavailable); + } + if (features.pubsub()) { + this.mServerInfoPep.setText(R.string.server_info_available); + } else { + this.mServerInfoPep.setText(R.string.server_info_unavailable); + } + final String fingerprint = this.mAccount + .getOtrFingerprint(xmppConnectionService); + if (fingerprint != null) { + this.mOtrFingerprintBox.setVisibility(View.VISIBLE); + this.mOtrFingerprint.setText(fingerprint); + this.mOtrFingerprintToClipboardButton + .setVisibility(View.VISIBLE); + this.mOtrFingerprintToClipboardButton + .setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + + if (OtrFingerprintToClipBoard(fingerprint)) { + Toast.makeText( + EditAccountActivity.this, + R.string.toast_message_otr_fingerprint, + Toast.LENGTH_SHORT).show(); + } + } + }); + } else { + this.mOtrFingerprintBox.setVisibility(View.GONE); + } + } else { + if (this.mAccount.errorStatus()) { + this.mAccountJid.setError(getString(this.mAccount + .getReadableStatusId())); + this.mAccountJid.requestFocus(); + } + this.mStats.setVisibility(View.GONE); + } + } + + private boolean OtrFingerprintToClipBoard(String fingerprint) { + ClipboardManager mClipBoardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + String label = getResources().getString(R.string.otr_fingerprint); + if (mClipBoardManager != null) { + ClipData mClipData = ClipData.newPlainText(label, fingerprint); + mClipBoardManager.setPrimaryClip(mClipData); + return true; + } + return false; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/EditMessage.java b/conversations/src/main/java/eu/siacs/conversations/ui/EditMessage.java new file mode 100644 index 000000000..f83020506 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/EditMessage.java @@ -0,0 +1,39 @@ +package eu.siacs.conversations.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.widget.EditText; + +public class EditMessage extends EditText { + + public EditMessage(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public EditMessage(Context context) { + super(context); + } + + protected OnEnterPressed mOnEnterPressed; + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (mOnEnterPressed != null) { + mOnEnterPressed.onEnterPressed(); + } + return true; + } + return super.onKeyDown(keyCode, event); + } + + public void setOnEnterPressedListener(OnEnterPressed listener) { + this.mOnEnterPressed = listener; + } + + public interface OnEnterPressed { + public void onEnterPressed(); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/ManageAccountActivity.java new file mode 100644 index 000000000..5b5b0608f --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -0,0 +1,217 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; +import eu.siacs.conversations.ui.adapter.AccountAdapter; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ContextMenu.ContextMenuInfo; +import android.widget.AdapterView; +import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ListView; + +public class ManageAccountActivity extends XmppActivity { + + protected Account selectedAccount = null; + + protected List accountList = new ArrayList(); + protected ListView accountListView; + protected AccountAdapter mAccountAdapter; + protected OnAccountUpdate accountChanged = new OnAccountUpdate() { + + @Override + public void onAccountUpdate() { + accountList.clear(); + accountList.addAll(xmppConnectionService.getAccounts()); + runOnUiThread(new Runnable() { + + @Override + public void run() { + mAccountAdapter.notifyDataSetChanged(); + } + }); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + + super.onCreate(savedInstanceState); + + setContentView(R.layout.manage_accounts); + + accountListView = (ListView) findViewById(R.id.account_list); + this.mAccountAdapter = new AccountAdapter(this, accountList); + accountListView.setAdapter(this.mAccountAdapter); + accountListView.setOnItemClickListener(new OnItemClickListener() { + + @Override + public void onItemClick(AdapterView arg0, View view, + int position, long arg3) { + switchToAccount(accountList.get(position)); + } + }); + registerForContextMenu(accountListView); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + ManageAccountActivity.this.getMenuInflater().inflate( + R.menu.manageaccounts_context, menu); + AdapterView.AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo; + this.selectedAccount = accountList.get(acmi.position); + if (this.selectedAccount.isOptionSet(Account.OPTION_DISABLED)) { + menu.findItem(R.id.mgmt_account_disable).setVisible(false); + menu.findItem(R.id.mgmt_account_announce_pgp).setVisible(false); + menu.findItem(R.id.mgmt_account_publish_avatar).setVisible(false); + } else { + menu.findItem(R.id.mgmt_account_enable).setVisible(false); + } + menu.setHeaderTitle(this.selectedAccount.getJid()); + } + + @Override + protected void onStop() { + if (xmppConnectionServiceBound) { + xmppConnectionService.removeOnAccountListChangedListener(); + } + super.onStop(); + } + + @Override + void onBackendConnected() { + xmppConnectionService.setOnAccountListChangedListener(accountChanged); + this.accountList.clear(); + this.accountList.addAll(xmppConnectionService.getAccounts()); + mAccountAdapter.notifyDataSetChanged(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.manageaccounts, menu); + return true; + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.mgmt_account_publish_avatar: + publishAvatar(selectedAccount); + return true; + case R.id.mgmt_account_disable: + disableAccount(selectedAccount); + return true; + case R.id.mgmt_account_enable: + enableAccount(selectedAccount); + return true; + case R.id.mgmt_account_delete: + deleteAccount(selectedAccount); + return true; + case R.id.mgmt_account_announce_pgp: + publishOpenPGPPublicKey(selectedAccount); + default: + return super.onContextItemSelected(item); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_add_account: + startActivity(new Intent(getApplicationContext(), + EditAccountActivity.class)); + break; + default: + break; + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onNavigateUp() { + if (xmppConnectionService.getConversations().size() == 0) { + Intent contactsIntent = new Intent(this, + StartConversationActivity.class); + contactsIntent.setFlags( + // if activity exists in stack, pop the stack and go back to it + Intent.FLAG_ACTIVITY_CLEAR_TOP | + // otherwise, make a new task for it + Intent.FLAG_ACTIVITY_NEW_TASK | + // don't use the new activity animation; finish + // animation runs instead + Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(contactsIntent); + finish(); + return true; + } else { + return super.onNavigateUp(); + } + } + + private void publishAvatar(Account account) { + Intent intent = new Intent(getApplicationContext(), + PublishProfilePictureActivity.class); + intent.putExtra("account", account.getJid()); + startActivity(intent); + } + + private void disableAccount(Account account) { + account.setOption(Account.OPTION_DISABLED, true); + xmppConnectionService.updateAccount(account); + } + + private void enableAccount(Account account) { + account.setOption(Account.OPTION_DISABLED, false); + xmppConnectionService.updateAccount(account); + } + + private void publishOpenPGPPublicKey(Account account) { + if (ManageAccountActivity.this.hasPgp()) { + announcePgp(account, null); + } else { + this.showInstallPgpDialog(); + } + } + + private void deleteAccount(final Account account) { + AlertDialog.Builder builder = new AlertDialog.Builder( + ManageAccountActivity.this); + builder.setTitle(getString(R.string.mgmt_account_are_you_sure)); + builder.setIconAttribute(android.R.attr.alertDialogIcon); + builder.setMessage(getString(R.string.mgmt_account_delete_confirm_text)); + builder.setPositiveButton(getString(R.string.delete), + new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + xmppConnectionService.deleteAccount(account); + selectedAccount = null; + } + }); + builder.setNegativeButton(getString(R.string.cancel), null); + builder.create().show(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + if (requestCode == REQUEST_ANNOUNCE_PGP) { + announcePgp(selectedAccount, null); + } + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java new file mode 100644 index 000000000..6aa40c418 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java @@ -0,0 +1,242 @@ +package eu.siacs.conversations.ui; + +import android.app.PendingIntent; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.utils.PhoneHelper; +import eu.siacs.conversations.xmpp.pep.Avatar; + +public class PublishProfilePictureActivity extends XmppActivity { + + private static final int REQUEST_CHOOSE_FILE = 0xac23; + + private ImageView avatar; + private TextView accountTextView; + private TextView hintOrWarning; + private TextView secondaryHint; + private Button cancelButton; + private Button publishButton; + + private Uri avatarUri; + private Uri defaultUri; + + private Account account; + + private boolean support = false; + + private boolean mInitialAccountSetup; + + private UiCallback avatarPublication = new UiCallback() { + + @Override + public void success(Avatar object) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + if (mInitialAccountSetup) { + startActivity(new Intent(getApplicationContext(), + StartConversationActivity.class)); + } + finish(); + } + }); + } + + @Override + public void error(final int errorCode, Avatar object) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + hintOrWarning.setText(errorCode); + hintOrWarning.setTextColor(getWarningTextColor()); + publishButton.setText(R.string.publish); + enablePublishButton(); + } + }); + + } + + @Override + public void userInputRequried(PendingIntent pi, Avatar object) { + } + }; + + private OnLongClickListener backToDefaultListener = new OnLongClickListener() { + + @Override + public boolean onLongClick(View v) { + avatarUri = defaultUri; + loadImageIntoPreview(defaultUri); + return true; + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_publish_profile_picture); + this.avatar = (ImageView) findViewById(R.id.account_image); + this.cancelButton = (Button) findViewById(R.id.cancel_button); + this.publishButton = (Button) findViewById(R.id.publish_button); + this.accountTextView = (TextView) findViewById(R.id.account); + this.hintOrWarning = (TextView) findViewById(R.id.hint_or_warning); + this.secondaryHint = (TextView) findViewById(R.id.secondary_hint); + this.publishButton.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (avatarUri != null) { + publishButton.setText(R.string.publishing); + disablePublishButton(); + xmppConnectionService.publishAvatar(account, avatarUri, + avatarPublication); + } + } + }); + this.cancelButton.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (mInitialAccountSetup) { + startActivity(new Intent(getApplicationContext(), + StartConversationActivity.class)); + } + finish(); + } + }); + this.avatar.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + Intent attachFileIntent = new Intent(); + attachFileIntent.setType("image/*"); + attachFileIntent.setAction(Intent.ACTION_GET_CONTENT); + Intent chooser = Intent.createChooser(attachFileIntent, + getString(R.string.attach_file)); + startActivityForResult(chooser, REQUEST_CHOOSE_FILE); + } + }); + this.defaultUri = PhoneHelper.getSefliUri(getApplicationContext()); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, + final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + if (requestCode == REQUEST_CHOOSE_FILE) { + this.avatarUri = data.getData(); + if (xmppConnectionServiceBound) { + loadImageIntoPreview(this.avatarUri); + } + } + } + } + + @Override + protected void onBackendConnected() { + if (getIntent() != null) { + String jid = getIntent().getStringExtra("account"); + if (jid != null) { + this.account = xmppConnectionService.findAccountByJid(jid); + if (this.account.getXmppConnection() != null) { + this.support = this.account.getXmppConnection() + .getFeatures().pubsub(); + } + if (this.avatarUri == null) { + if (this.account.getAvatar() != null + || this.defaultUri == null) { + this.avatar.setImageBitmap(avatarService().get(account, + getPixel(194))); + if (this.defaultUri != null) { + this.avatar + .setOnLongClickListener(this.backToDefaultListener); + } else { + this.secondaryHint.setVisibility(View.INVISIBLE); + } + if (!support) { + this.hintOrWarning + .setTextColor(getWarningTextColor()); + this.hintOrWarning + .setText(R.string.error_publish_avatar_no_server_support); + } + } else { + this.avatarUri = this.defaultUri; + loadImageIntoPreview(this.defaultUri); + this.secondaryHint.setVisibility(View.INVISIBLE); + } + } else { + loadImageIntoPreview(avatarUri); + } + this.accountTextView.setText(this.account.getJid()); + } + } + + } + + @Override + protected void onStart() { + super.onStart(); + if (getIntent() != null) { + this.mInitialAccountSetup = getIntent().getBooleanExtra("setup", + false); + } + if (this.mInitialAccountSetup) { + this.cancelButton.setText(R.string.skip); + } + } + + protected void loadImageIntoPreview(Uri uri) { + Bitmap bm = xmppConnectionService.getFileBackend().cropCenterSquare( + uri, 384); + if (bm == null) { + disablePublishButton(); + this.hintOrWarning.setTextColor(getWarningTextColor()); + this.hintOrWarning + .setText(R.string.error_publish_avatar_converting); + return; + } + this.avatar.setImageBitmap(bm); + if (support) { + enablePublishButton(); + this.publishButton.setText(R.string.publish); + this.hintOrWarning.setText(R.string.publish_avatar_explanation); + this.hintOrWarning.setTextColor(getPrimaryTextColor()); + } else { + disablePublishButton(); + this.hintOrWarning.setTextColor(getWarningTextColor()); + this.hintOrWarning + .setText(R.string.error_publish_avatar_no_server_support); + } + if (this.defaultUri != null && uri.equals(this.defaultUri)) { + this.secondaryHint.setVisibility(View.INVISIBLE); + this.avatar.setOnLongClickListener(null); + } else if (this.defaultUri != null) { + this.secondaryHint.setVisibility(View.VISIBLE); + this.avatar.setOnLongClickListener(this.backToDefaultListener); + } + } + + protected void enablePublishButton() { + this.publishButton.setEnabled(true); + this.publishButton.setTextColor(getPrimaryTextColor()); + } + + protected void disablePublishButton() { + this.publishButton.setEnabled(false); + this.publishButton.setTextColor(getSecondaryTextColor()); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java new file mode 100644 index 000000000..fc6308fce --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -0,0 +1,74 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; + +import eu.siacs.conversations.entities.Account; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.os.Build; +import android.os.Bundle; +import android.preference.ListPreference; +import android.preference.PreferenceManager; + +public class SettingsActivity extends XmppActivity implements + OnSharedPreferenceChangeListener { + private SettingsFragment mSettingsFragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mSettingsFragment = new SettingsFragment(); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, mSettingsFragment).commit(); + } + + @Override + void onBackendConnected() { + + } + + @Override + public void onStart() { + super.onStart(); + PreferenceManager.getDefaultSharedPreferences(this) + .registerOnSharedPreferenceChangeListener(this); + ListPreference resources = (ListPreference) mSettingsFragment + .findPreference("resource"); + if (resources != null) { + ArrayList entries = new ArrayList( + Arrays.asList(resources.getEntries())); + entries.add(0, Build.MODEL); + resources.setEntries(entries.toArray(new CharSequence[entries + .size()])); + resources.setEntryValues(entries.toArray(new CharSequence[entries + .size()])); + } + } + + @Override + public void onStop() { + super.onStop(); + PreferenceManager.getDefaultSharedPreferences(this) + .unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences preferences, + String name) { + if (name.equals("resource")) { + String resource = preferences.getString("resource", "mobile") + .toLowerCase(Locale.US); + if (xmppConnectionServiceBound) { + for (Account account : xmppConnectionService.getAccounts()) { + account.setResource(resource); + if (!account.isOptionSet(Account.OPTION_DISABLED)) { + xmppConnectionService.reconnectAccount(account, false); + } + } + } + } + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/SettingsFragment.java b/conversations/src/main/java/eu/siacs/conversations/ui/SettingsFragment.java new file mode 100644 index 000000000..7e1c36989 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/SettingsFragment.java @@ -0,0 +1,15 @@ +package eu.siacs.conversations.ui; + +import eu.siacs.conversations.R; +import android.os.Bundle; +import android.preference.PreferenceFragment; + +public class SettingsFragment extends PreferenceFragment { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java new file mode 100644 index 000000000..9fbc3db10 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java @@ -0,0 +1,185 @@ +package eu.siacs.conversations.ui; + +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.ui.adapter.ConversationAdapter; +import android.app.PendingIntent; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ListView; +import android.widget.Toast; + +public class ShareWithActivity extends XmppActivity { + + private class Share { + public Uri uri; + public String account; + public String contact; + public String text; + } + + private Share share; + + private static final int REQUEST_START_NEW_CONVERSATION = 0x0501; + private ListView mListView; + private List mConversations = new ArrayList(); + + private UiCallback attachImageCallback = new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, Message object) { + // TODO Auto-generated method stub + + } + + @Override + public void success(Message message) { + xmppConnectionService.sendMessage(message); + } + + @Override + public void error(int errorCode, Message object) { + // TODO Auto-generated method stub + + } + }; + + protected void onActivityResult(int requestCode, int resultCode, + final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_START_NEW_CONVERSATION + && resultCode == RESULT_OK) { + share.contact = data.getStringExtra("contact"); + share.account = data.getStringExtra("account"); + Log.d(Config.LOGTAG, "contact: " + share.contact + " account:" + + share.account); + } + if (xmppConnectionServiceBound && share != null + && share.contact != null && share.account != null) { + share(); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + + super.onCreate(savedInstanceState); + + getActionBar().setDisplayHomeAsUpEnabled(false); + getActionBar().setHomeButtonEnabled(false); + + setContentView(R.layout.share_with); + setTitle(getString(R.string.title_activity_sharewith)); + + mListView = (ListView) findViewById(R.id.choose_conversation_list); + ConversationAdapter mAdapter = new ConversationAdapter(this, + this.mConversations); + mListView.setAdapter(mAdapter); + mListView.setOnItemClickListener(new OnItemClickListener() { + + @Override + public void onItemClick(AdapterView arg0, View arg1, + int position, long arg3) { + Conversation conversation = mConversations.get(position); + if (conversation.getMode() == Conversation.MODE_SINGLE + || share.uri == null) { + share(mConversations.get(position)); + } + } + }); + + this.share = new Share(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.share_with, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_add: + Intent intent = new Intent(getApplicationContext(), + ChooseContactActivity.class); + startActivityForResult(intent, REQUEST_START_NEW_CONVERSATION); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onStart() { + if (getIntent().getType() != null + && getIntent().getType().startsWith("image/")) { + this.share.uri = (Uri) getIntent().getParcelableExtra( + Intent.EXTRA_STREAM); + } else { + this.share.text = getIntent().getStringExtra(Intent.EXTRA_TEXT); + } + if (xmppConnectionServiceBound) { + xmppConnectionService.populateWithOrderedConversations( + mConversations, this.share.uri == null); + } + super.onStart(); + } + + @Override + void onBackendConnected() { + if (xmppConnectionServiceBound && share != null + && share.contact != null && share.account != null) { + share(); + return; + } + xmppConnectionService.populateWithOrderedConversations(mConversations, + this.share != null && this.share.uri == null); + } + + private void share() { + Account account = xmppConnectionService.findAccountByJid(share.account); + if (account == null) { + return; + } + Conversation conversation = xmppConnectionService + .findOrCreateConversation(account, share.contact, false); + share(conversation); + } + + private void share(final Conversation conversation) { + if (share.uri != null) { + selectPresence(conversation, new OnPresenceSelected() { + @Override + public void onPresenceSelected() { + Toast.makeText(getApplicationContext(), + getText(R.string.preparing_image), + Toast.LENGTH_LONG).show(); + ShareWithActivity.this.xmppConnectionService + .attachImageToConversation(conversation, share.uri, + attachImageCallback); + switchToConversation(conversation, null, true); + finish(); + } + }); + + } else { + switchToConversation(conversation, this.share.text, true); + finish(); + } + + } + +} \ No newline at end of file diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java new file mode 100644 index 000000000..a1a2d4c2a --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java @@ -0,0 +1,677 @@ +package eu.siacs.conversations.ui; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import android.annotation.SuppressLint; +import android.app.ActionBar; +import android.app.ActionBar.Tab; +import android.app.ActionBar.TabListener; +import android.app.AlertDialog; +import android.app.Fragment; +import android.app.FragmentTransaction; +import android.app.ListFragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.v13.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.Spinner; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Bookmark; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.ListItem; +import eu.siacs.conversations.services.XmppConnectionService.OnRosterUpdate; +import eu.siacs.conversations.ui.adapter.KnownHostsAdapter; +import eu.siacs.conversations.ui.adapter.ListItemAdapter; +import eu.siacs.conversations.utils.Validator; + +public class StartConversationActivity extends XmppActivity { + + private Tab mContactsTab; + private Tab mConferencesTab; + private ViewPager mViewPager; + + private MyListFragment mContactsListFragment = new MyListFragment(); + private List contacts = new ArrayList(); + private ArrayAdapter mContactsAdapter; + + private MyListFragment mConferenceListFragment = new MyListFragment(); + private List conferences = new ArrayList(); + private ArrayAdapter mConferenceAdapter; + + private List mActivatedAccounts = new ArrayList(); + private List mKnownHosts; + private List mKnownConferenceHosts; + + private Menu mOptionsMenu; + private EditText mSearchEditText; + + public int conference_context_id; + public int contact_context_id; + + private TabListener mTabListener = new TabListener() { + + @Override + public void onTabUnselected(Tab tab, FragmentTransaction ft) { + return; + } + + @Override + public void onTabSelected(Tab tab, FragmentTransaction ft) { + mViewPager.setCurrentItem(tab.getPosition()); + onTabChanged(); + } + + @Override + public void onTabReselected(Tab tab, FragmentTransaction ft) { + return; + } + }; + + private ViewPager.SimpleOnPageChangeListener mOnPageChangeListener = new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + getActionBar().setSelectedNavigationItem(position); + onTabChanged(); + } + }; + + private MenuItem.OnActionExpandListener mOnActionExpandListener = new MenuItem.OnActionExpandListener() { + + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + mSearchEditText.post(new Runnable() { + + @Override + public void run() { + mSearchEditText.requestFocus(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mSearchEditText, + InputMethodManager.SHOW_IMPLICIT); + } + }); + + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mSearchEditText.getWindowToken(), + InputMethodManager.HIDE_IMPLICIT_ONLY); + mSearchEditText.setText(""); + filter(null); + return true; + } + }; + private TextWatcher mSearchTextWatcher = new TextWatcher() { + + @Override + public void afterTextChanged(Editable editable) { + filter(editable.toString()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, + int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, + int count) { + } + }; + private OnRosterUpdate onRosterUpdate = new OnRosterUpdate() { + + @Override + public void onRosterUpdate() { + runOnUiThread(new Runnable() { + + @Override + public void run() { + if (mSearchEditText != null) { + filter(mSearchEditText.getText().toString()); + } + } + }); + } + }; + private MenuItem mMenuSearchView; + private String mInitialJid; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_start_conversation); + mViewPager = (ViewPager) findViewById(R.id.start_conversation_view_pager); + ActionBar actionBar = getActionBar(); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + + mContactsTab = actionBar.newTab().setText(R.string.contacts) + .setTabListener(mTabListener); + mConferencesTab = actionBar.newTab().setText(R.string.conferences) + .setTabListener(mTabListener); + actionBar.addTab(mContactsTab); + actionBar.addTab(mConferencesTab); + + mViewPager.setOnPageChangeListener(mOnPageChangeListener); + mViewPager.setAdapter(new FragmentPagerAdapter(getFragmentManager()) { + + @Override + public int getCount() { + return 2; + } + + @Override + public Fragment getItem(int position) { + if (position == 0) { + return mContactsListFragment; + } else { + return mConferenceListFragment; + } + } + }); + + mConferenceAdapter = new ListItemAdapter(this, conferences); + mConferenceListFragment.setListAdapter(mConferenceAdapter); + mConferenceListFragment.setContextMenu(R.menu.conference_context); + mConferenceListFragment + .setOnListItemClickListener(new OnItemClickListener() { + + @Override + public void onItemClick(AdapterView arg0, View arg1, + int position, long arg3) { + openConversationForBookmark(position); + } + }); + + mContactsAdapter = new ListItemAdapter(this, contacts); + mContactsListFragment.setListAdapter(mContactsAdapter); + mContactsListFragment.setContextMenu(R.menu.contact_context); + mContactsListFragment + .setOnListItemClickListener(new OnItemClickListener() { + + @Override + public void onItemClick(AdapterView arg0, View arg1, + int position, long arg3) { + openConversationForContact(position); + } + }); + + } + + @Override + public void onStop() { + super.onStop(); + xmppConnectionService.removeOnRosterUpdateListener(); + } + + protected void openConversationForContact(int position) { + Contact contact = (Contact) contacts.get(position); + Conversation conversation = xmppConnectionService + .findOrCreateConversation(contact.getAccount(), + contact.getJid(), false); + switchToConversation(conversation); + } + + protected void openConversationForContact() { + int position = contact_context_id; + openConversationForContact(position); + } + + protected void openConversationForBookmark() { + openConversationForBookmark(conference_context_id); + } + + protected void openConversationForBookmark(int position) { + Bookmark bookmark = (Bookmark) conferences.get(position); + Conversation conversation = xmppConnectionService + .findOrCreateConversation(bookmark.getAccount(), + bookmark.getJid(), true); + conversation.setBookmark(bookmark); + if (!conversation.getMucOptions().online()) { + xmppConnectionService.joinMuc(conversation); + } + if (!bookmark.autojoin()) { + bookmark.setAutojoin(true); + xmppConnectionService.pushBookmarks(bookmark.getAccount()); + } + switchToConversation(conversation); + } + + protected void openDetailsForContact() { + int position = contact_context_id; + Contact contact = (Contact) contacts.get(position); + switchToContactDetails(contact); + } + + protected void deleteContact() { + int position = contact_context_id; + final Contact contact = (Contact) contacts.get(position); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setNegativeButton(R.string.cancel, null); + builder.setTitle(R.string.action_delete_contact); + builder.setMessage(getString(R.string.remove_contact_text, + contact.getJid())); + builder.setPositiveButton(R.string.delete, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + xmppConnectionService.deleteContactOnServer(contact); + filter(mSearchEditText.getText().toString()); + } + }); + builder.create().show(); + + } + + protected void deleteConference() { + int position = conference_context_id; + final Bookmark bookmark = (Bookmark) conferences.get(position); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setNegativeButton(R.string.cancel, null); + builder.setTitle(R.string.delete_bookmark); + builder.setMessage(getString(R.string.remove_bookmark_text, + bookmark.getJid())); + builder.setPositiveButton(R.string.delete, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + bookmark.unregisterConversation(); + Account account = bookmark.getAccount(); + account.getBookmarks().remove(bookmark); + xmppConnectionService.pushBookmarks(account); + filter(mSearchEditText.getText().toString()); + } + }); + builder.create().show(); + + } + + @SuppressLint("InflateParams") + protected void showCreateContactDialog(String prefilledJid) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.create_contact); + View dialogView = getLayoutInflater().inflate( + R.layout.create_contact_dialog, null); + final Spinner spinner = (Spinner) dialogView.findViewById(R.id.account); + final AutoCompleteTextView jid = (AutoCompleteTextView) dialogView + .findViewById(R.id.jid); + jid.setAdapter(new KnownHostsAdapter(this, + android.R.layout.simple_list_item_1, mKnownHosts)); + if (prefilledJid != null) { + jid.append(prefilledJid); + } + populateAccountSpinner(spinner); + builder.setView(dialogView); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.create, null); + final AlertDialog dialog = builder.create(); + dialog.show(); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener( + new View.OnClickListener() { + + @Override + public void onClick(View v) { + if (!xmppConnectionServiceBound) { + return; + } + if (Validator.isValidJid(jid.getText().toString())) { + String accountJid = (String) spinner + .getSelectedItem(); + String contactJid = jid.getText().toString(); + Account account = xmppConnectionService + .findAccountByJid(accountJid); + if (account == null) { + dialog.dismiss(); + return; + } + Contact contact = account.getRoster().getContact( + contactJid); + if (contact.showInRoster()) { + jid.setError(getString(R.string.contact_already_exists)); + } else { + xmppConnectionService.createContact(contact); + dialog.dismiss(); + switchToConversation(contact); + } + } else { + jid.setError(getString(R.string.invalid_jid)); + } + } + }); + + } + + @SuppressLint("InflateParams") + protected void showJoinConferenceDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.join_conference); + View dialogView = getLayoutInflater().inflate( + R.layout.join_conference_dialog, null); + final Spinner spinner = (Spinner) dialogView.findViewById(R.id.account); + final AutoCompleteTextView jid = (AutoCompleteTextView) dialogView + .findViewById(R.id.jid); + jid.setAdapter(new KnownHostsAdapter(this, + android.R.layout.simple_list_item_1, mKnownConferenceHosts)); + populateAccountSpinner(spinner); + final CheckBox bookmarkCheckBox = (CheckBox) dialogView + .findViewById(R.id.bookmark); + builder.setView(dialogView); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.join, null); + final AlertDialog dialog = builder.create(); + dialog.show(); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener( + new View.OnClickListener() { + + @Override + public void onClick(View v) { + if (!xmppConnectionServiceBound) { + return; + } + if (Validator.isValidJid(jid.getText().toString())) { + String accountJid = (String) spinner + .getSelectedItem(); + String conferenceJid = jid.getText().toString(); + Account account = xmppConnectionService + .findAccountByJid(accountJid); + if (account == null) { + dialog.dismiss(); + return; + } + if (bookmarkCheckBox.isChecked()) { + if (account.hasBookmarkFor(conferenceJid)) { + jid.setError(getString(R.string.bookmark_already_exists)); + } else { + Bookmark bookmark = new Bookmark(account, + conferenceJid); + bookmark.setAutojoin(true); + account.getBookmarks().add(bookmark); + xmppConnectionService + .pushBookmarks(account); + Conversation conversation = xmppConnectionService + .findOrCreateConversation(account, + conferenceJid, true); + conversation.setBookmark(bookmark); + if (!conversation.getMucOptions().online()) { + xmppConnectionService + .joinMuc(conversation); + } + dialog.dismiss(); + switchToConversation(conversation); + } + } else { + Conversation conversation = xmppConnectionService + .findOrCreateConversation(account, + conferenceJid, true); + if (!conversation.getMucOptions().online()) { + xmppConnectionService.joinMuc(conversation); + } + dialog.dismiss(); + switchToConversation(conversation); + } + } else { + jid.setError(getString(R.string.invalid_jid)); + } + } + }); + } + + protected void switchToConversation(Contact contact) { + Conversation conversation = xmppConnectionService + .findOrCreateConversation(contact.getAccount(), + contact.getJid(), false); + switchToConversation(conversation); + } + + private void populateAccountSpinner(Spinner spinner) { + ArrayAdapter adapter = new ArrayAdapter(this, + android.R.layout.simple_spinner_item, mActivatedAccounts); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + this.mOptionsMenu = menu; + getMenuInflater().inflate(R.menu.start_conversation, menu); + MenuItem menuCreateContact = (MenuItem) menu + .findItem(R.id.action_create_contact); + MenuItem menuCreateConference = (MenuItem) menu + .findItem(R.id.action_join_conference); + mMenuSearchView = (MenuItem) menu.findItem(R.id.action_search); + mMenuSearchView.setOnActionExpandListener(mOnActionExpandListener); + View mSearchView = mMenuSearchView.getActionView(); + mSearchEditText = (EditText) mSearchView + .findViewById(R.id.search_field); + mSearchEditText.addTextChangedListener(mSearchTextWatcher); + if (getActionBar().getSelectedNavigationIndex() == 0) { + menuCreateConference.setVisible(false); + } else { + menuCreateContact.setVisible(false); + } + if (mInitialJid != null) { + mMenuSearchView.expandActionView(); + mSearchEditText.append(mInitialJid); + filter(mInitialJid); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_create_contact: + showCreateContactDialog(null); + break; + case R.id.action_join_conference: + showJoinConferenceDialog(); + break; + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_SEARCH && !event.isLongPress()) { + mOptionsMenu.findItem(R.id.action_search).expandActionView(); + return true; + } + return super.onKeyUp(keyCode, event); + } + + @Override + protected void onBackendConnected() { + xmppConnectionService.setOnRosterUpdateListener(this.onRosterUpdate); + this.mActivatedAccounts.clear(); + for (Account account : xmppConnectionService.getAccounts()) { + if (account.getStatus() != Account.STATUS_DISABLED) { + this.mActivatedAccounts.add(account.getJid()); + } + } + this.mKnownHosts = xmppConnectionService.getKnownHosts(); + this.mKnownConferenceHosts = xmppConnectionService + .getKnownConferenceHosts(); + if (!startByIntent()) { + if (mSearchEditText != null) { + filter(mSearchEditText.getText().toString()); + } else { + filter(null); + } + } + } + + protected boolean startByIntent() { + if (getIntent() != null + && Intent.ACTION_SENDTO.equals(getIntent().getAction())) { + try { + String jid = URLDecoder.decode( + getIntent().getData().getEncodedPath(), "UTF-8").split( + "/")[1]; + setIntent(null); + return handleJid(jid); + } catch (UnsupportedEncodingException e) { + setIntent(null); + return false; + } + } else if (getIntent() != null + && Intent.ACTION_VIEW.equals(getIntent().getAction())) { + Uri uri = getIntent().getData(); + String jid = uri.getSchemeSpecificPart().split("\\?")[0]; + return handleJid(jid); + } + return false; + } + + private boolean handleJid(String jid) { + List contacts = xmppConnectionService.findContacts(jid); + if (contacts.size() == 0) { + showCreateContactDialog(jid); + return false; + } else if (contacts.size() == 1) { + switchToConversation(contacts.get(0)); + return true; + } else { + if (mMenuSearchView != null) { + mMenuSearchView.expandActionView(); + mSearchEditText.setText(jid); + filter(jid); + } else { + mInitialJid = jid; + } + return true; + } + } + + protected void filter(String needle) { + if (xmppConnectionServiceBound) { + this.filterContacts(needle); + this.filterConferences(needle); + } + } + + protected void filterContacts(String needle) { + this.contacts.clear(); + for (Account account : xmppConnectionService.getAccounts()) { + if (account.getStatus() != Account.STATUS_DISABLED) { + for (Contact contact : account.getRoster().getContacts()) { + if (contact.showInRoster() && contact.match(needle)) { + this.contacts.add(contact); + } + } + } + } + Collections.sort(this.contacts); + mContactsAdapter.notifyDataSetChanged(); + } + + protected void filterConferences(String needle) { + this.conferences.clear(); + for (Account account : xmppConnectionService.getAccounts()) { + if (account.getStatus() != Account.STATUS_DISABLED) { + for (Bookmark bookmark : account.getBookmarks()) { + if (bookmark.match(needle)) { + this.conferences.add(bookmark); + } + } + } + } + Collections.sort(this.conferences); + mConferenceAdapter.notifyDataSetChanged(); + } + + private void onTabChanged() { + invalidateOptionsMenu(); + } + + public static class MyListFragment extends ListFragment { + private AdapterView.OnItemClickListener mOnItemClickListener; + private int mResContextMenu; + + public void setContextMenu(int res) { + this.mResContextMenu = res; + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + if (mOnItemClickListener != null) { + mOnItemClickListener.onItemClick(l, v, position, id); + } + } + + public void setOnListItemClickListener(AdapterView.OnItemClickListener l) { + this.mOnItemClickListener = l; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + registerForContextMenu(getListView()); + getListView().setFastScrollEnabled(true); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + StartConversationActivity activity = (StartConversationActivity) getActivity(); + activity.getMenuInflater().inflate(mResContextMenu, menu); + AdapterView.AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo; + if (mResContextMenu == R.menu.conference_context) { + activity.conference_context_id = acmi.position; + } else { + activity.contact_context_id = acmi.position; + } + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + StartConversationActivity activity = (StartConversationActivity) getActivity(); + switch (item.getItemId()) { + case R.id.context_start_conversation: + activity.openConversationForContact(); + break; + case R.id.context_contact_details: + activity.openDetailsForContact(); + break; + case R.id.context_delete_contact: + activity.deleteContact(); + break; + case R.id.context_join_conference: + activity.openConversationForBookmark(); + break; + case R.id.context_delete_conference: + activity.deleteConference(); + } + return true; + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/UiCallback.java b/conversations/src/main/java/eu/siacs/conversations/ui/UiCallback.java new file mode 100644 index 000000000..c80199e17 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/UiCallback.java @@ -0,0 +1,11 @@ +package eu.siacs.conversations.ui; + +import android.app.PendingIntent; + +public interface UiCallback { + public void success(T object); + + public void error(int errorCode, T object); + + public void userInputRequried(PendingIntent pi, T object); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/conversations/src/main/java/eu/siacs/conversations/ui/XmppActivity.java new file mode 100644 index 000000000..d26f0e31d --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -0,0 +1,637 @@ +package eu.siacs.conversations.ui; + +import java.io.FileNotFoundException; +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.RejectedExecutionException; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Presences; +import eu.siacs.conversations.services.AvatarService; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder; +import eu.siacs.conversations.utils.ExceptionHelper; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.app.AlertDialog.Builder; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.content.DialogInterface.OnClickListener; +import android.content.IntentSender.SendIntentException; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.content.Intent; +import android.content.ServiceConnection; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.text.InputType; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.ImageView; + +public abstract class XmppActivity extends Activity { + + protected static final int REQUEST_ANNOUNCE_PGP = 0x0101; + protected static final int REQUEST_INVITE_TO_CONVERSATION = 0x0102; + + public XmppConnectionService xmppConnectionService; + public boolean xmppConnectionServiceBound = false; + protected boolean handledViewIntent = false; + + protected int mPrimaryTextColor; + protected int mSecondaryTextColor; + protected int mSecondaryBackgroundColor; + protected int mColorRed; + protected int mColorOrange; + protected int mColorGreen; + protected int mPrimaryColor; + + protected boolean mUseSubject = true; + + private DisplayMetrics metrics; + + protected interface OnValueEdited { + public void onValueEdited(String value); + } + + public interface OnPresenceSelected { + public void onPresenceSelected(); + } + + protected ServiceConnection mConnection = new ServiceConnection() { + + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + XmppConnectionBinder binder = (XmppConnectionBinder) service; + xmppConnectionService = binder.getService(); + xmppConnectionServiceBound = true; + onBackendConnected(); + } + + @Override + public void onServiceDisconnected(ComponentName arg0) { + xmppConnectionServiceBound = false; + } + }; + + @Override + protected void onStart() { + super.onStart(); + if (!xmppConnectionServiceBound) { + connectToBackend(); + } + } + + public void connectToBackend() { + Intent intent = new Intent(this, XmppConnectionService.class); + intent.setAction("ui"); + startService(intent); + bindService(intent, mConnection, Context.BIND_AUTO_CREATE); + } + + @Override + protected void onStop() { + super.onStop(); + if (xmppConnectionServiceBound) { + unbindService(mConnection); + xmppConnectionServiceBound = false; + } + } + + protected void hideKeyboard() { + InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + + View focus = getCurrentFocus(); + + if (focus != null) { + + inputManager.hideSoftInputFromWindow(focus.getWindowToken(), + InputMethodManager.HIDE_NOT_ALWAYS); + } + } + + public boolean hasPgp() { + return xmppConnectionService.getPgpEngine() != null; + } + + public void showInstallPgpDialog() { + Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.openkeychain_required)); + builder.setIconAttribute(android.R.attr.alertDialogIcon); + builder.setMessage(getText(R.string.openkeychain_required_long)); + builder.setNegativeButton(getString(R.string.cancel), null); + builder.setNeutralButton(getString(R.string.restart), + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + if (xmppConnectionServiceBound) { + unbindService(mConnection); + xmppConnectionServiceBound = false; + } + stopService(new Intent(XmppActivity.this, + XmppConnectionService.class)); + finish(); + } + }); + builder.setPositiveButton(getString(R.string.install), + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + Uri uri = Uri + .parse("market://details?id=org.sufficientlysecure.keychain"); + Intent marketIntent = new Intent(Intent.ACTION_VIEW, + uri); + PackageManager manager = getApplicationContext() + .getPackageManager(); + List infos = manager + .queryIntentActivities(marketIntent, 0); + if (infos.size() > 0) { + startActivity(marketIntent); + } else { + uri = Uri.parse("http://www.openkeychain.org/"); + Intent browserIntent = new Intent( + Intent.ACTION_VIEW, uri); + startActivity(browserIntent); + } + finish(); + } + }); + builder.create().show(); + } + + abstract void onBackendConnected(); + + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_settings: + startActivity(new Intent(this, SettingsActivity.class)); + break; + case R.id.action_accounts: + startActivity(new Intent(this, ManageAccountActivity.class)); + break; + case android.R.id.home: + finish(); + break; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + metrics = getResources().getDisplayMetrics(); + ExceptionHelper.init(getApplicationContext()); + mPrimaryTextColor = getResources().getColor(R.color.primarytext); + mSecondaryTextColor = getResources().getColor(R.color.secondarytext); + mColorRed = getResources().getColor(R.color.red); + mColorOrange = getResources().getColor(R.color.orange); + mColorGreen = getResources().getColor(R.color.green); + mPrimaryColor = getResources().getColor(R.color.primary); + mSecondaryBackgroundColor = getResources().getColor( + R.color.secondarybackground); + if (getPreferences().getBoolean("use_larger_font", false)) { + setTheme(R.style.ConversationsTheme_LargerText); + } + mUseSubject = getPreferences().getBoolean("use_subject", true); + } + + protected SharedPreferences getPreferences() { + return PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()); + } + + public boolean useSubjectToIdentifyConference() { + return mUseSubject; + } + + public void switchToConversation(Conversation conversation) { + switchToConversation(conversation, null, false); + } + + public void switchToConversation(Conversation conversation, String text, + boolean newTask) { + Intent viewConversationIntent = new Intent(this, + ConversationActivity.class); + viewConversationIntent.setAction(Intent.ACTION_VIEW); + viewConversationIntent.putExtra(ConversationActivity.CONVERSATION, + conversation.getUuid()); + if (text != null) { + viewConversationIntent.putExtra(ConversationActivity.TEXT, text); + } + viewConversationIntent.setType(ConversationActivity.VIEW_CONVERSATION); + if (newTask) { + viewConversationIntent.setFlags(viewConversationIntent.getFlags() + | Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_SINGLE_TOP); + } else { + viewConversationIntent.setFlags(viewConversationIntent.getFlags() + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + } + startActivity(viewConversationIntent); + finish(); + } + + public void switchToContactDetails(Contact contact) { + Intent intent = new Intent(this, ContactDetailsActivity.class); + intent.setAction(ContactDetailsActivity.ACTION_VIEW_CONTACT); + intent.putExtra("account", contact.getAccount().getJid()); + intent.putExtra("contact", contact.getJid()); + startActivity(intent); + } + + public void switchToAccount(Account account) { + Intent intent = new Intent(this, EditAccountActivity.class); + intent.putExtra("jid", account.getJid()); + startActivity(intent); + } + + protected void inviteToConversation(Conversation conversation) { + Intent intent = new Intent(getApplicationContext(), + ChooseContactActivity.class); + intent.putExtra("conversation", conversation.getUuid()); + startActivityForResult(intent, REQUEST_INVITE_TO_CONVERSATION); + } + + protected void announcePgp(Account account, final Conversation conversation) { + xmppConnectionService.getPgpEngine().generateSignature(account, + "online", new UiCallback() { + + @Override + public void userInputRequried(PendingIntent pi, + Account account) { + try { + startIntentSenderForResult(pi.getIntentSender(), + REQUEST_ANNOUNCE_PGP, null, 0, 0, 0); + } catch (SendIntentException e) { + } + } + + @Override + public void success(Account account) { + xmppConnectionService.databaseBackend + .updateAccount(account); + xmppConnectionService.sendPresencePacket(account, + xmppConnectionService.getPresenceGenerator() + .sendPresence(account)); + if (conversation != null) { + conversation + .setNextEncryption(Message.ENCRYPTION_PGP); + xmppConnectionService.databaseBackend + .updateConversation(conversation); + } + } + + @Override + public void error(int error, Account account) { + displayErrorDialog(error); + } + }); + } + + protected void displayErrorDialog(final int errorCode) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + AlertDialog.Builder builder = new AlertDialog.Builder( + XmppActivity.this); + builder.setIconAttribute(android.R.attr.alertDialogIcon); + builder.setTitle(getString(R.string.error)); + builder.setMessage(errorCode); + builder.setNeutralButton(R.string.accept, null); + builder.create().show(); + } + }); + + } + + protected void showAddToRosterDialog(final Conversation conversation) { + String jid = conversation.getContactJid(); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(jid); + builder.setMessage(getString(R.string.not_in_roster)); + builder.setNegativeButton(getString(R.string.cancel), null); + builder.setPositiveButton(getString(R.string.add_contact), + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + String jid = conversation.getContactJid(); + Account account = conversation.getAccount(); + Contact contact = account.getRoster().getContact(jid); + xmppConnectionService.createContact(contact); + switchToContactDetails(contact); + } + }); + builder.create().show(); + } + + private void showAskForPresenceDialog(final Contact contact) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(contact.getJid()); + builder.setMessage(R.string.request_presence_updates); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.request_now, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + if (xmppConnectionServiceBound) { + xmppConnectionService.sendPresencePacket(contact + .getAccount(), xmppConnectionService + .getPresenceGenerator() + .requestPresenceUpdatesFrom(contact)); + } + } + }); + builder.create().show(); + } + + private void warnMutalPresenceSubscription(final Conversation conversation, + final OnPresenceSelected listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(conversation.getContact().getJid()); + builder.setMessage(R.string.without_mutual_presence_updates); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ignore, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + conversation.setNextPresence(null); + if (listener != null) { + listener.onPresenceSelected(); + } + } + }); + builder.create().show(); + } + + protected void quickEdit(String previousValue, OnValueEdited callback) { + quickEdit(previousValue, callback, false); + } + + protected void quickPasswordEdit(String previousValue, + OnValueEdited callback) { + quickEdit(previousValue, callback, true); + } + + @SuppressLint("InflateParams") + private void quickEdit(final String previousValue, + final OnValueEdited callback, boolean password) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + View view = (View) getLayoutInflater() + .inflate(R.layout.quickedit, null); + final EditText editor = (EditText) view.findViewById(R.id.editor); + OnClickListener mClickListener = new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + String value = editor.getText().toString(); + if (!previousValue.equals(value) && value.trim().length() > 0) { + callback.onValueEdited(value); + } + } + }; + if (password) { + editor.setInputType(InputType.TYPE_CLASS_TEXT + | InputType.TYPE_TEXT_VARIATION_PASSWORD); + editor.setHint(R.string.password); + builder.setPositiveButton(R.string.accept, mClickListener); + } else { + builder.setPositiveButton(R.string.edit, mClickListener); + } + editor.requestFocus(); + editor.setText(previousValue); + builder.setView(view); + builder.setNegativeButton(R.string.cancel, null); + builder.create().show(); + } + + public void selectPresence(final Conversation conversation, + final OnPresenceSelected listener) { + Contact contact = conversation.getContact(); + if (!contact.showInRoster()) { + showAddToRosterDialog(conversation); + } else { + Presences presences = contact.getPresences(); + if (presences.size() == 0) { + if (!contact.getOption(Contact.Options.TO) + && !contact.getOption(Contact.Options.ASKING) + && contact.getAccount().getStatus() == Account.STATUS_ONLINE) { + showAskForPresenceDialog(contact); + } else if (!contact.getOption(Contact.Options.TO) + || !contact.getOption(Contact.Options.FROM)) { + warnMutalPresenceSubscription(conversation, listener); + } else { + conversation.setNextPresence(null); + listener.onPresenceSelected(); + } + } else if (presences.size() == 1) { + String presence = (String) presences.asStringArray()[0]; + conversation.setNextPresence(presence); + listener.onPresenceSelected(); + } else { + final StringBuilder presence = new StringBuilder(); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.choose_presence)); + final String[] presencesArray = presences.asStringArray(); + int preselectedPresence = 0; + for (int i = 0; i < presencesArray.length; ++i) { + if (presencesArray[i].equals(contact.lastseen.presence)) { + preselectedPresence = i; + break; + } + } + presence.append(presencesArray[preselectedPresence]); + builder.setSingleChoiceItems(presencesArray, + preselectedPresence, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + presence.delete(0, presence.length()); + presence.append(presencesArray[which]); + } + }); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ok, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + conversation.setNextPresence(presence.toString()); + listener.onPresenceSelected(); + } + }); + builder.create().show(); + } + } + } + + protected void onActivityResult(int requestCode, int resultCode, + final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_INVITE_TO_CONVERSATION + && resultCode == RESULT_OK) { + String contactJid = data.getStringExtra("contact"); + String conversationUuid = data.getStringExtra("conversation"); + Conversation conversation = xmppConnectionService + .findConversationByUuid(conversationUuid); + if (conversation.getMode() == Conversation.MODE_MULTI) { + xmppConnectionService.invite(conversation, contactJid); + } + Log.d(Config.LOGTAG, "inviting " + contactJid + " to " + + conversation.getName()); + } + } + + public int getSecondaryTextColor() { + return this.mSecondaryTextColor; + } + + public int getPrimaryTextColor() { + return this.mPrimaryTextColor; + } + + public int getWarningTextColor() { + return this.mColorRed; + } + + public int getPrimaryColor() { + return this.mPrimaryColor; + } + + public int getSecondaryBackgroundColor() { + return this.mSecondaryBackgroundColor; + } + + public int getPixel(int dp) { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + return ((int) (dp * metrics.density)); + } + + public AvatarService avatarService() { + return xmppConnectionService.getAvatarService(); + } + + class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private Message message = null; + + public BitmapWorkerTask(ImageView imageView) { + imageViewReference = new WeakReference(imageView); + } + + @Override + protected Bitmap doInBackground(Message... params) { + message = params[0]; + try { + return xmppConnectionService.getFileBackend().getThumbnail( + message, (int) (metrics.density * 288), false); + } catch (FileNotFoundException e) { + return null; + } + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (imageViewReference != null && bitmap != null) { + final ImageView imageView = imageViewReference.get(); + if (imageView != null) { + imageView.setImageBitmap(bitmap); + imageView.setBackgroundColor(0x00000000); + } + } + } + } + + public void loadBitmap(Message message, ImageView imageView) { + Bitmap bm; + try { + bm = xmppConnectionService.getFileBackend().getThumbnail(message, + (int) (metrics.density * 288), true); + } catch (FileNotFoundException e) { + bm = null; + } + if (bm != null) { + imageView.setImageBitmap(bm); + imageView.setBackgroundColor(0x00000000); + } else { + if (cancelPotentialWork(message, imageView)) { + imageView.setBackgroundColor(0xff333333); + final BitmapWorkerTask task = new BitmapWorkerTask(imageView); + final AsyncDrawable asyncDrawable = new AsyncDrawable( + getResources(), null, task); + imageView.setImageDrawable(asyncDrawable); + try { + task.execute(message); + } catch (RejectedExecutionException e) { + return; + } + } + } + } + + public static boolean cancelPotentialWork(Message message, + ImageView imageView) { + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + + if (bitmapWorkerTask != null) { + final Message oldMessage = bitmapWorkerTask.message; + if (oldMessage == null || message != oldMessage) { + bitmapWorkerTask.cancel(true); + } else { + return false; + } + } + return true; + } + + private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { + if (imageView != null) { + final Drawable drawable = imageView.getDrawable(); + if (drawable instanceof AsyncDrawable) { + final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getBitmapWorkerTask(); + } + } + return null; + } + + static class AsyncDrawable extends BitmapDrawable { + private final WeakReference bitmapWorkerTaskReference; + + public AsyncDrawable(Resources res, Bitmap bitmap, + BitmapWorkerTask bitmapWorkerTask) { + super(res, bitmap); + bitmapWorkerTaskReference = new WeakReference( + bitmapWorkerTask); + } + + public BitmapWorkerTask getBitmapWorkerTask() { + return bitmapWorkerTaskReference.get(); + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java new file mode 100644 index 000000000..4ca21a3b3 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -0,0 +1,102 @@ +package eu.siacs.conversations.ui.adapter; + +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.ui.XmppActivity; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +public class AccountAdapter extends ArrayAdapter { + + private XmppActivity activity; + + public AccountAdapter(XmppActivity activity, List objects) { + super(activity, 0, objects); + this.activity = activity; + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + Account account = getItem(position); + if (view == null) { + LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = (View) inflater.inflate(R.layout.account_row, parent, false); + } + TextView jid = (TextView) view.findViewById(R.id.account_jid); + jid.setText(account.getJid()); + TextView statusView = (TextView) view.findViewById(R.id.account_status); + ImageView imageView = (ImageView) view.findViewById(R.id.account_image); + imageView.setImageBitmap(activity.avatarService().get(account, + activity.getPixel(48))); + switch (account.getStatus()) { + case Account.STATUS_DISABLED: + statusView.setText(getContext().getString( + R.string.account_status_disabled)); + statusView.setTextColor(activity.getSecondaryTextColor()); + break; + case Account.STATUS_ONLINE: + statusView.setText(getContext().getString( + R.string.account_status_online)); + statusView.setTextColor(activity.getPrimaryColor()); + break; + case Account.STATUS_CONNECTING: + statusView.setText(getContext().getString( + R.string.account_status_connecting)); + statusView.setTextColor(activity.getSecondaryTextColor()); + break; + case Account.STATUS_OFFLINE: + statusView.setText(getContext().getString( + R.string.account_status_offline)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + case Account.STATUS_UNAUTHORIZED: + statusView.setText(getContext().getString( + R.string.account_status_unauthorized)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + case Account.STATUS_SERVER_NOT_FOUND: + statusView.setText(getContext().getString( + R.string.account_status_not_found)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + case Account.STATUS_NO_INTERNET: + statusView.setText(getContext().getString( + R.string.account_status_no_internet)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + case Account.STATUS_REGISTRATION_FAILED: + statusView.setText(getContext().getString( + R.string.account_status_regis_fail)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + case Account.STATUS_REGISTRATION_CONFLICT: + statusView.setText(getContext().getString( + R.string.account_status_regis_conflict)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + case Account.STATUS_REGISTRATION_SUCCESSFULL: + statusView.setText(getContext().getString( + R.string.account_status_regis_success)); + statusView.setTextColor(activity.getSecondaryTextColor()); + break; + case Account.STATUS_REGISTRATION_NOT_SUPPORTED: + statusView.setText(getContext().getString( + R.string.account_status_regis_not_sup)); + statusView.setTextColor(activity.getWarningTextColor()); + break; + default: + statusView.setText(""); + break; + } + + return view; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java new file mode 100644 index 000000000..183c89fad --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java @@ -0,0 +1,135 @@ +package eu.siacs.conversations.ui.adapter; + +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Downloadable; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.ui.ConversationActivity; +import eu.siacs.conversations.ui.XmppActivity; +import eu.siacs.conversations.utils.UIHelper; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +public class ConversationAdapter extends ArrayAdapter { + + private XmppActivity activity; + + public ConversationAdapter(XmppActivity activity, + List conversations) { + super(activity, 0, conversations); + this.activity = activity; + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + if (view == null) { + LayoutInflater inflater = (LayoutInflater) activity + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = (View) inflater.inflate(R.layout.conversation_list_row, + parent, false); + } + Conversation conversation = getItem(position); + if (this.activity instanceof ConversationActivity) { + ConversationActivity activity = (ConversationActivity) this.activity; + if (!activity.isConversationsOverviewHideable()) { + if (conversation == activity.getSelectedConversation()) { + view.setBackgroundColor(activity + .getSecondaryBackgroundColor()); + } else { + view.setBackgroundColor(Color.TRANSPARENT); + } + } else { + view.setBackgroundColor(Color.TRANSPARENT); + } + } + TextView convName = (TextView) view + .findViewById(R.id.conversation_name); + if (conversation.getMode() == Conversation.MODE_SINGLE + || activity.useSubjectToIdentifyConference()) { + convName.setText(conversation.getName()); + } else { + convName.setText(conversation.getContactJid().split("/")[0]); + } + TextView mLastMessage = (TextView) view + .findViewById(R.id.conversation_lastmsg); + TextView mTimestamp = (TextView) view + .findViewById(R.id.conversation_lastupdate); + ImageView imagePreview = (ImageView) view + .findViewById(R.id.conversation_lastimage); + + Message message = conversation.getLatestMessage(); + + if (!conversation.isRead()) { + convName.setTypeface(null, Typeface.BOLD); + } else { + convName.setTypeface(null, Typeface.NORMAL); + } + + if (message.getType() == Message.TYPE_IMAGE + || message.getDownloadable() != null) { + Downloadable d = message.getDownloadable(); + if (d != null) { + mLastMessage.setVisibility(View.VISIBLE); + imagePreview.setVisibility(View.GONE); + if (conversation.isRead()) { + mLastMessage.setTypeface(null, Typeface.ITALIC); + } else { + mLastMessage.setTypeface(null, Typeface.BOLD_ITALIC); + } + if (d.getStatus() == Downloadable.STATUS_CHECKING) { + mLastMessage.setText(R.string.checking_image); + } else if (d.getStatus() == Downloadable.STATUS_DOWNLOADING) { + mLastMessage.setText(R.string.receiving_image); + } else if (d.getStatus() == Downloadable.STATUS_OFFER) { + mLastMessage.setText(R.string.image_offered_for_download); + } else if (d.getStatus() == Downloadable.STATUS_OFFER_CHECK_FILESIZE) { + mLastMessage.setText(R.string.image_offered_for_download); + } else if (d.getStatus() == Downloadable.STATUS_DELETED) { + mLastMessage.setText(R.string.image_file_deleted); + } else { + mLastMessage.setText(""); + } + } else { + mLastMessage.setVisibility(View.GONE); + imagePreview.setVisibility(View.VISIBLE); + activity.loadBitmap(message, imagePreview); + } + } else { + if ((message.getEncryption() != Message.ENCRYPTION_PGP) + && (message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED)) { + String body = Config.PARSE_EMOTICONS ? UIHelper + .transformAsciiEmoticons(message.getBody()) : message + .getBody(); + mLastMessage.setText(body); + } else { + mLastMessage.setText(R.string.encrypted_message_received); + } + if (!conversation.isRead()) { + mLastMessage.setTypeface(null, Typeface.BOLD); + } else { + mLastMessage.setTypeface(null, Typeface.NORMAL); + } + mLastMessage.setVisibility(View.VISIBLE); + imagePreview.setVisibility(View.GONE); + } + mTimestamp.setText(UIHelper.readableTimeDifference(getContext(), + conversation.getLatestMessage().getTimeSent())); + + ImageView profilePicture = (ImageView) view + .findViewById(R.id.conversation_image); + profilePicture.setImageBitmap(activity.avatarService().get( + conversation, activity.getPixel(56))); + + return view; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/adapter/KnownHostsAdapter.java b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/KnownHostsAdapter.java new file mode 100644 index 000000000..143dfda12 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/KnownHostsAdapter.java @@ -0,0 +1,74 @@ +package eu.siacs.conversations.ui.adapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import android.content.Context; +import android.widget.ArrayAdapter; +import android.widget.Filter; + +public class KnownHostsAdapter extends ArrayAdapter { + private ArrayList domains; + private Filter domainFilter = new Filter() { + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + if (constraint != null) { + ArrayList suggestions = new ArrayList(); + final String[] split = constraint.toString().split("@"); + if (split.length == 1) { + for (String domain : domains) { + suggestions.add(split[0].toLowerCase(Locale + .getDefault()) + "@" + domain); + } + } else if (split.length == 2) { + for (String domain : domains) { + if (domain.contentEquals(split[1])) { + suggestions.clear(); + break; + } else if (domain.contains(split[1])) { + suggestions.add(split[0].toLowerCase(Locale + .getDefault()) + "@" + domain); + } + } + } else { + return new FilterResults(); + } + FilterResults filterResults = new FilterResults(); + filterResults.values = suggestions; + filterResults.count = suggestions.size(); + return filterResults; + } else { + return new FilterResults(); + } + } + + @Override + protected void publishResults(CharSequence constraint, + FilterResults results) { + ArrayList filteredList = (ArrayList) results.values; + if (results != null && results.count > 0) { + clear(); + for (Object c : filteredList) { + add((String) c); + } + notifyDataSetChanged(); + } + } + }; + + public KnownHostsAdapter(Context context, int viewResourceId, + List mKnownHosts) { + super(context, viewResourceId, mKnownHosts); + domains = new ArrayList(mKnownHosts.size()); + for (String domain : mKnownHosts) { + domains.add(new String(domain)); + } + } + + @Override + public Filter getFilter() { + return domainFilter; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java new file mode 100644 index 000000000..977aa7b57 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java @@ -0,0 +1,44 @@ +package eu.siacs.conversations.ui.adapter; + +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.ListItem; +import eu.siacs.conversations.ui.XmppActivity; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +public class ListItemAdapter extends ArrayAdapter { + + protected XmppActivity activity; + + public ListItemAdapter(XmppActivity activity, List objects) { + super(activity, 0, objects); + this.activity = activity; + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + ListItem item = getItem(position); + if (view == null) { + view = (View) inflater.inflate(R.layout.contact, parent, false); + } + TextView name = (TextView) view.findViewById(R.id.contact_display_name); + TextView jid = (TextView) view.findViewById(R.id.contact_jid); + ImageView picture = (ImageView) view.findViewById(R.id.contact_photo); + + jid.setText(item.getJid()); + name.setText(item.getDisplayName()); + picture.setImageBitmap(activity.avatarService().get(item, + activity.getPixel(48))); + return view; + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java new file mode 100644 index 000000000..a9a55cbf4 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -0,0 +1,560 @@ +package eu.siacs.conversations.ui.adapter; + +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Downloadable; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Message.ImageParams; +import eu.siacs.conversations.ui.ConversationActivity; +import eu.siacs.conversations.utils.UIHelper; +import android.content.Intent; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +public class MessageAdapter extends ArrayAdapter { + + private static final int SENT = 0; + private static final int RECEIVED = 1; + private static final int STATUS = 2; + private static final int NULL = 3; + + private ConversationActivity activity; + + private DisplayMetrics metrics; + + private OnContactPictureClicked mOnContactPictureClickedListener; + private OnContactPictureLongClicked mOnContactPictureLongClickedListener; + + public MessageAdapter(ConversationActivity activity, List messages) { + super(activity, 0, messages); + this.activity = activity; + metrics = getContext().getResources().getDisplayMetrics(); + } + + public void setOnContactPictureClicked(OnContactPictureClicked listener) { + this.mOnContactPictureClickedListener = listener; + } + + public void setOnContactPictureLongClicked( + OnContactPictureLongClicked listener) { + this.mOnContactPictureLongClickedListener = listener; + } + + @Override + public int getViewTypeCount() { + return 4; + } + + @Override + public int getItemViewType(int position) { + if (getItem(position).wasMergedIntoPrevious()) { + return NULL; + } else if (getItem(position).getType() == Message.TYPE_STATUS) { + return STATUS; + } else if (getItem(position).getStatus() <= Message.STATUS_RECEIVED) { + return RECEIVED; + } else { + return SENT; + } + } + + private void displayStatus(ViewHolder viewHolder, Message message) { + String filesize = null; + String info = null; + boolean error = false; + if (viewHolder.indicatorReceived != null) { + viewHolder.indicatorReceived.setVisibility(View.GONE); + } + boolean multiReceived = message.getConversation().getMode() == Conversation.MODE_MULTI + && message.getMergedStatus() <= Message.STATUS_RECEIVED; + if (message.getType() == Message.TYPE_IMAGE + || message.getDownloadable() != null) { + ImageParams params = message.getImageParams(); + if (params.size != 0) { + filesize = params.size / 1024 + " KB"; + } + } + switch (message.getMergedStatus()) { + case Message.STATUS_WAITING: + info = getContext().getString(R.string.waiting); + break; + case Message.STATUS_UNSEND: + info = getContext().getString(R.string.sending); + break; + case Message.STATUS_OFFERED: + info = getContext().getString(R.string.offering); + break; + case Message.STATUS_SEND_RECEIVED: + if (activity.indicateReceived()) { + viewHolder.indicatorReceived.setVisibility(View.VISIBLE); + } + break; + case Message.STATUS_SEND_DISPLAYED: + if (activity.indicateReceived()) { + viewHolder.indicatorReceived.setVisibility(View.VISIBLE); + } + break; + case Message.STATUS_SEND_FAILED: + info = getContext().getString(R.string.send_failed); + error = true; + break; + case Message.STATUS_SEND_REJECTED: + info = getContext().getString(R.string.send_rejected); + error = true; + break; + default: + if (multiReceived) { + Contact contact = message.getContact(); + if (contact != null) { + info = contact.getDisplayName(); + } else { + if (message.getPresence() != null) { + info = message.getPresence(); + } else { + info = message.getCounterpart(); + } + } + } + break; + } + if (error) { + viewHolder.time.setTextColor(activity.getWarningTextColor()); + } else { + viewHolder.time.setTextColor(activity.getSecondaryTextColor()); + } + if (message.getEncryption() == Message.ENCRYPTION_NONE) { + viewHolder.indicator.setVisibility(View.GONE); + } else { + viewHolder.indicator.setVisibility(View.VISIBLE); + } + + String formatedTime = UIHelper.readableTimeDifferenceFull(getContext(), + message.getMergedTimeSent()); + if (message.getStatus() <= Message.STATUS_RECEIVED) { + if ((filesize != null) && (info != null)) { + viewHolder.time.setText(filesize + " \u00B7 " + info); + } else if ((filesize == null) && (info != null)) { + viewHolder.time.setText(formatedTime + " \u00B7 " + info); + } else if ((filesize != null) && (info == null)) { + viewHolder.time.setText(formatedTime + " \u00B7 " + filesize); + } else { + viewHolder.time.setText(formatedTime); + } + } else { + if ((filesize != null) && (info != null)) { + viewHolder.time.setText(filesize + " \u00B7 " + info); + } else if ((filesize == null) && (info != null)) { + if (error) { + viewHolder.time.setText(info + " \u00B7 " + formatedTime); + } else { + viewHolder.time.setText(info); + } + } else if ((filesize != null) && (info == null)) { + viewHolder.time.setText(filesize + " \u00B7 " + formatedTime); + } else { + viewHolder.time.setText(formatedTime); + } + } + } + + private void displayInfoMessage(ViewHolder viewHolder, int r) { + if (viewHolder.download_button != null) { + viewHolder.download_button.setVisibility(View.GONE); + } + viewHolder.image.setVisibility(View.GONE); + viewHolder.messageBody.setVisibility(View.VISIBLE); + viewHolder.messageBody.setText(getContext().getString(r)); + viewHolder.messageBody.setTextColor(activity.getSecondaryTextColor()); + viewHolder.messageBody.setTypeface(null, Typeface.ITALIC); + viewHolder.messageBody.setTextIsSelectable(false); + } + + private void displayDecryptionFailed(ViewHolder viewHolder) { + if (viewHolder.download_button != null) { + viewHolder.download_button.setVisibility(View.GONE); + } + viewHolder.image.setVisibility(View.GONE); + viewHolder.messageBody.setVisibility(View.VISIBLE); + viewHolder.messageBody.setText(getContext().getString( + R.string.decryption_failed)); + viewHolder.messageBody.setTextColor(activity.getWarningTextColor()); + viewHolder.messageBody.setTypeface(null, Typeface.NORMAL); + viewHolder.messageBody.setTextIsSelectable(false); + } + + private void displayTextMessage(ViewHolder viewHolder, Message message) { + if (viewHolder.download_button != null) { + viewHolder.download_button.setVisibility(View.GONE); + } + viewHolder.image.setVisibility(View.GONE); + viewHolder.messageBody.setVisibility(View.VISIBLE); + if (message.getBody() != null) { + if (message.getType() != Message.TYPE_PRIVATE) { + String body = Config.PARSE_EMOTICONS ? UIHelper + .transformAsciiEmoticons(message.getMergedBody()) + : message.getMergedBody(); + viewHolder.messageBody.setText(body); + } else { + String privateMarker; + if (message.getStatus() <= Message.STATUS_RECEIVED) { + privateMarker = activity + .getString(R.string.private_message); + } else { + String to; + if (message.getPresence() != null) { + to = message.getPresence(); + } else { + to = message.getCounterpart(); + } + privateMarker = activity.getString( + R.string.private_message_to, to); + } + SpannableString span = new SpannableString(privateMarker + " " + + message.getBody()); + span.setSpan( + new ForegroundColorSpan(activity + .getSecondaryTextColor()), 0, privateMarker + .length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + span.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, + privateMarker.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + viewHolder.messageBody.setText(span); + } + } else { + viewHolder.messageBody.setText(""); + } + viewHolder.messageBody.setTextColor(activity.getPrimaryTextColor()); + viewHolder.messageBody.setTypeface(null, Typeface.NORMAL); + viewHolder.messageBody.setTextIsSelectable(true); + } + + private void displayDownloadableMessage(ViewHolder viewHolder, + final Message message, int resid) { + viewHolder.image.setVisibility(View.GONE); + viewHolder.messageBody.setVisibility(View.GONE); + viewHolder.download_button.setVisibility(View.VISIBLE); + viewHolder.download_button.setText(resid); + viewHolder.download_button.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + startDonwloadable(message); + } + }); + } + + private void displayImageMessage(ViewHolder viewHolder, + final Message message) { + if (viewHolder.download_button != null) { + viewHolder.download_button.setVisibility(View.GONE); + } + viewHolder.messageBody.setVisibility(View.GONE); + viewHolder.image.setVisibility(View.VISIBLE); + ImageParams params = message.getImageParams(); + double target = metrics.density * 288; + int scalledW; + int scalledH; + if (params.width <= params.height) { + scalledW = (int) (params.width / ((double) params.height / target)); + scalledH = (int) target; + } else { + scalledW = (int) target; + scalledH = (int) (params.height / ((double) params.width / target)); + } + viewHolder.image.setLayoutParams(new LinearLayout.LayoutParams( + scalledW, scalledH)); + activity.loadBitmap(message, viewHolder.image); + viewHolder.image.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(activity.xmppConnectionService + .getFileBackend().getJingleFileUri(message), "image/*"); + getContext().startActivity(intent); + } + }); + viewHolder.image.setOnLongClickListener(new OnLongClickListener() { + + @Override + public boolean onLongClick(View v) { + Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_STREAM, + activity.xmppConnectionService.getFileBackend() + .getJingleFileUri(message)); + shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.setType("image/webp"); + getContext().startActivity( + Intent.createChooser(shareIntent, + getContext().getText(R.string.share_with))); + return true; + } + }); + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + final Message item = getItem(position); + int type = getItemViewType(position); + ViewHolder viewHolder; + if (view == null) { + viewHolder = new ViewHolder(); + switch (type) { + case NULL: + view = (View) activity.getLayoutInflater().inflate( + R.layout.message_null, parent, false); + break; + case SENT: + view = (View) activity.getLayoutInflater().inflate( + R.layout.message_sent, parent, false); + viewHolder.message_box = (LinearLayout) view + .findViewById(R.id.message_box); + viewHolder.contact_picture = (ImageView) view + .findViewById(R.id.message_photo); + viewHolder.contact_picture.setImageBitmap(activity + .avatarService().get( + item.getConversation().getAccount(), + activity.getPixel(48))); + viewHolder.download_button = (Button) view + .findViewById(R.id.download_button); + viewHolder.indicator = (ImageView) view + .findViewById(R.id.security_indicator); + viewHolder.image = (ImageView) view + .findViewById(R.id.message_image); + viewHolder.messageBody = (TextView) view + .findViewById(R.id.message_body); + viewHolder.time = (TextView) view + .findViewById(R.id.message_time); + viewHolder.indicatorReceived = (ImageView) view + .findViewById(R.id.indicator_received); + view.setTag(viewHolder); + break; + case RECEIVED: + view = (View) activity.getLayoutInflater().inflate( + R.layout.message_received, parent, false); + viewHolder.message_box = (LinearLayout) view + .findViewById(R.id.message_box); + viewHolder.contact_picture = (ImageView) view + .findViewById(R.id.message_photo); + viewHolder.download_button = (Button) view + .findViewById(R.id.download_button); + if (item.getConversation().getMode() == Conversation.MODE_SINGLE) { + viewHolder.contact_picture.setImageBitmap(activity + .avatarService().get(item.getContact(), + activity.getPixel(48))); + } + viewHolder.indicator = (ImageView) view + .findViewById(R.id.security_indicator); + viewHolder.image = (ImageView) view + .findViewById(R.id.message_image); + viewHolder.messageBody = (TextView) view + .findViewById(R.id.message_body); + viewHolder.time = (TextView) view + .findViewById(R.id.message_time); + view.setTag(viewHolder); + break; + case STATUS: + view = (View) activity.getLayoutInflater().inflate( + R.layout.message_status, parent, false); + viewHolder.contact_picture = (ImageView) view + .findViewById(R.id.message_photo); + if (item.getConversation().getMode() == Conversation.MODE_SINGLE) { + + viewHolder.contact_picture.setImageBitmap(activity + .avatarService().get( + item.getConversation().getContact(), + activity.getPixel(32))); + viewHolder.contact_picture.setAlpha(0.5f); + viewHolder.contact_picture + .setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + String name = item.getConversation() + .getName(); + String read = getContext() + .getString( + R.string.contact_has_read_up_to_this_point, + name); + Toast.makeText(getContext(), read, + Toast.LENGTH_SHORT).show(); + } + }); + + } + break; + default: + viewHolder = null; + break; + } + } else { + viewHolder = (ViewHolder) view.getTag(); + } + + if (type == STATUS) { + return view; + } + if (type == NULL) { + if (position == getCount() - 1) { + view.getLayoutParams().height = 1; + } else { + view.getLayoutParams().height = 0; + + } + view.setLayoutParams(view.getLayoutParams()); + return view; + } + + if (viewHolder.contact_picture != null) { + viewHolder.contact_picture + .setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (MessageAdapter.this.mOnContactPictureClickedListener != null) { + MessageAdapter.this.mOnContactPictureClickedListener + .onContactPictureClicked(item); + ; + } + + } + }); + viewHolder.contact_picture + .setOnLongClickListener(new OnLongClickListener() { + + @Override + public boolean onLongClick(View v) { + if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) { + MessageAdapter.this.mOnContactPictureLongClickedListener + .onContactPictureLongClicked(item); + return true; + } else { + return false; + } + } + }); + } + + if (type == RECEIVED) { + if (item.getConversation().getMode() == Conversation.MODE_MULTI) { + Contact contact = item.getContact(); + if (contact != null) { + viewHolder.contact_picture.setImageBitmap(activity + .avatarService() + .get(contact, activity.getPixel(48))); + } else { + String name = item.getPresence(); + if (name == null) { + name = item.getCounterpart(); + } + viewHolder.contact_picture.setImageBitmap(activity + .avatarService().get(name, activity.getPixel(48))); + } + } + } + + if (item.getType() == Message.TYPE_IMAGE + || item.getDownloadable() != null) { + Downloadable d = item.getDownloadable(); + if (d != null && d.getStatus() == Downloadable.STATUS_DOWNLOADING) { + displayInfoMessage(viewHolder, R.string.receiving_image); + } else if (d != null + && d.getStatus() == Downloadable.STATUS_CHECKING) { + displayInfoMessage(viewHolder, R.string.checking_image); + } else if (d != null + && d.getStatus() == Downloadable.STATUS_DELETED) { + displayInfoMessage(viewHolder, R.string.image_file_deleted); + } else if (d != null && d.getStatus() == Downloadable.STATUS_OFFER) { + displayDownloadableMessage(viewHolder, item, + R.string.download_image); + } else if (d != null + && d.getStatus() == Downloadable.STATUS_OFFER_CHECK_FILESIZE) { + displayDownloadableMessage(viewHolder, item, + R.string.check_image_filesize); + } else if ((item.getEncryption() == Message.ENCRYPTION_DECRYPTED) + || (item.getEncryption() == Message.ENCRYPTION_NONE) + || (item.getEncryption() == Message.ENCRYPTION_OTR)) { + displayImageMessage(viewHolder, item); + } else if (item.getEncryption() == Message.ENCRYPTION_PGP) { + displayInfoMessage(viewHolder, R.string.encrypted_message); + } else { + displayDecryptionFailed(viewHolder); + } + } else { + if (item.getEncryption() == Message.ENCRYPTION_PGP) { + if (activity.hasPgp()) { + displayInfoMessage(viewHolder, R.string.encrypted_message); + } else { + displayInfoMessage(viewHolder, + R.string.install_openkeychain); + viewHolder.message_box + .setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + activity.showInstallPgpDialog(); + } + }); + } + } else if (item.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) { + displayDecryptionFailed(viewHolder); + } else { + displayTextMessage(viewHolder, item); + } + } + + displayStatus(viewHolder, item); + + return view; + } + + public void startDonwloadable(Message message) { + Downloadable downloadable = message.getDownloadable(); + if (downloadable != null) { + if (!downloadable.start()) { + Toast.makeText(activity, R.string.not_connected_try_again, + Toast.LENGTH_SHORT).show(); + } + } + } + + private static class ViewHolder { + + protected LinearLayout message_box; + protected Button download_button; + protected ImageView image; + protected ImageView indicator; + protected ImageView indicatorReceived; + protected TextView time; + protected TextView messageBody; + protected ImageView contact_picture; + + } + + public interface OnContactPictureClicked { + public void onContactPictureClicked(Message message); + } + + public interface OnContactPictureLongClicked { + public void onContactPictureLongClicked(Message message); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java b/conversations/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java new file mode 100644 index 000000000..47595c6e3 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java @@ -0,0 +1,112 @@ +package eu.siacs.conversations.utils; + +import java.math.BigInteger; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import eu.siacs.conversations.entities.Account; +import android.util.Base64; + +public class CryptoHelper { + public static final String FILETRANSFER = "?FILETRANSFERv1:"; + final protected static char[] hexArray = "0123456789abcdef".toCharArray(); + final protected static char[] vowels = "aeiou".toCharArray(); + final protected static char[] consonants = "bcdfghjklmnpqrstvwxyz" + .toCharArray(); + + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } + + public static byte[] hexToBytes(String hexString) { + int len = hexString.length(); + byte[] array = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + array[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4) + Character + .digit(hexString.charAt(i + 1), 16)); + } + return array; + } + + public static String saslPlain(String username, String password) { + String sasl = '\u0000' + username + '\u0000' + password; + return Base64.encodeToString(sasl.getBytes(Charset.defaultCharset()), + Base64.NO_WRAP); + } + + private static byte[] concatenateByteArrays(byte[] a, byte[] b) { + byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } + + public static String saslDigestMd5(Account account, String challenge, + SecureRandom random) { + try { + String[] challengeParts = new String(Base64.decode(challenge, + Base64.DEFAULT)).split(","); + String nonce = ""; + for (int i = 0; i < challengeParts.length; ++i) { + String[] parts = challengeParts[i].split("="); + if (parts[0].equals("nonce")) { + nonce = parts[1].replace("\"", ""); + } else if (parts[0].equals("rspauth")) { + return null; + } + } + String digestUri = "xmpp/" + account.getServer(); + String nonceCount = "00000001"; + String x = account.getUsername() + ":" + account.getServer() + ":" + + account.getPassword(); + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] y = md.digest(x.getBytes(Charset.defaultCharset())); + String cNonce = new BigInteger(100, random).toString(32); + byte[] a1 = concatenateByteArrays(y, + (":" + nonce + ":" + cNonce).getBytes(Charset + .defaultCharset())); + String a2 = "AUTHENTICATE:" + digestUri; + String ha1 = bytesToHex(md.digest(a1)); + String ha2 = bytesToHex(md.digest(a2.getBytes(Charset + .defaultCharset()))); + String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + + ":auth:" + ha2; + String response = bytesToHex(md.digest(kd.getBytes(Charset + .defaultCharset()))); + String saslString = "username=\"" + account.getUsername() + + "\",realm=\"" + account.getServer() + "\",nonce=\"" + + nonce + "\",cnonce=\"" + cNonce + "\",nc=" + nonceCount + + ",qop=auth,digest-uri=\"" + digestUri + "\",response=" + + response + ",charset=utf-8"; + return Base64.encodeToString( + saslString.getBytes(Charset.defaultCharset()), + Base64.NO_WRAP); + } catch (NoSuchAlgorithmException e) { + return null; + } + } + + public static String randomMucName(SecureRandom random) { + return randomWord(3, random) + "." + randomWord(7, random); + } + + protected static String randomWord(int lenght, SecureRandom random) { + StringBuilder builder = new StringBuilder(lenght); + for (int i = 0; i < lenght; ++i) { + if (i % 2 == 0) { + builder.append(consonants[random.nextInt(consonants.length)]); + } else { + builder.append(vowels[random.nextInt(vowels.length)]); + } + } + return builder.toString(); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/DNSHelper.java b/conversations/src/main/java/eu/siacs/conversations/utils/DNSHelper.java new file mode 100644 index 000000000..c51a75ac6 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/DNSHelper.java @@ -0,0 +1,185 @@ +package eu.siacs.conversations.utils; + +import de.measite.minidns.Client; +import de.measite.minidns.DNSMessage; +import de.measite.minidns.Record; +import de.measite.minidns.Record.TYPE; +import de.measite.minidns.Record.CLASS; +import de.measite.minidns.record.SRV; +import de.measite.minidns.record.A; +import de.measite.minidns.record.AAAA; +import de.measite.minidns.record.Data; +import de.measite.minidns.util.NameUtil; +import eu.siacs.conversations.Config; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Random; +import java.util.TreeMap; + +import android.os.Bundle; +import android.util.Log; + +public class DNSHelper { + protected static Client client = new Client(); + + public static Bundle getSRVRecord(String host) throws IOException { + String dns[] = client.findDNS(); + + if (dns != null) { + for (String dnsserver : dns) { + InetAddress ip = InetAddress.getByName(dnsserver); + Bundle b = queryDNS(host, ip); + if (b.containsKey("name")) { + return b; + } else if (b.containsKey("error") + && "nosrv".equals(b.getString("error", null))) { + return b; + } + } + } + return queryDNS(host, InetAddress.getByName("8.8.8.8")); + } + + public static Bundle queryDNS(String host, InetAddress dnsServer) { + Bundle namePort = new Bundle(); + try { + String qname = "_xmpp-client._tcp." + host; + Log.d(Config.LOGTAG, + "using dns server: " + dnsServer.getHostAddress() + + " to look up " + host); + DNSMessage message = client.query(qname, TYPE.SRV, CLASS.IN, + dnsServer.getHostAddress()); + + // How should we handle priorities and weight? + // Wikipedia has a nice article about priorities vs. weights: + // https://en.wikipedia.org/wiki/SRV_record#Provisioning_for_high_service_availability + + // we bucket the SRV records based on priority, pick per priority + // a random order respecting the weight, and dump that priority by + // priority + + TreeMap> priorities = new TreeMap>(); + TreeMap> ips4 = new TreeMap>(); + TreeMap> ips6 = new TreeMap>(); + + for (Record[] rrset : new Record[][] { message.getAnswers(), + message.getAdditionalResourceRecords() }) { + for (Record rr : rrset) { + Data d = rr.getPayload(); + if (d instanceof SRV + && NameUtil.idnEquals(qname, rr.getName())) { + SRV srv = (SRV) d; + if (!priorities.containsKey(srv.getPriority())) { + priorities.put(srv.getPriority(), + new ArrayList(2)); + } + priorities.get(srv.getPriority()).add(srv); + } + if (d instanceof A) { + A arecord = (A) d; + if (!ips4.containsKey(rr.getName())) { + ips4.put(rr.getName(), new ArrayList(3)); + } + ips4.get(rr.getName()).add(arecord.toString()); + } + if (d instanceof AAAA) { + AAAA aaaa = (AAAA) d; + if (!ips6.containsKey(rr.getName())) { + ips6.put(rr.getName(), new ArrayList(3)); + } + ips6.get(rr.getName()).add("[" + aaaa.toString() + "]"); + } + } + } + + Random rnd = new Random(); + ArrayList result = new ArrayList( + priorities.size() * 2 + 1); + for (ArrayList s : priorities.values()) { + + // trivial case + if (s.size() <= 1) { + result.addAll(s); + continue; + } + + long totalweight = 0l; + for (SRV srv : s) { + totalweight += srv.getWeight(); + } + + while (totalweight > 0l && s.size() > 0) { + long p = (rnd.nextLong() & 0x7fffffffffffffffl) + % totalweight; + int i = 0; + while (p > 0) { + p -= s.get(i++).getPriority(); + } + i--; + // remove is expensive, but we have only a few entries + // anyway + SRV srv = s.remove(i); + totalweight -= srv.getWeight(); + result.add(srv); + } + + Collections.shuffle(s, rnd); + result.addAll(s); + + } + + if (result.size() == 0) { + namePort.putString("error", "nosrv"); + return namePort; + } + // we now have a list of servers to try :-) + + // classic name/port pair + String resultName = result.get(0).getName(); + namePort.putString("name", resultName); + namePort.putInt("port", result.get(0).getPort()); + + if (ips4.containsKey(resultName)) { + // we have an ip! + ArrayList ip = ips4.get(resultName); + Collections.shuffle(ip, rnd); + namePort.putString("ipv4", ip.get(0)); + } + if (ips6.containsKey(resultName)) { + ArrayList ip = ips6.get(resultName); + Collections.shuffle(ip, rnd); + namePort.putString("ipv6", ip.get(0)); + } + + // add all other records + int i = 0; + for (SRV srv : result) { + namePort.putString("name" + i, srv.getName()); + namePort.putInt("port" + i, srv.getPort()); + i++; + } + + } catch (SocketTimeoutException e) { + namePort.putString("error", "timeout"); + } catch (Exception e) { + namePort.putString("error", "unhandled"); + } + return namePort; + } + + final protected static char[] hexArray = "0123456789ABCDEF".toCharArray(); + + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHandler.java b/conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHandler.java new file mode 100644 index 000000000..88fa18ff2 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHandler.java @@ -0,0 +1,44 @@ +package eu.siacs.conversations.utils; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.lang.Thread.UncaughtExceptionHandler; + +import android.content.Context; + +public class ExceptionHandler implements UncaughtExceptionHandler { + + private UncaughtExceptionHandler defaultHandler; + private Context context; + + public ExceptionHandler(Context context) { + this.context = context; + this.defaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + } + + @Override + public void uncaughtException(Thread thread, Throwable ex) { + Writer result = new StringWriter(); + PrintWriter printWriter = new PrintWriter(result); + ex.printStackTrace(printWriter); + String stacktrace = result.toString(); + printWriter.close(); + try { + OutputStream os = context.openFileOutput("stacktrace.txt", + Context.MODE_PRIVATE); + os.write(stacktrace.getBytes()); + } catch (FileNotFoundException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + this.defaultHandler.uncaughtException(thread, ex); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHelper.java b/conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHelper.java new file mode 100644 index 000000000..b5fc88bdd --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/ExceptionHelper.java @@ -0,0 +1,117 @@ +package eu.siacs.conversations.utils; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.content.DialogInterface.OnClickListener; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.preference.PreferenceManager; +import android.text.format.DateUtils; +import android.util.Log; + +public class ExceptionHelper { + public static void init(Context context) { + if (!(Thread.getDefaultUncaughtExceptionHandler() instanceof ExceptionHandler)) { + Thread.setDefaultUncaughtExceptionHandler(new ExceptionHandler( + context)); + } + } + + public static void checkForCrash(Context context, + final XmppConnectionService service) { + try { + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(context); + boolean neverSend = preferences.getBoolean("never_send", false); + if (neverSend) { + return; + } + List accounts = service.getAccounts(); + Account account = null; + for (int i = 0; i < accounts.size(); ++i) { + if (!accounts.get(i).isOptionSet(Account.OPTION_DISABLED)) { + account = accounts.get(i); + break; + } + } + if (account == null) { + return; + } + final Account finalAccount = account; + FileInputStream file = context.openFileInput("stacktrace.txt"); + InputStreamReader inputStreamReader = new InputStreamReader(file); + BufferedReader stacktrace = new BufferedReader(inputStreamReader); + final StringBuilder report = new StringBuilder(); + PackageManager pm = context.getPackageManager(); + PackageInfo packageInfo = null; + try { + packageInfo = pm.getPackageInfo(context.getPackageName(), 0); + report.append("Version: " + packageInfo.versionName + '\n'); + report.append("Last Update: " + + DateUtils.formatDateTime(context, + packageInfo.lastUpdateTime, + DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_SHOW_DATE) + '\n'); + } catch (NameNotFoundException e) { + } + String line; + while ((line = stacktrace.readLine()) != null) { + report.append(line); + report.append('\n'); + } + file.close(); + context.deleteFile("stacktrace.txt"); + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(context.getString(R.string.crash_report_title)); + builder.setMessage(context.getText(R.string.crash_report_message)); + builder.setPositiveButton(context.getText(R.string.send_now), + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + + Log.d(Config.LOGTAG, "using account=" + + finalAccount.getJid() + + " to send in stack trace"); + Conversation conversation = service + .findOrCreateConversation(finalAccount, + "bugs@siacs.eu", false); + Message message = new Message(conversation, report + .toString(), Message.ENCRYPTION_NONE); + service.sendMessage(message); + } + }); + builder.setNegativeButton(context.getText(R.string.send_never), + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + preferences.edit().putBoolean("never_send", true) + .commit(); + } + }); + builder.create().show(); + } catch (FileNotFoundException e) { + return; + } catch (IOException e) { + return; + } + + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java b/conversations/src/main/java/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java new file mode 100644 index 000000000..9a6897689 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java @@ -0,0 +1,9 @@ +package eu.siacs.conversations.utils; + +import java.util.List; + +import android.os.Bundle; + +public interface OnPhoneContactsLoadedListener { + public void onPhoneContactsLoaded(List phoneContacts); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/PRNGFixes.java b/conversations/src/main/java/eu/siacs/conversations/utils/PRNGFixes.java new file mode 100644 index 000000000..8fe67234e --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/PRNGFixes.java @@ -0,0 +1,327 @@ +package eu.siacs.conversations.utils; + +import android.os.Build; +import android.os.Process; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.SecureRandomSpi; +import java.security.Security; + +/** + * Fixes for the output of the default PRNG having low entropy. + * + * The fixes need to be applied via {@link #apply()} before any use of Java + * Cryptography Architecture primitives. A good place to invoke them is in the + * application's {@code onCreate}. + */ +public final class PRNGFixes { + + private static final int VERSION_CODE_JELLY_BEAN = 16; + private static final int VERSION_CODE_JELLY_BEAN_MR2 = 18; + private static final byte[] BUILD_FINGERPRINT_AND_DEVICE_SERIAL = getBuildFingerprintAndDeviceSerial(); + + /** Hidden constructor to prevent instantiation. */ + private PRNGFixes() { + } + + /** + * Applies all fixes. + * + * @throws SecurityException + * if a fix is needed but could not be applied. + */ + public static void apply() { + applyOpenSSLFix(); + installLinuxPRNGSecureRandom(); + } + + /** + * Applies the fix for OpenSSL PRNG having low entropy. Does nothing if the + * fix is not needed. + * + * @throws SecurityException + * if the fix is needed but could not be applied. + */ + private static void applyOpenSSLFix() throws SecurityException { + if ((Build.VERSION.SDK_INT < VERSION_CODE_JELLY_BEAN) + || (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2)) { + // No need to apply the fix + return; + } + + try { + // Mix in the device- and invocation-specific seed. + Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_seed", byte[].class) + .invoke(null, generateSeed()); + + // Mix output of Linux PRNG into OpenSSL's PRNG + int bytesRead = (Integer) Class + .forName( + "org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_load_file", String.class, long.class) + .invoke(null, "/dev/urandom", 1024); + if (bytesRead != 1024) { + throw new IOException( + "Unexpected number of bytes read from Linux PRNG: " + + bytesRead); + } + } catch (Exception e) { + throw new SecurityException("Failed to seed OpenSSL PRNG", e); + } + } + + /** + * Installs a Linux PRNG-backed {@code SecureRandom} implementation as the + * default. Does nothing if the implementation is already the default or if + * there is not need to install the implementation. + * + * @throws SecurityException + * if the fix is needed but could not be applied. + */ + private static void installLinuxPRNGSecureRandom() throws SecurityException { + if (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2) { + // No need to apply the fix + return; + } + + // Install a Linux PRNG-based SecureRandom implementation as the + // default, if not yet installed. + Provider[] secureRandomProviders = Security + .getProviders("SecureRandom.SHA1PRNG"); + if ((secureRandomProviders == null) + || (secureRandomProviders.length < 1) + || (!LinuxPRNGSecureRandomProvider.class + .equals(secureRandomProviders[0].getClass()))) { + Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1); + } + + // Assert that new SecureRandom() and + // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed + // by the Linux PRNG-based SecureRandom implementation. + SecureRandom rng1 = new SecureRandom(); + if (!LinuxPRNGSecureRandomProvider.class.equals(rng1.getProvider() + .getClass())) { + throw new SecurityException( + "new SecureRandom() backed by wrong Provider: " + + rng1.getProvider().getClass()); + } + + SecureRandom rng2; + try { + rng2 = SecureRandom.getInstance("SHA1PRNG"); + } catch (NoSuchAlgorithmException e) { + throw new SecurityException("SHA1PRNG not available", e); + } + if (!LinuxPRNGSecureRandomProvider.class.equals(rng2.getProvider() + .getClass())) { + throw new SecurityException( + "SecureRandom.getInstance(\"SHA1PRNG\") backed by wrong" + + " Provider: " + rng2.getProvider().getClass()); + } + } + + /** + * {@code Provider} of {@code SecureRandom} engines which pass through all + * requests to the Linux PRNG. + */ + private static class LinuxPRNGSecureRandomProvider extends Provider { + + public LinuxPRNGSecureRandomProvider() { + super("LinuxPRNG", 1.0, + "A Linux-specific random number provider that uses" + + " /dev/urandom"); + // Although /dev/urandom is not a SHA-1 PRNG, some apps + // explicitly request a SHA1PRNG SecureRandom and we thus need to + // prevent them from getting the default implementation whose output + // may have low entropy. + put("SecureRandom.SHA1PRNG", LinuxPRNGSecureRandom.class.getName()); + put("SecureRandom.SHA1PRNG ImplementedIn", "Software"); + } + } + + /** + * {@link SecureRandomSpi} which passes all requests to the Linux PRNG ( + * {@code /dev/urandom}). + */ + public static class LinuxPRNGSecureRandom extends SecureRandomSpi { + + /* + * IMPLEMENTATION NOTE: Requests to generate bytes and to mix in a seed + * are passed through to the Linux PRNG (/dev/urandom). Instances of + * this class seed themselves by mixing in the current time, PID, UID, + * build fingerprint, and hardware serial number (where available) into + * Linux PRNG. + * + * Concurrency: Read requests to the underlying Linux PRNG are + * serialized (on sLock) to ensure that multiple threads do not get + * duplicated PRNG output. + */ + + private static final File URANDOM_FILE = new File("/dev/urandom"); + + private static final Object sLock = new Object(); + + /** + * Input stream for reading from Linux PRNG or {@code null} if not yet + * opened. + * + * @GuardedBy("sLock") + */ + private static DataInputStream sUrandomIn; + + /** + * Output stream for writing to Linux PRNG or {@code null} if not yet + * opened. + * + * @GuardedBy("sLock") + */ + private static OutputStream sUrandomOut; + + /** + * Whether this engine instance has been seeded. This is needed because + * each instance needs to seed itself if the client does not explicitly + * seed it. + */ + private boolean mSeeded; + + @Override + protected void engineSetSeed(byte[] bytes) { + try { + OutputStream out; + synchronized (sLock) { + out = getUrandomOutputStream(); + } + out.write(bytes); + out.flush(); + } catch (IOException e) { + // On a small fraction of devices /dev/urandom is not writable. + // Log and ignore. + Log.w(PRNGFixes.class.getSimpleName(), + "Failed to mix seed into " + URANDOM_FILE); + } finally { + mSeeded = true; + } + } + + @Override + protected void engineNextBytes(byte[] bytes) { + if (!mSeeded) { + // Mix in the device- and invocation-specific seed. + engineSetSeed(generateSeed()); + } + + try { + DataInputStream in; + synchronized (sLock) { + in = getUrandomInputStream(); + } + synchronized (in) { + in.readFully(bytes); + } + } catch (IOException e) { + throw new SecurityException("Failed to read from " + + URANDOM_FILE, e); + } + } + + @Override + protected byte[] engineGenerateSeed(int size) { + byte[] seed = new byte[size]; + engineNextBytes(seed); + return seed; + } + + private DataInputStream getUrandomInputStream() { + synchronized (sLock) { + if (sUrandomIn == null) { + // NOTE: Consider inserting a BufferedInputStream between + // DataInputStream and FileInputStream if you need higher + // PRNG output performance and can live with future PRNG + // output being pulled into this process prematurely. + try { + sUrandomIn = new DataInputStream(new FileInputStream( + URANDOM_FILE)); + } catch (IOException e) { + throw new SecurityException("Failed to open " + + URANDOM_FILE + " for reading", e); + } + } + return sUrandomIn; + } + } + + private OutputStream getUrandomOutputStream() throws IOException { + synchronized (sLock) { + if (sUrandomOut == null) { + sUrandomOut = new FileOutputStream(URANDOM_FILE); + } + return sUrandomOut; + } + } + } + + /** + * Generates a device- and invocation-specific seed to be mixed into the + * Linux PRNG. + */ + private static byte[] generateSeed() { + try { + ByteArrayOutputStream seedBuffer = new ByteArrayOutputStream(); + DataOutputStream seedBufferOut = new DataOutputStream(seedBuffer); + seedBufferOut.writeLong(System.currentTimeMillis()); + seedBufferOut.writeLong(System.nanoTime()); + seedBufferOut.writeInt(Process.myPid()); + seedBufferOut.writeInt(Process.myUid()); + seedBufferOut.write(BUILD_FINGERPRINT_AND_DEVICE_SERIAL); + seedBufferOut.close(); + return seedBuffer.toByteArray(); + } catch (IOException e) { + throw new SecurityException("Failed to generate seed", e); + } + } + + /** + * Gets the hardware serial number of this device. + * + * @return serial number or {@code null} if not available. + */ + private static String getDeviceSerialNumber() { + // We're using the Reflection API because Build.SERIAL is only available + // since API Level 9 (Gingerbread, Android 2.3). + try { + return (String) Build.class.getField("SERIAL").get(null); + } catch (Exception ignored) { + return null; + } + } + + private static byte[] getBuildFingerprintAndDeviceSerial() { + StringBuilder result = new StringBuilder(); + String fingerprint = Build.FINGERPRINT; + if (fingerprint != null) { + result.append(fingerprint); + } + String serial = getDeviceSerialNumber(); + if (serial != null) { + result.append(serial); + } + try { + return result.toString().getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 encoding not supported"); + } + } +} \ No newline at end of file diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java b/conversations/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java new file mode 100644 index 000000000..5becc7e79 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/PhoneHelper.java @@ -0,0 +1,95 @@ +package eu.siacs.conversations.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.RejectedExecutionException; + +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.content.Loader.OnLoadCompleteListener; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Profile; + +public class PhoneHelper { + + public static void loadPhoneContacts(Context context, + final OnPhoneContactsLoadedListener listener) { + final List phoneContacts = new ArrayList(); + + final String[] PROJECTION = new String[] { ContactsContract.Data._ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.Data.PHOTO_URI, + ContactsContract.Data.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Im.DATA }; + + final String SELECTION = "(" + ContactsContract.Data.MIMETYPE + "=\"" + + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE + + "\") AND (" + ContactsContract.CommonDataKinds.Im.PROTOCOL + + "=\"" + ContactsContract.CommonDataKinds.Im.PROTOCOL_JABBER + + "\")"; + + CursorLoader mCursorLoader = new CursorLoader(context, + ContactsContract.Data.CONTENT_URI, PROJECTION, SELECTION, null, + null); + mCursorLoader.registerListener(0, new OnLoadCompleteListener() { + + @Override + public void onLoadComplete(Loader arg0, Cursor cursor) { + if (cursor == null) { + return; + } + while (cursor.moveToNext()) { + Bundle contact = new Bundle(); + contact.putInt("phoneid", cursor.getInt(cursor + .getColumnIndex(ContactsContract.Data._ID))); + contact.putString( + "displayname", + cursor.getString(cursor + .getColumnIndex(ContactsContract.Data.DISPLAY_NAME))); + contact.putString("photouri", cursor.getString(cursor + .getColumnIndex(ContactsContract.Data.PHOTO_URI))); + contact.putString("lookup", cursor.getString(cursor + .getColumnIndex(ContactsContract.Data.LOOKUP_KEY))); + + contact.putString( + "jid", + cursor.getString(cursor + .getColumnIndex(ContactsContract.CommonDataKinds.Im.DATA))); + phoneContacts.add(contact); + } + if (listener != null) { + listener.onPhoneContactsLoaded(phoneContacts); + } + } + }); + try { + mCursorLoader.startLoading(); + } catch (RejectedExecutionException e) { + if (listener != null) { + listener.onPhoneContactsLoaded(phoneContacts); + } + } + } + + public static Uri getSefliUri(Context context) { + String[] mProjection = new String[] { Profile._ID, Profile.PHOTO_URI }; + Cursor mProfileCursor = context.getContentResolver().query( + Profile.CONTENT_URI, mProjection, null, null, null); + + if (mProfileCursor == null || mProfileCursor.getCount() == 0) { + return null; + } else { + mProfileCursor.moveToFirst(); + String uri = mProfileCursor.getString(1); + if (uri == null) { + return null; + } else { + return Uri.parse(uri); + } + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/conversations/src/main/java/eu/siacs/conversations/utils/UIHelper.java new file mode 100644 index 000000000..5141c83c4 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -0,0 +1,225 @@ +package eu.siacs.conversations.utils; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.ui.ConversationActivity; +import eu.siacs.conversations.ui.ManageAccountActivity; +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.TaskStackBuilder; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +public class UIHelper { + private static final int SHORT_DATE_FLAGS = DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL; + private static final int FULL_DATE_FLAGS = DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE; + + public static String readableTimeDifference(Context context, long time) { + return readableTimeDifference(context, time, false); + } + + public static String readableTimeDifferenceFull(Context context, long time) { + return readableTimeDifference(context, time, true); + } + + private static String readableTimeDifference(Context context, long time, + boolean fullDate) { + if (time == 0) { + return context.getString(R.string.just_now); + } + Date date = new Date(time); + long difference = (System.currentTimeMillis() - time) / 1000; + if (difference < 60) { + return context.getString(R.string.just_now); + } else if (difference < 60 * 2) { + return context.getString(R.string.minute_ago); + } else if (difference < 60 * 15) { + return context.getString(R.string.minutes_ago, + Math.round(difference / 60.0)); + } else if (today(date)) { + java.text.DateFormat df = DateFormat.getTimeFormat(context); + return df.format(date); + } else { + if (fullDate) { + return DateUtils.formatDateTime(context, date.getTime(), + FULL_DATE_FLAGS); + } else { + return DateUtils.formatDateTime(context, date.getTime(), + SHORT_DATE_FLAGS); + } + } + } + + private static boolean today(Date date) { + Calendar cal1 = Calendar.getInstance(); + Calendar cal2 = Calendar.getInstance(); + cal1.setTime(date); + cal2.setTimeInMillis(System.currentTimeMillis()); + return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) + && cal1.get(Calendar.DAY_OF_YEAR) == cal2 + .get(Calendar.DAY_OF_YEAR); + } + + public static String lastseen(Context context, long time) { + if (time == 0) { + return context.getString(R.string.never_seen); + } + long difference = (System.currentTimeMillis() - time) / 1000; + if (difference < 60) { + return context.getString(R.string.last_seen_now); + } else if (difference < 60 * 2) { + return context.getString(R.string.last_seen_min); + } else if (difference < 60 * 60) { + return context.getString(R.string.last_seen_mins, + Math.round(difference / 60.0)); + } else if (difference < 60 * 60 * 2) { + return context.getString(R.string.last_seen_hour); + } else if (difference < 60 * 60 * 24) { + return context.getString(R.string.last_seen_hours, + Math.round(difference / (60.0 * 60.0))); + } else if (difference < 60 * 60 * 48) { + return context.getString(R.string.last_seen_day); + } else { + return context.getString(R.string.last_seen_days, + Math.round(difference / (60.0 * 60.0 * 24.0))); + } + } + + public static void showErrorNotification(Context context, + List accounts) { + NotificationManager mNotificationManager = (NotificationManager) context + .getSystemService(Context.NOTIFICATION_SERVICE); + List accountsWproblems = new ArrayList(); + for (Account account : accounts) { + if (account.hasErrorStatus()) { + accountsWproblems.add(account); + } + } + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder( + context); + if (accountsWproblems.size() == 0) { + mNotificationManager.cancel(1111); + return; + } else if (accountsWproblems.size() == 1) { + mBuilder.setContentTitle(context + .getString(R.string.problem_connecting_to_account)); + mBuilder.setContentText(accountsWproblems.get(0).getJid()); + } else { + mBuilder.setContentTitle(context + .getString(R.string.problem_connecting_to_accounts)); + mBuilder.setContentText(context.getString(R.string.touch_to_fix)); + } + mBuilder.setOngoing(true); + mBuilder.setLights(0xffffffff, 2000, 4000); + mBuilder.setSmallIcon(R.drawable.ic_notification); + TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + stackBuilder.addParentStack(ConversationActivity.class); + + Intent manageAccountsIntent = new Intent(context, + ManageAccountActivity.class); + stackBuilder.addNextIntent(manageAccountsIntent); + + PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, + PendingIntent.FLAG_UPDATE_CURRENT); + + mBuilder.setContentIntent(resultPendingIntent); + Notification notification = mBuilder.build(); + mNotificationManager.notify(1111, notification); + } + + @SuppressLint("InflateParams") + public static AlertDialog getVerifyFingerprintDialog( + final ConversationActivity activity, + final Conversation conversation, final View msg) { + final Contact contact = conversation.getContact(); + final Account account = conversation.getAccount(); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle("Verify fingerprint"); + LayoutInflater inflater = activity.getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_verify_otr, null); + TextView jid = (TextView) view.findViewById(R.id.verify_otr_jid); + TextView fingerprint = (TextView) view + .findViewById(R.id.verify_otr_fingerprint); + TextView yourprint = (TextView) view + .findViewById(R.id.verify_otr_yourprint); + + jid.setText(contact.getJid()); + fingerprint.setText(conversation.getOtrFingerprint()); + yourprint.setText(account.getOtrFingerprint()); + builder.setNegativeButton("Cancel", null); + builder.setPositiveButton("Verify", new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + contact.addOtrFingerprint(conversation.getOtrFingerprint()); + msg.setVisibility(View.GONE); + activity.xmppConnectionService.syncRosterToDisk(account); + } + }); + builder.setView(view); + return builder.create(); + } + + private final static class EmoticonPattern { + Pattern pattern; + String replacement; + + EmoticonPattern(String ascii, int unicode) { + this.pattern = Pattern.compile("(?<=(^|\\s))" + ascii + + "(?=(\\s|$))"); + this.replacement = new String(new int[] { unicode, }, 0, 1); + } + + String replaceAll(String body) { + return pattern.matcher(body).replaceAll(replacement); + } + } + + private static final EmoticonPattern[] patterns = new EmoticonPattern[] { + new EmoticonPattern(":-?D", 0x1f600), + new EmoticonPattern("\\^\\^", 0x1f601), + new EmoticonPattern(":'D", 0x1f602), + new EmoticonPattern("\\]-?D", 0x1f608), + new EmoticonPattern(";-?\\)", 0x1f609), + new EmoticonPattern(":-?\\)", 0x1f60a), + new EmoticonPattern("[B8]-?\\)", 0x1f60e), + new EmoticonPattern(":-?\\|", 0x1f610), + new EmoticonPattern(":-?[/\\\\]", 0x1f615), + new EmoticonPattern(":-?\\*", 0x1f617), + new EmoticonPattern(":-?[Ppb]", 0x1f61b), + new EmoticonPattern(":-?\\(", 0x1f61e), + new EmoticonPattern(":-?[0Oo]", 0x1f62e), + new EmoticonPattern("\\\\o/", 0x1F631), }; + + public static String transformAsciiEmoticons(String body) { + if (body != null) { + for (EmoticonPattern p : patterns) { + body = p.replaceAll(body); + } + body = body.trim(); + } + return body; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/Validator.java b/conversations/src/main/java/eu/siacs/conversations/utils/Validator.java new file mode 100644 index 000000000..00130fa21 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/Validator.java @@ -0,0 +1,14 @@ +package eu.siacs.conversations.utils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Validator { + public static final Pattern VALID_JID = Pattern.compile( + "^[^@/<>'\"\\s]+@[^@/<>'\"\\s]+$", Pattern.CASE_INSENSITIVE); + + public static boolean isValidJid(String jid) { + Matcher matcher = VALID_JID.matcher(jid); + return matcher.find(); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/XmlHelper.java b/conversations/src/main/java/eu/siacs/conversations/utils/XmlHelper.java new file mode 100644 index 000000000..4dee07cf7 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/XmlHelper.java @@ -0,0 +1,12 @@ +package eu.siacs.conversations.utils; + +public class XmlHelper { + public static String encodeEntities(String content) { + content = content.replace("&", "&"); + content = content.replace("<", "<"); + content = content.replace(">", ">"); + content = content.replace("\"", """); + content = content.replace("'", "'"); + return content; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibInputStream.java b/conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibInputStream.java new file mode 100644 index 000000000..b777c10c8 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibInputStream.java @@ -0,0 +1,54 @@ +package eu.siacs.conversations.utils.zlib; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +/** + * ZLibInputStream is a zlib and input stream compatible version of an + * InflaterInputStream. This class solves the incompatibility between + * {@link InputStream#available()} and {@link InflaterInputStream#available()}. + */ +public class ZLibInputStream extends InflaterInputStream { + + /** + * Construct a ZLibInputStream, reading data from the underlying stream. + * + * @param is + * The {@code InputStream} to read data from. + * @throws IOException + * If an {@code IOException} occurs. + */ + public ZLibInputStream(InputStream is) throws IOException { + super(is, new Inflater(), 512); + } + + /** + * Provide a more InputStream compatible version of available. A return + * value of 1 means that it is likly to read one byte without blocking, 0 + * means that the system is known to block for more input. + * + * @return 0 if no data is available, 1 otherwise + * @throws IOException + */ + @Override + public int available() throws IOException { + /* + * This is one of the funny code blocks. InflaterInputStream.available + * violates the contract of InputStream.available, which breaks kXML2. + * + * I'm not sure who's to blame, oracle/sun for a broken api or the + * google guys for mixing a sun bug with a xml reader that can't handle + * it.... + * + * Anyway, this simple if breaks suns distorted reality, but helps to + * use the api as intended. + */ + if (inf.needsInput()) { + return 0; + } + return super.available(); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibOutputStream.java b/conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibOutputStream.java new file mode 100644 index 000000000..8b3f5e681 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/utils/zlib/ZLibOutputStream.java @@ -0,0 +1,95 @@ +package eu.siacs.conversations.utils.zlib; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.NoSuchAlgorithmException; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +/** + *

+ * Android 2.2 includes Java7 FLUSH_SYNC option, which will be used by this + * Implementation, preferable via reflection. The @hide was remove in API level + * 19. This class might thus go away in the future. + *

+ *

+ * Please use {@link ZLibOutputStream#SUPPORTED} to check for flush + * compatibility. + *

+ */ +public class ZLibOutputStream extends DeflaterOutputStream { + + /** + * The reflection based flush method. + */ + + private final static Method method; + /** + * SUPPORTED is true if a flush compatible method exists. + */ + public final static boolean SUPPORTED; + + /** + * Static block to initialize {@link #SUPPORTED} and {@link #method}. + */ + static { + Method m = null; + try { + m = Deflater.class.getMethod("deflate", byte[].class, int.class, + int.class, int.class); + } catch (SecurityException e) { + } catch (NoSuchMethodException e) { + } + method = m; + SUPPORTED = (method != null); + } + + /** + * Create a new ZLib compatible output stream wrapping the given low level + * stream. ZLib compatiblity means we will send a zlib header. + * + * @param os + * OutputStream The underlying stream. + * @throws IOException + * In case of a lowlevel transfer problem. + * @throws NoSuchAlgorithmException + * In case of a {@link Deflater} error. + */ + public ZLibOutputStream(OutputStream os) throws IOException, + NoSuchAlgorithmException { + super(os, new Deflater(Deflater.BEST_COMPRESSION)); + } + + /** + * Flush the given stream, preferring Java7 FLUSH_SYNC if available. + * + * @throws IOException + * In case of a lowlevel exception. + */ + @Override + public void flush() throws IOException { + if (!SUPPORTED) { + super.flush(); + return; + } + try { + int count = 0; + do { + count = (Integer) method.invoke(def, buf, 0, buf.length, 3); + if (count > 0) { + out.write(buf, 0, count); + } + } while (count > 0); + } catch (IllegalArgumentException e) { + throw new IOException("Can't flush"); + } catch (IllegalAccessException e) { + throw new IOException("Can't flush"); + } catch (InvocationTargetException e) { + throw new IOException("Can't flush"); + } + super.flush(); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xml/Element.java b/conversations/src/main/java/eu/siacs/conversations/xml/Element.java new file mode 100644 index 000000000..4e11ee2cd --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xml/Element.java @@ -0,0 +1,148 @@ +package eu.siacs.conversations.xml; + +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.List; + +import eu.siacs.conversations.utils.XmlHelper; + +public class Element { + protected String name; + protected Hashtable attributes = new Hashtable(); + protected String content; + protected List children = new ArrayList(); + + public Element(String name) { + this.name = name; + } + + public Element addChild(Element child) { + this.content = null; + children.add(child); + return child; + } + + public Element addChild(String name) { + this.content = null; + Element child = new Element(name); + children.add(child); + return child; + } + + public Element addChild(String name, String xmlns) { + this.content = null; + Element child = new Element(name); + child.setAttribute("xmlns", xmlns); + children.add(child); + return child; + } + + public Element setContent(String content) { + this.content = content; + this.children.clear(); + return this; + } + + public Element findChild(String name) { + for (Element child : this.children) { + if (child.getName().equals(name)) { + return child; + } + } + return null; + } + + public Element findChild(String name, String xmlns) { + for (Element child : this.children) { + if (child.getName().equals(name) + && (child.getAttribute("xmlns").equals(xmlns))) { + return child; + } + } + return null; + } + + public boolean hasChild(String name) { + return findChild(name) != null; + } + + public boolean hasChild(String name, String xmlns) { + return findChild(name, xmlns) != null; + } + + public List getChildren() { + return this.children; + } + + public Element setChildren(List children) { + this.children = children; + return this; + } + + public String getContent() { + return content; + } + + public Element setAttribute(String name, String value) { + if (name != null && value != null) { + this.attributes.put(name, value); + } + return this; + } + + public Element setAttributes(Hashtable attributes) { + this.attributes = attributes; + return this; + } + + public String getAttribute(String name) { + if (this.attributes.containsKey(name)) { + return this.attributes.get(name); + } else { + return null; + } + } + + public Hashtable getAttributes() { + return this.attributes; + } + + public String toString() { + StringBuilder elementOutput = new StringBuilder(); + if ((content == null) && (children.size() == 0)) { + Tag emptyTag = Tag.empty(name); + emptyTag.setAtttributes(this.attributes); + elementOutput.append(emptyTag.toString()); + } else { + Tag startTag = Tag.start(name); + startTag.setAtttributes(this.attributes); + elementOutput.append(startTag); + if (content != null) { + elementOutput.append(XmlHelper.encodeEntities(content)); + } else { + for (Element child : children) { + elementOutput.append(child.toString()); + } + } + Tag endTag = Tag.end(name); + elementOutput.append(endTag); + } + return elementOutput.toString(); + } + + public String getName() { + return name; + } + + public void clearChildren() { + this.children.clear(); + } + + public void setAttribute(String name, long value) { + this.setAttribute(name, Long.toString(value)); + } + + public void setAttribute(String name, int value) { + this.setAttribute(name, Integer.toString(value)); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xml/Tag.java b/conversations/src/main/java/eu/siacs/conversations/xml/Tag.java new file mode 100644 index 000000000..b9ef979ff --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xml/Tag.java @@ -0,0 +1,104 @@ +package eu.siacs.conversations.xml; + +import java.util.Hashtable; +import java.util.Iterator; +import java.util.Map.Entry; +import java.util.Set; + +import eu.siacs.conversations.utils.XmlHelper; + +public class Tag { + public static final int NO = -1; + public static final int START = 0; + public static final int END = 1; + public static final int EMPTY = 2; + + protected int type; + protected String name; + protected Hashtable attributes = new Hashtable(); + + protected Tag(int type, String name) { + this.type = type; + this.name = name; + } + + public static Tag no(String text) { + return new Tag(NO, text); + } + + public static Tag start(String name) { + return new Tag(START, name); + } + + public static Tag end(String name) { + return new Tag(END, name); + } + + public static Tag empty(String name) { + return new Tag(EMPTY, name); + } + + public String getName() { + return name; + } + + public String getAttribute(String attrName) { + return this.attributes.get(attrName); + } + + public Tag setAttribute(String attrName, String attrValue) { + this.attributes.put(attrName, attrValue); + return this; + } + + public Tag setAtttributes(Hashtable attributes) { + this.attributes = attributes; + return this; + } + + public boolean isStart(String needle) { + if (needle == null) + return false; + return (this.type == START) && (needle.equals(this.name)); + } + + public boolean isEnd(String needle) { + if (needle == null) + return false; + return (this.type == END) && (needle.equals(this.name)); + } + + public boolean isNo() { + return (this.type == NO); + } + + public String toString() { + StringBuilder tagOutput = new StringBuilder(); + tagOutput.append('<'); + if (type == END) { + tagOutput.append('/'); + } + tagOutput.append(name); + if (type != END) { + Set> attributeSet = attributes.entrySet(); + Iterator> it = attributeSet.iterator(); + while (it.hasNext()) { + Entry entry = it.next(); + tagOutput.append(' '); + tagOutput.append(entry.getKey()); + tagOutput.append("=\""); + tagOutput.append(XmlHelper.encodeEntities(entry.getValue())); + tagOutput.append('"'); + } + } + if (type == EMPTY) { + tagOutput.append('/'); + } + tagOutput.append('>'); + return tagOutput.toString(); + } + + public Hashtable getAttributes() { + return this.attributes; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xml/TagWriter.java b/conversations/src/main/java/eu/siacs/conversations/xml/TagWriter.java new file mode 100644 index 000000000..f11c18464 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xml/TagWriter.java @@ -0,0 +1,114 @@ +package eu.siacs.conversations.xml; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.concurrent.LinkedBlockingQueue; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class TagWriter { + + private OutputStream plainOutputStream; + private OutputStreamWriter outputStream; + private boolean finshed = false; + private LinkedBlockingQueue writeQueue = new LinkedBlockingQueue(); + private Thread asyncStanzaWriter = new Thread() { + private boolean shouldStop = false; + + @Override + public void run() { + while (!shouldStop) { + if ((finshed) && (writeQueue.size() == 0)) { + return; + } + try { + AbstractStanza output = writeQueue.take(); + if (outputStream == null) { + shouldStop = true; + } else { + outputStream.write(output.toString()); + outputStream.flush(); + } + } catch (IOException e) { + shouldStop = true; + } catch (InterruptedException e) { + shouldStop = true; + } + } + } + }; + + public TagWriter() { + } + + public void setOutputStream(OutputStream out) throws IOException { + if (out == null) { + throw new IOException(); + } + this.plainOutputStream = out; + this.outputStream = new OutputStreamWriter(out); + } + + public OutputStream getOutputStream() throws IOException { + if (this.plainOutputStream == null) { + throw new IOException(); + } + return this.plainOutputStream; + } + + public TagWriter beginDocument() throws IOException { + if (outputStream == null) { + throw new IOException("output stream was null"); + } + outputStream.write(""); + outputStream.flush(); + return this; + } + + public TagWriter writeTag(Tag tag) throws IOException { + if (outputStream == null) { + throw new IOException("output stream was null"); + } + outputStream.write(tag.toString()); + outputStream.flush(); + return this; + } + + public TagWriter writeElement(Element element) throws IOException { + if (outputStream == null) { + throw new IOException("output stream was null"); + } + outputStream.write(element.toString()); + outputStream.flush(); + return this; + } + + public TagWriter writeStanzaAsync(AbstractStanza stanza) { + if (finshed) { + return this; + } else { + if (!asyncStanzaWriter.isAlive()) { + try { + asyncStanzaWriter.start(); + } catch (IllegalThreadStateException e) { + // already started + } + } + writeQueue.add(stanza); + return this; + } + } + + public void finish() { + this.finshed = true; + } + + public boolean finished() { + return (this.writeQueue.size() == 0); + } + + public boolean isActive() { + return outputStream != null; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xml/XmlReader.java b/conversations/src/main/java/eu/siacs/conversations/xml/XmlReader.java new file mode 100644 index 000000000..52d3d46ac --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xml/XmlReader.java @@ -0,0 +1,141 @@ +package eu.siacs.conversations.xml; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import eu.siacs.conversations.Config; + +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.util.Log; +import android.util.Xml; + +public class XmlReader { + private XmlPullParser parser; + private PowerManager.WakeLock wakeLock; + private InputStream is; + + public XmlReader(WakeLock wakeLock) { + this.parser = Xml.newPullParser(); + try { + this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, + true); + } catch (XmlPullParserException e) { + Log.d(Config.LOGTAG, "error setting namespace feature on parser"); + } + this.wakeLock = wakeLock; + } + + public void setInputStream(InputStream inputStream) throws IOException { + if (inputStream == null) { + throw new IOException(); + } + this.is = inputStream; + try { + parser.setInput(new InputStreamReader(this.is)); + } catch (XmlPullParserException e) { + throw new IOException("error resetting parser"); + } + } + + public InputStream getInputStream() throws IOException { + if (this.is == null) { + throw new IOException(); + } + return is; + } + + public void reset() throws IOException { + if (this.is == null) { + throw new IOException(); + } + try { + parser.setInput(new InputStreamReader(this.is)); + } catch (XmlPullParserException e) { + throw new IOException("error resetting parser"); + } + } + + public Tag readTag() throws XmlPullParserException, IOException { + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + try { + while (this.is != null + && parser.next() != XmlPullParser.END_DOCUMENT) { + wakeLock.acquire(); + if (parser.getEventType() == XmlPullParser.START_TAG) { + Tag tag = Tag.start(parser.getName()); + for (int i = 0; i < parser.getAttributeCount(); ++i) { + tag.setAttribute(parser.getAttributeName(i), + parser.getAttributeValue(i)); + } + String xmlns = parser.getNamespace(); + if (xmlns != null) { + tag.setAttribute("xmlns", xmlns); + } + return tag; + } else if (parser.getEventType() == XmlPullParser.END_TAG) { + Tag tag = Tag.end(parser.getName()); + return tag; + } else if (parser.getEventType() == XmlPullParser.TEXT) { + Tag tag = Tag.no(parser.getText()); + return tag; + } + } + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + } catch (ArrayIndexOutOfBoundsException e) { + throw new IOException( + "xml parser mishandled ArrayIndexOufOfBounds", e); + } catch (StringIndexOutOfBoundsException e) { + throw new IOException( + "xml parser mishandled StringIndexOufOfBounds", e); + } catch (NullPointerException e) { + throw new IOException("xml parser mishandled NullPointerException", + e); + } catch (IndexOutOfBoundsException e) { + throw new IOException("xml parser mishandled IndexOutOfBound", e); + } + return null; + } + + public Element readElement(Tag currentTag) throws XmlPullParserException, + IOException { + Element element = new Element(currentTag.getName()); + element.setAttributes(currentTag.getAttributes()); + Tag nextTag = this.readTag(); + if (nextTag == null) { + throw new IOException("unterupted mid tag"); + } + if (nextTag.isNo()) { + element.setContent(nextTag.getName()); + nextTag = this.readTag(); + if (nextTag == null) { + throw new IOException("unterupted mid tag"); + } + } + while (!nextTag.isEnd(element.getName())) { + if (!nextTag.isNo()) { + Element child = this.readElement(nextTag); + element.addChild(child); + } + nextTag = this.readTag(); + if (nextTag == null) { + throw new IOException("unterupted mid tag"); + } + } + return element; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnBindListener.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnBindListener.java new file mode 100644 index 000000000..f09cf33dd --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnBindListener.java @@ -0,0 +1,7 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Account; + +public interface OnBindListener { + public void onBind(Account account); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnContactStatusChanged.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnContactStatusChanged.java new file mode 100644 index 000000000..849e8e764 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnContactStatusChanged.java @@ -0,0 +1,7 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Contact; + +public interface OnContactStatusChanged { + public void onContactStatusChanged(Contact contact, boolean online); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnIqPacketReceived.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnIqPacketReceived.java new file mode 100644 index 000000000..a4cff9863 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnIqPacketReceived.java @@ -0,0 +1,8 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public interface OnIqPacketReceived extends PacketReceived { + public void onIqPacketReceived(Account account, IqPacket packet); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessageAcknowledged.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessageAcknowledged.java new file mode 100644 index 000000000..5f670d933 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessageAcknowledged.java @@ -0,0 +1,7 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Account; + +public interface OnMessageAcknowledged { + public void onMessageAcknowledged(Account account, String id); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessagePacketReceived.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessagePacketReceived.java new file mode 100644 index 000000000..325e945f0 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnMessagePacketReceived.java @@ -0,0 +1,8 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; + +public interface OnMessagePacketReceived extends PacketReceived { + public void onMessagePacketReceived(Account account, MessagePacket packet); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnPresencePacketReceived.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnPresencePacketReceived.java new file mode 100644 index 000000000..95c1acfcc --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnPresencePacketReceived.java @@ -0,0 +1,8 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xmpp.stanzas.PresencePacket; + +public interface OnPresencePacketReceived extends PacketReceived { + public void onPresencePacketReceived(Account account, PresencePacket packet); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/OnStatusChanged.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnStatusChanged.java new file mode 100644 index 000000000..ad1d98cb9 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/OnStatusChanged.java @@ -0,0 +1,7 @@ +package eu.siacs.conversations.xmpp; + +import eu.siacs.conversations.entities.Account; + +public interface OnStatusChanged { + public void onStatusChanged(Account account); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/PacketReceived.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/PacketReceived.java new file mode 100644 index 000000000..d4502d734 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/PacketReceived.java @@ -0,0 +1,5 @@ +package eu.siacs.conversations.xmpp; + +public abstract interface PacketReceived { + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java new file mode 100644 index 000000000..903dc59d2 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java @@ -0,0 +1,1130 @@ +package eu.siacs.conversations.xmpp; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.LinkedList; +import java.util.List; +import java.util.Map.Entry; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import javax.net.ssl.X509TrustManager; + +import org.apache.http.conn.ssl.StrictHostnameVerifier; +import org.xmlpull.v1.XmlPullParserException; + +import de.duenndns.ssl.MemorizingTrustManager; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.os.SystemClock; +import android.preference.PreferenceManager; +import android.util.Log; +import android.util.SparseArray; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.utils.DNSHelper; +import eu.siacs.conversations.utils.zlib.ZLibOutputStream; +import eu.siacs.conversations.utils.zlib.ZLibInputStream; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Tag; +import eu.siacs.conversations.xml.TagWriter; +import eu.siacs.conversations.xml.XmlReader; +import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; +import eu.siacs.conversations.xmpp.stanzas.PresencePacket; +import eu.siacs.conversations.xmpp.stanzas.csi.ActivePacket; +import eu.siacs.conversations.xmpp.stanzas.csi.InactivePacket; +import eu.siacs.conversations.xmpp.stanzas.streammgmt.AckPacket; +import eu.siacs.conversations.xmpp.stanzas.streammgmt.EnablePacket; +import eu.siacs.conversations.xmpp.stanzas.streammgmt.RequestPacket; +import eu.siacs.conversations.xmpp.stanzas.streammgmt.ResumePacket; + +public class XmppConnection implements Runnable { + + protected Account account; + + private WakeLock wakeLock; + + private SecureRandom mRandom; + + private Socket socket; + private XmlReader tagReader; + private TagWriter tagWriter; + + private Features features = new Features(this); + + private boolean shouldBind = true; + private boolean shouldAuthenticate = true; + private Element streamFeatures; + private HashMap> disco = new HashMap>(); + + private String streamId = null; + private int smVersion = 3; + private SparseArray messageReceipts = new SparseArray(); + + private boolean usingCompression = false; + private boolean usingEncryption = false; + + private int stanzasReceived = 0; + private int stanzasSent = 0; + + private long lastPaketReceived = 0; + private long lastPingSent = 0; + private long lastConnect = 0; + private long lastSessionStarted = 0; + + private int attempt = 0; + + private static final int PACKET_IQ = 0; + private static final int PACKET_MESSAGE = 1; + private static final int PACKET_PRESENCE = 2; + + private Hashtable packetCallbacks = new Hashtable(); + private OnPresencePacketReceived presenceListener = null; + private OnJinglePacketReceived jingleListener = null; + private OnIqPacketReceived unregisteredIqListener = null; + private OnMessagePacketReceived messageListener = null; + private OnStatusChanged statusListener = null; + private OnBindListener bindListener = null; + private OnMessageAcknowledged acknowledgedListener = null; + private MemorizingTrustManager mMemorizingTrustManager; + private final Context applicationContext; + + public XmppConnection(Account account, XmppConnectionService service) { + this.mRandom = service.getRNG(); + this.mMemorizingTrustManager = service.getMemorizingTrustManager(); + this.account = account; + this.wakeLock = service.getPowerManager().newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, account.getJid()); + tagWriter = new TagWriter(); + applicationContext = service.getApplicationContext(); + } + + protected void changeStatus(int nextStatus) { + if (account.getStatus() != nextStatus) { + if ((nextStatus == Account.STATUS_OFFLINE) + && (account.getStatus() != Account.STATUS_CONNECTING) + && (account.getStatus() != Account.STATUS_ONLINE) + && (account.getStatus() != Account.STATUS_DISABLED)) { + return; + } + if (nextStatus == Account.STATUS_ONLINE) { + this.attempt = 0; + } + account.setStatus(nextStatus); + if (statusListener != null) { + statusListener.onStatusChanged(account); + } + } + } + + protected void connect() { + Log.d(Config.LOGTAG, account.getJid() + ": connecting"); + usingCompression = false; + usingEncryption = false; + lastConnect = SystemClock.elapsedRealtime(); + lastPingSent = SystemClock.elapsedRealtime(); + this.attempt++; + try { + shouldAuthenticate = shouldBind = !account + .isOptionSet(Account.OPTION_REGISTER); + tagReader = new XmlReader(wakeLock); + tagWriter = new TagWriter(); + packetCallbacks.clear(); + this.changeStatus(Account.STATUS_CONNECTING); + Bundle namePort = DNSHelper.getSRVRecord(account.getServer()); + if ("timeout".equals(namePort.getString("error"))) { + Log.d(Config.LOGTAG, account.getJid() + ": dns timeout"); + this.changeStatus(Account.STATUS_OFFLINE); + return; + } + String srvRecordServer = namePort.getString("name"); + String srvIpServer = namePort.getString("ipv4"); + int srvRecordPort = namePort.getInt("port"); + if (srvRecordServer != null) { + if (srvIpServer != null) { + Log.d(Config.LOGTAG, account.getJid() + + ": using values from dns " + srvRecordServer + + "[" + srvIpServer + "]:" + srvRecordPort); + socket = new Socket(srvIpServer, srvRecordPort); + } else { + boolean socketError = true; + int srvIndex = 0; + while (socketError + && namePort.containsKey("name" + srvIndex)) { + try { + srvRecordServer = namePort.getString("name" + + srvIndex); + srvRecordPort = namePort.getInt("port" + srvIndex); + Log.d(Config.LOGTAG, account.getJid() + + ": using values from dns " + + srvRecordServer + ":" + srvRecordPort); + socket = new Socket(srvRecordServer, srvRecordPort); + socketError = false; + } catch (UnknownHostException e) { + srvIndex++; + if (!namePort.containsKey("name" + srvIndex)) { + throw e; + } + } catch (IOException e) { + srvIndex++; + if (!namePort.containsKey("name" + srvIndex)) { + throw e; + } + } + } + } + } else if (namePort.containsKey("error") + && "nosrv".equals(namePort.getString("error", null))) { + socket = new Socket(account.getServer(), 5222); + } else { + Log.d(Config.LOGTAG, account.getJid() + + ": timeout in DNS resolution"); + changeStatus(Account.STATUS_OFFLINE); + return; + } + OutputStream out = socket.getOutputStream(); + tagWriter.setOutputStream(out); + InputStream in = socket.getInputStream(); + tagReader.setInputStream(in); + tagWriter.beginDocument(); + sendStartStream(); + Tag nextTag; + while ((nextTag = tagReader.readTag()) != null) { + if (nextTag.isStart("stream")) { + processStream(nextTag); + break; + } else { + Log.d(Config.LOGTAG, + "found unexpected tag: " + nextTag.getName()); + return; + } + } + if (socket.isConnected()) { + socket.close(); + } + } catch (UnknownHostException e) { + this.changeStatus(Account.STATUS_SERVER_NOT_FOUND); + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + return; + } catch (IOException e) { + this.changeStatus(Account.STATUS_OFFLINE); + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + return; + } catch (NoSuchAlgorithmException e) { + this.changeStatus(Account.STATUS_OFFLINE); + Log.d(Config.LOGTAG, "compression exception " + e.getMessage()); + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + return; + } catch (XmlPullParserException e) { + this.changeStatus(Account.STATUS_OFFLINE); + Log.d(Config.LOGTAG, "xml exception " + e.getMessage()); + if (wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (RuntimeException re) { + } + } + return; + } + + } + + @Override + public void run() { + connect(); + } + + private void processStream(Tag currentTag) throws XmlPullParserException, + IOException, NoSuchAlgorithmException { + Tag nextTag = tagReader.readTag(); + while ((nextTag != null) && (!nextTag.isEnd("stream"))) { + if (nextTag.isStart("error")) { + processStreamError(nextTag); + } else if (nextTag.isStart("features")) { + processStreamFeatures(nextTag); + } else if (nextTag.isStart("proceed")) { + switchOverToTls(nextTag); + } else if (nextTag.isStart("compressed")) { + switchOverToZLib(nextTag); + } else if (nextTag.isStart("success")) { + Log.d(Config.LOGTAG, account.getJid() + ": logged in"); + tagReader.readTag(); + tagReader.reset(); + sendStartStream(); + processStream(tagReader.readTag()); + break; + } else if (nextTag.isStart("failure")) { + tagReader.readElement(nextTag); + changeStatus(Account.STATUS_UNAUTHORIZED); + } else if (nextTag.isStart("challenge")) { + String challange = tagReader.readElement(nextTag).getContent(); + Element response = new Element("response"); + response.setAttribute("xmlns", + "urn:ietf:params:xml:ns:xmpp-sasl"); + response.setContent(CryptoHelper.saslDigestMd5(account, + challange, mRandom)); + tagWriter.writeElement(response); + } else if (nextTag.isStart("enabled")) { + Element enabled = tagReader.readElement(nextTag); + if ("true".equals(enabled.getAttribute("resume"))) { + this.streamId = enabled.getAttribute("id"); + Log.d(Config.LOGTAG, account.getJid() + + ": stream managment(" + smVersion + + ") enabled (resumable)"); + } else { + Log.d(Config.LOGTAG, account.getJid() + + ": stream managment(" + smVersion + ") enabled"); + } + this.lastSessionStarted = SystemClock.elapsedRealtime(); + this.stanzasReceived = 0; + RequestPacket r = new RequestPacket(smVersion); + tagWriter.writeStanzaAsync(r); + } else if (nextTag.isStart("resumed")) { + lastPaketReceived = SystemClock.elapsedRealtime(); + Element resumed = tagReader.readElement(nextTag); + String h = resumed.getAttribute("h"); + try { + int serverCount = Integer.parseInt(h); + if (serverCount != stanzasSent) { + Log.d(Config.LOGTAG, account.getJid() + + ": session resumed with lost packages"); + stanzasSent = serverCount; + } else { + Log.d(Config.LOGTAG, account.getJid() + + ": session resumed"); + } + if (acknowledgedListener != null) { + for (int i = 0; i < messageReceipts.size(); ++i) { + if (serverCount >= messageReceipts.keyAt(i)) { + acknowledgedListener.onMessageAcknowledged( + account, messageReceipts.valueAt(i)); + } + } + } + messageReceipts.clear(); + } catch (NumberFormatException e) { + + } + sendInitialPing(); + + } else if (nextTag.isStart("r")) { + tagReader.readElement(nextTag); + AckPacket ack = new AckPacket(this.stanzasReceived, smVersion); + tagWriter.writeStanzaAsync(ack); + } else if (nextTag.isStart("a")) { + Element ack = tagReader.readElement(nextTag); + lastPaketReceived = SystemClock.elapsedRealtime(); + int serverSequence = Integer.parseInt(ack.getAttribute("h")); + String msgId = this.messageReceipts.get(serverSequence); + if (msgId != null) { + if (this.acknowledgedListener != null) { + this.acknowledgedListener.onMessageAcknowledged( + account, msgId); + } + this.messageReceipts.remove(serverSequence); + } + } else if (nextTag.isStart("failed")) { + tagReader.readElement(nextTag); + Log.d(Config.LOGTAG, account.getJid() + ": resumption failed"); + streamId = null; + if (account.getStatus() != Account.STATUS_ONLINE) { + sendBindRequest(); + } + } else if (nextTag.isStart("iq")) { + processIq(nextTag); + } else if (nextTag.isStart("message")) { + processMessage(nextTag); + } else if (nextTag.isStart("presence")) { + processPresence(nextTag); + } + nextTag = tagReader.readTag(); + } + if (account.getStatus() == Account.STATUS_ONLINE) { + account.setStatus(Account.STATUS_OFFLINE); + if (statusListener != null) { + statusListener.onStatusChanged(account); + } + } + } + + private void sendInitialPing() { + Log.d(Config.LOGTAG, account.getJid() + ": sending intial ping"); + IqPacket iq = new IqPacket(IqPacket.TYPE_GET); + iq.setFrom(account.getFullJid()); + iq.addChild("ping", "urn:xmpp:ping"); + this.sendIqPacket(iq, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + Log.d(Config.LOGTAG, account.getJid() + + ": online with resource " + account.getResource()); + changeStatus(Account.STATUS_ONLINE); + } + }); + } + + private Element processPacket(Tag currentTag, int packetType) + throws XmlPullParserException, IOException { + Element element; + switch (packetType) { + case PACKET_IQ: + element = new IqPacket(); + break; + case PACKET_MESSAGE: + element = new MessagePacket(); + break; + case PACKET_PRESENCE: + element = new PresencePacket(); + break; + default: + return null; + } + element.setAttributes(currentTag.getAttributes()); + Tag nextTag = tagReader.readTag(); + if (nextTag == null) { + throw new IOException("interrupted mid tag"); + } + while (!nextTag.isEnd(element.getName())) { + if (!nextTag.isNo()) { + Element child = tagReader.readElement(nextTag); + String type = currentTag.getAttribute("type"); + if (packetType == PACKET_IQ + && "jingle".equals(child.getName()) + && ("set".equalsIgnoreCase(type) || "get" + .equalsIgnoreCase(type))) { + element = new JinglePacket(); + element.setAttributes(currentTag.getAttributes()); + } + element.addChild(child); + } + nextTag = tagReader.readTag(); + if (nextTag == null) { + throw new IOException("interrupted mid tag"); + } + } + ++stanzasReceived; + lastPaketReceived = SystemClock.elapsedRealtime(); + return element; + } + + private void processIq(Tag currentTag) throws XmlPullParserException, + IOException { + IqPacket packet = (IqPacket) processPacket(currentTag, PACKET_IQ); + + if (packet.getId() == null) { + return; // an iq packet without id is definitely invalid + } + + if (packet instanceof JinglePacket) { + if (this.jingleListener != null) { + this.jingleListener.onJinglePacketReceived(account, + (JinglePacket) packet); + } + } else { + if (packetCallbacks.containsKey(packet.getId())) { + if (packetCallbacks.get(packet.getId()) instanceof OnIqPacketReceived) { + ((OnIqPacketReceived) packetCallbacks.get(packet.getId())) + .onIqPacketReceived(account, packet); + } + + packetCallbacks.remove(packet.getId()); + } else if ((packet.getType() == IqPacket.TYPE_GET || packet + .getType() == IqPacket.TYPE_SET) + && this.unregisteredIqListener != null) { + this.unregisteredIqListener.onIqPacketReceived(account, packet); + } + } + } + + private void processMessage(Tag currentTag) throws XmlPullParserException, + IOException { + MessagePacket packet = (MessagePacket) processPacket(currentTag, + PACKET_MESSAGE); + String id = packet.getAttribute("id"); + if ((id != null) && (packetCallbacks.containsKey(id))) { + if (packetCallbacks.get(id) instanceof OnMessagePacketReceived) { + ((OnMessagePacketReceived) packetCallbacks.get(id)) + .onMessagePacketReceived(account, packet); + } + packetCallbacks.remove(id); + } else if (this.messageListener != null) { + this.messageListener.onMessagePacketReceived(account, packet); + } + } + + private void processPresence(Tag currentTag) throws XmlPullParserException, + IOException { + PresencePacket packet = (PresencePacket) processPacket(currentTag, + PACKET_PRESENCE); + String id = packet.getAttribute("id"); + if ((id != null) && (packetCallbacks.containsKey(id))) { + if (packetCallbacks.get(id) instanceof OnPresencePacketReceived) { + ((OnPresencePacketReceived) packetCallbacks.get(id)) + .onPresencePacketReceived(account, packet); + } + packetCallbacks.remove(id); + } else if (this.presenceListener != null) { + this.presenceListener.onPresencePacketReceived(account, packet); + } + } + + private void sendCompressionZlib() throws IOException { + Element compress = new Element("compress"); + compress.setAttribute("xmlns", "http://jabber.org/protocol/compress"); + compress.addChild("method").setContent("zlib"); + tagWriter.writeElement(compress); + } + + private void switchOverToZLib(Tag currentTag) + throws XmlPullParserException, IOException, + NoSuchAlgorithmException { + tagReader.readTag(); // read tag close + tagWriter.setOutputStream(new ZLibOutputStream(tagWriter + .getOutputStream())); + tagReader + .setInputStream(new ZLibInputStream(tagReader.getInputStream())); + + sendStartStream(); + Log.d(Config.LOGTAG, account.getJid() + ": compression enabled"); + usingCompression = true; + processStream(tagReader.readTag()); + } + + private void sendStartTLS() throws IOException { + Tag startTLS = Tag.empty("starttls"); + startTLS.setAttribute("xmlns", "urn:ietf:params:xml:ns:xmpp-tls"); + tagWriter.writeTag(startTLS); + } + + private SharedPreferences getPreferences() { + return PreferenceManager + .getDefaultSharedPreferences(applicationContext); + } + + private boolean enableLegacySSL() { + return getPreferences().getBoolean("enable_legacy_ssl", false); + } + + private void switchOverToTls(Tag currentTag) throws XmlPullParserException, + IOException { + tagReader.readTag(); + try { + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, + new X509TrustManager[] { this.mMemorizingTrustManager }, + mRandom); + SSLSocketFactory factory = sc.getSocketFactory(); + + HostnameVerifier verifier = this.mMemorizingTrustManager + .wrapHostnameVerifier(new StrictHostnameVerifier()); + SSLSocket sslSocket = (SSLSocket) factory.createSocket(socket, + socket.getInetAddress().getHostAddress(), socket.getPort(), + true); + + // Support all protocols except legacy SSL. + // The min SDK version prevents us having to worry about SSLv2. In + // future, this may be + // true of SSLv3 as well. + final String[] supportProtocols; + if (enableLegacySSL()) { + supportProtocols = sslSocket.getSupportedProtocols(); + } else { + final List supportedProtocols = new LinkedList( + Arrays.asList(sslSocket.getSupportedProtocols())); + supportedProtocols.remove("SSLv3"); + supportProtocols = new String[supportedProtocols.size()]; + supportedProtocols.toArray(supportProtocols); + } + sslSocket.setEnabledProtocols(supportProtocols); + + if (verifier != null + && !verifier.verify(account.getServer(), + sslSocket.getSession())) { + Log.d(Config.LOGTAG, account.getJid() + + ": host mismatch in TLS connection"); + sslSocket.close(); + throw new IOException(); + } + tagReader.setInputStream(sslSocket.getInputStream()); + tagWriter.setOutputStream(sslSocket.getOutputStream()); + sendStartStream(); + Log.d(Config.LOGTAG, account.getJid() + + ": TLS connection established"); + usingEncryption = true; + processStream(tagReader.readTag()); + sslSocket.close(); + } catch (NoSuchAlgorithmException e1) { + e1.printStackTrace(); + } catch (KeyManagementException e) { + e.printStackTrace(); + } + } + + private void sendSaslAuthPlain() throws IOException { + String saslString = CryptoHelper.saslPlain(account.getUsername(), + account.getPassword()); + Element auth = new Element("auth"); + auth.setAttribute("xmlns", "urn:ietf:params:xml:ns:xmpp-sasl"); + auth.setAttribute("mechanism", "PLAIN"); + auth.setContent(saslString); + tagWriter.writeElement(auth); + } + + private void sendSaslAuthDigestMd5() throws IOException { + Element auth = new Element("auth"); + auth.setAttribute("xmlns", "urn:ietf:params:xml:ns:xmpp-sasl"); + auth.setAttribute("mechanism", "DIGEST-MD5"); + tagWriter.writeElement(auth); + } + + private void processStreamFeatures(Tag currentTag) + throws XmlPullParserException, IOException { + this.streamFeatures = tagReader.readElement(currentTag); + if (this.streamFeatures.hasChild("starttls") && !usingEncryption) { + sendStartTLS(); + } else if (compressionAvailable()) { + sendCompressionZlib(); + } else if (this.streamFeatures.hasChild("register") + && account.isOptionSet(Account.OPTION_REGISTER) + && usingEncryption) { + sendRegistryRequest(); + } else if (!this.streamFeatures.hasChild("register") + && account.isOptionSet(Account.OPTION_REGISTER)) { + changeStatus(Account.STATUS_REGISTRATION_NOT_SUPPORTED); + disconnect(true); + } else if (this.streamFeatures.hasChild("mechanisms") + && shouldAuthenticate && usingEncryption) { + List mechanisms = extractMechanisms(streamFeatures + .findChild("mechanisms")); + if (mechanisms.contains("PLAIN")) { + sendSaslAuthPlain(); + } else if (mechanisms.contains("DIGEST-MD5")) { + sendSaslAuthDigestMd5(); + } + } else if (this.streamFeatures.hasChild("sm", "urn:xmpp:sm:" + + smVersion) + && streamId != null) { + ResumePacket resume = new ResumePacket(this.streamId, + stanzasReceived, smVersion); + this.tagWriter.writeStanzaAsync(resume); + } else if (this.streamFeatures.hasChild("bind") && shouldBind) { + sendBindRequest(); + } else { + Log.d(Config.LOGTAG, account.getJid() + + ": incompatible server. disconnecting"); + disconnect(true); + } + } + + private boolean compressionAvailable() { + if (!this.streamFeatures.hasChild("compression", + "http://jabber.org/features/compress")) + return false; + if (!ZLibOutputStream.SUPPORTED) + return false; + if (!account.isOptionSet(Account.OPTION_USECOMPRESSION)) + return false; + + Element compression = this.streamFeatures.findChild("compression", + "http://jabber.org/features/compress"); + for (Element child : compression.getChildren()) { + if (!"method".equals(child.getName())) + continue; + + if ("zlib".equalsIgnoreCase(child.getContent())) { + return true; + } + } + return false; + } + + private List extractMechanisms(Element stream) { + ArrayList mechanisms = new ArrayList(stream + .getChildren().size()); + for (Element child : stream.getChildren()) { + mechanisms.add(child.getContent()); + } + return mechanisms; + } + + private void sendRegistryRequest() { + IqPacket register = new IqPacket(IqPacket.TYPE_GET); + register.query("jabber:iq:register"); + register.setTo(account.getServer()); + sendIqPacket(register, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + Element instructions = packet.query().findChild("instructions"); + if (packet.query().hasChild("username") + && (packet.query().hasChild("password"))) { + IqPacket register = new IqPacket(IqPacket.TYPE_SET); + Element username = new Element("username") + .setContent(account.getUsername()); + Element password = new Element("password") + .setContent(account.getPassword()); + register.query("jabber:iq:register").addChild(username); + register.query().addChild(password); + sendIqPacket(register, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, + IqPacket packet) { + if (packet.getType() == IqPacket.TYPE_RESULT) { + account.setOption(Account.OPTION_REGISTER, + false); + changeStatus(Account.STATUS_REGISTRATION_SUCCESSFULL); + } else if (packet.hasChild("error") + && (packet.findChild("error") + .hasChild("conflict"))) { + changeStatus(Account.STATUS_REGISTRATION_CONFLICT); + } else { + changeStatus(Account.STATUS_REGISTRATION_FAILED); + Log.d(Config.LOGTAG, packet.toString()); + } + disconnect(true); + } + }); + } else { + changeStatus(Account.STATUS_REGISTRATION_FAILED); + disconnect(true); + Log.d(Config.LOGTAG, account.getJid() + + ": could not register. instructions are" + + instructions.getContent()); + } + } + }); + } + + private void sendBindRequest() throws IOException { + IqPacket iq = new IqPacket(IqPacket.TYPE_SET); + iq.addChild("bind", "urn:ietf:params:xml:ns:xmpp-bind") + .addChild("resource").setContent(account.getResource()); + this.sendUnboundIqPacket(iq, new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + Element bind = packet.findChild("bind"); + if (bind != null) { + Element jid = bind.findChild("jid"); + if (jid != null && jid.getContent() != null) { + account.setResource(jid.getContent().split("/", 2)[1]); + if (streamFeatures.hasChild("sm", "urn:xmpp:sm:3")) { + smVersion = 3; + EnablePacket enable = new EnablePacket(smVersion); + tagWriter.writeStanzaAsync(enable); + stanzasSent = 0; + messageReceipts.clear(); + } else if (streamFeatures.hasChild("sm", + "urn:xmpp:sm:2")) { + smVersion = 2; + EnablePacket enable = new EnablePacket(smVersion); + tagWriter.writeStanzaAsync(enable); + stanzasSent = 0; + messageReceipts.clear(); + } + sendServiceDiscoveryInfo(account.getServer()); + sendServiceDiscoveryItems(account.getServer()); + if (bindListener != null) { + bindListener.onBind(account); + } + sendInitialPing(); + } else { + disconnect(true); + } + } else { + disconnect(true); + } + } + }); + if (this.streamFeatures.hasChild("session")) { + Log.d(Config.LOGTAG, account.getJid() + + ": sending deprecated session"); + IqPacket startSession = new IqPacket(IqPacket.TYPE_SET); + startSession.addChild("session", + "urn:ietf:params:xml:ns:xmpp-session"); + this.sendUnboundIqPacket(startSession, null); + } + } + + private void sendServiceDiscoveryInfo(final String server) { + IqPacket iq = new IqPacket(IqPacket.TYPE_GET); + iq.setTo(server); + iq.query("http://jabber.org/protocol/disco#info"); + this.sendIqPacket(iq, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + List elements = packet.query().getChildren(); + List features = new ArrayList(); + for (int i = 0; i < elements.size(); ++i) { + if (elements.get(i).getName().equals("feature")) { + features.add(elements.get(i).getAttribute("var")); + } + } + disco.put(server, features); + + if (account.getServer().equals(server)) { + enableAdvancedStreamFeatures(); + } + } + }); + } + + private void enableAdvancedStreamFeatures() { + if (getFeatures().carbons()) { + sendEnableCarbons(); + } + } + + private void sendServiceDiscoveryItems(final String server) { + IqPacket iq = new IqPacket(IqPacket.TYPE_GET); + iq.setTo(server); + iq.query("http://jabber.org/protocol/disco#items"); + this.sendIqPacket(iq, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + List elements = packet.query().getChildren(); + for (int i = 0; i < elements.size(); ++i) { + if (elements.get(i).getName().equals("item")) { + String jid = elements.get(i).getAttribute("jid"); + sendServiceDiscoveryInfo(jid); + } + } + } + }); + } + + private void sendEnableCarbons() { + IqPacket iq = new IqPacket(IqPacket.TYPE_SET); + iq.addChild("enable", "urn:xmpp:carbons:2"); + this.sendIqPacket(iq, new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + if (!packet.hasChild("error")) { + Log.d(Config.LOGTAG, account.getJid() + + ": successfully enabled carbons"); + } else { + Log.d(Config.LOGTAG, account.getJid() + + ": error enableing carbons " + packet.toString()); + } + } + }); + } + + private void processStreamError(Tag currentTag) + throws XmlPullParserException, IOException { + Element streamError = tagReader.readElement(currentTag); + if (streamError != null && streamError.hasChild("conflict")) { + String resource = account.getResource().split("\\.")[0]; + account.setResource(resource + "." + nextRandomId()); + Log.d(Config.LOGTAG, + account.getJid() + ": switching resource due to conflict (" + + account.getResource() + ")"); + } + } + + private void sendStartStream() throws IOException { + Tag stream = Tag.start("stream:stream"); + stream.setAttribute("from", account.getJid()); + stream.setAttribute("to", account.getServer()); + stream.setAttribute("version", "1.0"); + stream.setAttribute("xml:lang", "en"); + stream.setAttribute("xmlns", "jabber:client"); + stream.setAttribute("xmlns:stream", "http://etherx.jabber.org/streams"); + tagWriter.writeTag(stream); + } + + private String nextRandomId() { + return new BigInteger(50, mRandom).toString(32); + } + + public void sendIqPacket(IqPacket packet, OnIqPacketReceived callback) { + if (packet.getId() == null) { + String id = nextRandomId(); + packet.setAttribute("id", id); + } + packet.setFrom(account.getFullJid()); + this.sendPacket(packet, callback); + } + + public void sendUnboundIqPacket(IqPacket packet, OnIqPacketReceived callback) { + if (packet.getId() == null) { + String id = nextRandomId(); + packet.setAttribute("id", id); + } + this.sendPacket(packet, callback); + } + + public void sendMessagePacket(MessagePacket packet) { + this.sendPacket(packet, null); + } + + public void sendPresencePacket(PresencePacket packet) { + this.sendPacket(packet, null); + } + + private synchronized void sendPacket(final AbstractStanza packet, + PacketReceived callback) { + if (packet.getName().equals("iq") || packet.getName().equals("message") + || packet.getName().equals("presence")) { + ++stanzasSent; + } + tagWriter.writeStanzaAsync(packet); + if (packet instanceof MessagePacket && packet.getId() != null + && this.streamId != null) { + Log.d(Config.LOGTAG, "request delivery report for stanza " + + stanzasSent); + this.messageReceipts.put(stanzasSent, packet.getId()); + tagWriter.writeStanzaAsync(new RequestPacket(this.smVersion)); + } + if (callback != null) { + if (packet.getId() == null) { + packet.setId(nextRandomId()); + } + packetCallbacks.put(packet.getId(), callback); + } + } + + public void sendPing() { + if (streamFeatures.hasChild("sm")) { + tagWriter.writeStanzaAsync(new RequestPacket(smVersion)); + } else { + IqPacket iq = new IqPacket(IqPacket.TYPE_GET); + iq.setFrom(account.getFullJid()); + iq.addChild("ping", "urn:xmpp:ping"); + this.sendIqPacket(iq, null); + } + this.lastPingSent = SystemClock.elapsedRealtime(); + } + + public void setOnMessagePacketReceivedListener( + OnMessagePacketReceived listener) { + this.messageListener = listener; + } + + public void setOnUnregisteredIqPacketReceivedListener( + OnIqPacketReceived listener) { + this.unregisteredIqListener = listener; + } + + public void setOnPresencePacketReceivedListener( + OnPresencePacketReceived listener) { + this.presenceListener = listener; + } + + public void setOnJinglePacketReceivedListener( + OnJinglePacketReceived listener) { + this.jingleListener = listener; + } + + public void setOnStatusChangedListener(OnStatusChanged listener) { + this.statusListener = listener; + } + + public void setOnBindListener(OnBindListener listener) { + this.bindListener = listener; + } + + public void setOnMessageAcknowledgeListener(OnMessageAcknowledged listener) { + this.acknowledgedListener = listener; + } + + public void disconnect(boolean force) { + Log.d(Config.LOGTAG, account.getJid() + ": disconnecting"); + try { + if (force) { + socket.close(); + return; + } + new Thread(new Runnable() { + + @Override + public void run() { + if (tagWriter.isActive()) { + tagWriter.finish(); + try { + while (!tagWriter.finished()) { + Log.d(Config.LOGTAG, "not yet finished"); + Thread.sleep(100); + } + tagWriter.writeTag(Tag.end("stream:stream")); + socket.close(); + } catch (IOException e) { + Log.d(Config.LOGTAG, + "io exception during disconnect"); + } catch (InterruptedException e) { + Log.d(Config.LOGTAG, "interrupted"); + } + } + } + }).start(); + } catch (IOException e) { + Log.d(Config.LOGTAG, "io exception during disconnect"); + } + } + + public List findDiscoItemsByFeature(String feature) { + List items = new ArrayList(); + for (Entry> cursor : disco.entrySet()) { + if (cursor.getValue().contains(feature)) { + items.add(cursor.getKey()); + } + } + return items; + } + + public String findDiscoItemByFeature(String feature) { + List items = findDiscoItemsByFeature(feature); + if (items.size() >= 1) { + return items.get(0); + } + return null; + } + + public void r() { + this.tagWriter.writeStanzaAsync(new RequestPacket(smVersion)); + } + + public String getMucServer() { + return findDiscoItemByFeature("http://jabber.org/protocol/muc"); + } + + public int getTimeToNextAttempt() { + int interval = (int) (25 * Math.pow(1.5, attempt)); + int secondsSinceLast = (int) ((SystemClock.elapsedRealtime() - this.lastConnect) / 1000); + return interval - secondsSinceLast; + } + + public int getAttempt() { + return this.attempt; + } + + public Features getFeatures() { + return this.features; + } + + public class Features { + XmppConnection connection; + + public Features(XmppConnection connection) { + this.connection = connection; + } + + private boolean hasDiscoFeature(String server, String feature) { + if (!connection.disco.containsKey(server)) { + return false; + } + return connection.disco.get(server).contains(feature); + } + + public boolean carbons() { + return hasDiscoFeature(account.getServer(), "urn:xmpp:carbons:2"); + } + + public boolean sm() { + return streamId != null; + } + + public boolean csi() { + if (connection.streamFeatures == null) { + return false; + } else { + return connection.streamFeatures.hasChild("csi", + "urn:xmpp:csi:0"); + } + } + + public boolean pubsub() { + return hasDiscoFeature(account.getServer(), + "http://jabber.org/protocol/pubsub#publish"); + } + + public boolean rosterVersioning() { + if (connection.streamFeatures == null) { + return false; + } else { + return connection.streamFeatures.hasChild("ver"); + } + } + + public boolean streamhost() { + return connection + .findDiscoItemByFeature("http://jabber.org/protocol/bytestreams") != null; + } + + public boolean compression() { + return connection.usingCompression; + } + } + + public long getLastSessionEstablished() { + long diff; + if (this.lastSessionStarted == 0) { + diff = SystemClock.elapsedRealtime() - this.lastConnect; + } else { + diff = SystemClock.elapsedRealtime() - this.lastSessionStarted; + } + return System.currentTimeMillis() - diff; + } + + public long getLastConnect() { + return this.lastConnect; + } + + public long getLastPingSent() { + return this.lastPingSent; + } + + public long getLastPacketReceived() { + return this.lastPaketReceived; + } + + public void sendActive() { + this.sendPacket(new ActivePacket(), null); + } + + public void sendInactive() { + this.sendPacket(new InactivePacket(), null); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java new file mode 100644 index 000000000..3e7c7b682 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java @@ -0,0 +1,143 @@ +package eu.siacs.conversations.xmpp.jingle; + +import java.util.ArrayList; +import java.util.List; + +import eu.siacs.conversations.xml.Element; + +public class JingleCandidate { + + public static int TYPE_UNKNOWN; + public static int TYPE_DIRECT = 0; + public static int TYPE_PROXY = 1; + + private boolean ours; + private boolean usedByCounterpart = false; + private String cid; + private String host; + private int port; + private int type; + private String jid; + private int priority; + + public JingleCandidate(String cid, boolean ours) { + this.ours = ours; + this.cid = cid; + } + + public String getCid() { + return cid; + } + + public void setHost(String host) { + this.host = host; + } + + public String getHost() { + return this.host; + } + + public void setJid(String jid) { + this.jid = jid; + } + + public String getJid() { + return this.jid; + } + + public void setPort(int port) { + this.port = port; + } + + public int getPort() { + return this.port; + } + + public void setType(int type) { + this.type = type; + } + + public void setType(String type) { + if ("proxy".equals(type)) { + this.type = TYPE_PROXY; + } else if ("direct".equals(type)) { + this.type = TYPE_DIRECT; + } else { + this.type = TYPE_UNKNOWN; + } + } + + public void setPriority(int i) { + this.priority = i; + } + + public int getPriority() { + return this.priority; + } + + public boolean equals(JingleCandidate other) { + return this.getCid().equals(other.getCid()); + } + + public boolean equalValues(JingleCandidate other) { + return other.getHost().equals(this.getHost()) + && (other.getPort() == this.getPort()); + } + + public boolean isOurs() { + return ours; + } + + public int getType() { + return this.type; + } + + public static List parse(List canditates) { + List parsedCandidates = new ArrayList(); + for (Element c : canditates) { + parsedCandidates.add(JingleCandidate.parse(c)); + } + return parsedCandidates; + } + + public static JingleCandidate parse(Element candidate) { + JingleCandidate parsedCandidate = new JingleCandidate( + candidate.getAttribute("cid"), false); + parsedCandidate.setHost(candidate.getAttribute("host")); + parsedCandidate.setJid(candidate.getAttribute("jid")); + parsedCandidate.setType(candidate.getAttribute("type")); + parsedCandidate.setPriority(Integer.parseInt(candidate + .getAttribute("priority"))); + parsedCandidate + .setPort(Integer.parseInt(candidate.getAttribute("port"))); + return parsedCandidate; + } + + public Element toElement() { + Element element = new Element("candidate"); + element.setAttribute("cid", this.getCid()); + element.setAttribute("host", this.getHost()); + element.setAttribute("port", Integer.toString(this.getPort())); + element.setAttribute("jid", this.getJid()); + element.setAttribute("priority", Integer.toString(this.getPriority())); + if (this.getType() == TYPE_DIRECT) { + element.setAttribute("type", "direct"); + } else if (this.getType() == TYPE_PROXY) { + element.setAttribute("type", "proxy"); + } + return element; + } + + public void flagAsUsedByCounterpart() { + this.usedByCounterpart = true; + } + + public boolean isUsedByCounterpart() { + return this.usedByCounterpart; + } + + public String toString() { + return this.getHost() + ":" + this.getPort() + " (prio=" + + this.getPriority() + ")"; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java new file mode 100644 index 000000000..a0b2feb21 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java @@ -0,0 +1,910 @@ +package eu.siacs.conversations.xmpp.jingle; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; + +import android.content.Intent; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.util.Log; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Downloadable; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class JingleConnection implements Downloadable { + + private final String[] extensions = { "webp", "jpeg", "jpg", "png" }; + private final String[] cryptoExtensions = { "pgp", "gpg", "otr" }; + + private JingleConnectionManager mJingleConnectionManager; + private XmppConnectionService mXmppConnectionService; + + protected static final int JINGLE_STATUS_INITIATED = 0; + protected static final int JINGLE_STATUS_ACCEPTED = 1; + protected static final int JINGLE_STATUS_TERMINATED = 2; + protected static final int JINGLE_STATUS_CANCELED = 3; + protected static final int JINGLE_STATUS_FINISHED = 4; + protected static final int JINGLE_STATUS_TRANSMITTING = 5; + protected static final int JINGLE_STATUS_FAILED = 99; + + private int ibbBlockSize = 4096; + + private int mJingleStatus = -1; + private int mStatus = -1; + private Message message; + private String sessionId; + private Account account; + private String initiator; + private String responder; + private List candidates = new ArrayList(); + private ConcurrentHashMap connections = new ConcurrentHashMap(); + + private String transportId; + private Element fileOffer; + private DownloadableFile file = null; + + private String contentName; + private String contentCreator; + + private boolean receivedCandidate = false; + private boolean sentCandidate = false; + + private boolean acceptedAutomatically = false; + + private JingleTransport transport = null; + + private OnIqPacketReceived responseListener = new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + if (packet.getType() == IqPacket.TYPE_ERROR) { + if (initiator.equals(account.getFullJid())) { + mXmppConnectionService.markMessage(message, + Message.STATUS_SEND_FAILED); + } + mJingleStatus = JINGLE_STATUS_FAILED; + } + } + }; + + final OnFileTransmissionStatusChanged onFileTransmissionSatusChanged = new OnFileTransmissionStatusChanged() { + + @Override + public void onFileTransmitted(DownloadableFile file) { + if (responder.equals(account.getFullJid())) { + sendSuccess(); + if (acceptedAutomatically) { + message.markUnread(); + JingleConnection.this.mXmppConnectionService + .getNotificationService().push(message); + } + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(file.getAbsolutePath(), options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + message.setBody(Long.toString(file.getSize()) + ',' + + imageWidth + ',' + imageHeight); + mXmppConnectionService.databaseBackend.createMessage(message); + mXmppConnectionService.markMessage(message, + Message.STATUS_RECEIVED); + } + Log.d(Config.LOGTAG, + "sucessfully transmitted file:" + file.getAbsolutePath()); + if (message.getEncryption() != Message.ENCRYPTION_PGP) { + Intent intent = new Intent( + Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + intent.setData(Uri.fromFile(file)); + mXmppConnectionService.sendBroadcast(intent); + } + } + + @Override + public void onFileTransferAborted() { + JingleConnection.this.sendCancel(); + JingleConnection.this.cancel(); + } + }; + + private OnProxyActivated onProxyActivated = new OnProxyActivated() { + + @Override + public void success() { + if (initiator.equals(account.getFullJid())) { + Log.d(Config.LOGTAG, "we were initiating. sending file"); + transport.send(file, onFileTransmissionSatusChanged); + } else { + transport.receive(file, onFileTransmissionSatusChanged); + Log.d(Config.LOGTAG, "we were responding. receiving file"); + } + } + + @Override + public void failed() { + Log.d(Config.LOGTAG, "proxy activation failed"); + } + }; + + public JingleConnection(JingleConnectionManager mJingleConnectionManager) { + this.mJingleConnectionManager = mJingleConnectionManager; + this.mXmppConnectionService = mJingleConnectionManager + .getXmppConnectionService(); + } + + public String getSessionId() { + return this.sessionId; + } + + public Account getAccount() { + return this.account; + } + + public String getCounterPart() { + return this.message.getCounterpart(); + } + + public void deliverPacket(JinglePacket packet) { + boolean returnResult = true; + if (packet.isAction("session-terminate")) { + Reason reason = packet.getReason(); + if (reason != null) { + if (reason.hasChild("cancel")) { + this.cancel(); + } else if (reason.hasChild("success")) { + this.receiveSuccess(); + } else { + this.cancel(); + } + } else { + this.cancel(); + } + } else if (packet.isAction("session-accept")) { + returnResult = receiveAccept(packet); + } else if (packet.isAction("transport-info")) { + returnResult = receiveTransportInfo(packet); + } else if (packet.isAction("transport-replace")) { + if (packet.getJingleContent().hasIbbTransport()) { + returnResult = this.receiveFallbackToIbb(packet); + } else { + returnResult = false; + Log.d(Config.LOGTAG, "trying to fallback to something unknown" + + packet.toString()); + } + } else if (packet.isAction("transport-accept")) { + returnResult = this.receiveTransportAccept(packet); + } else { + Log.d(Config.LOGTAG, "packet arrived in connection. action was " + + packet.getAction()); + returnResult = false; + } + IqPacket response; + if (returnResult) { + response = packet.generateRespone(IqPacket.TYPE_RESULT); + + } else { + response = packet.generateRespone(IqPacket.TYPE_ERROR); + } + account.getXmppConnection().sendIqPacket(response, null); + } + + public void init(Message message) { + this.contentCreator = "initiator"; + this.contentName = this.mJingleConnectionManager.nextRandomId(); + this.message = message; + this.account = message.getConversation().getAccount(); + this.initiator = this.account.getFullJid(); + this.responder = this.message.getCounterpart(); + this.sessionId = this.mJingleConnectionManager.nextRandomId(); + if (this.candidates.size() > 0) { + this.sendInitRequest(); + } else { + this.mJingleConnectionManager.getPrimaryCandidate(account, + new OnPrimaryCandidateFound() { + + @Override + public void onPrimaryCandidateFound(boolean success, + final JingleCandidate candidate) { + if (success) { + final JingleSocks5Transport socksConnection = new JingleSocks5Transport( + JingleConnection.this, candidate); + connections.put(candidate.getCid(), + socksConnection); + socksConnection + .connect(new OnTransportConnected() { + + @Override + public void failed() { + Log.d(Config.LOGTAG, + "connection to our own primary candidete failed"); + sendInitRequest(); + } + + @Override + public void established() { + Log.d(Config.LOGTAG, + "succesfully connected to our own primary candidate"); + mergeCandidate(candidate); + sendInitRequest(); + } + }); + mergeCandidate(candidate); + } else { + Log.d(Config.LOGTAG, + "no primary candidate of our own was found"); + sendInitRequest(); + } + } + }); + } + + } + + public void init(Account account, JinglePacket packet) { + this.mJingleStatus = JINGLE_STATUS_INITIATED; + Conversation conversation = this.mXmppConnectionService + .findOrCreateConversation(account, + packet.getFrom().split("/", 2)[0], false); + this.message = new Message(conversation, "", Message.ENCRYPTION_NONE); + this.message.setStatus(Message.STATUS_RECEIVED); + this.message.setType(Message.TYPE_IMAGE); + this.mStatus = Downloadable.STATUS_OFFER; + this.message.setDownloadable(this); + String[] fromParts = packet.getFrom().split("/", 2); + this.message.setPresence(fromParts[1]); + this.account = account; + this.initiator = packet.getFrom(); + this.responder = this.account.getFullJid(); + this.sessionId = packet.getSessionId(); + Content content = packet.getJingleContent(); + this.contentCreator = content.getAttribute("creator"); + this.contentName = content.getAttribute("name"); + this.transportId = content.getTransportId(); + this.mergeCandidates(JingleCandidate.parse(content.socks5transport() + .getChildren())); + this.fileOffer = packet.getJingleContent().getFileOffer(); + if (fileOffer != null) { + Element fileSize = fileOffer.findChild("size"); + Element fileNameElement = fileOffer.findChild("name"); + if (fileNameElement != null) { + boolean supportedFile = false; + String[] filename = fileNameElement.getContent() + .toLowerCase(Locale.US).split("\\."); + if (Arrays.asList(this.extensions).contains( + filename[filename.length - 1])) { + supportedFile = true; + } else if (Arrays.asList(this.cryptoExtensions).contains( + filename[filename.length - 1])) { + if (filename.length == 3) { + if (Arrays.asList(this.extensions).contains( + filename[filename.length - 2])) { + supportedFile = true; + if (filename[filename.length - 1].equals("otr")) { + Log.d(Config.LOGTAG, "receiving otr file"); + this.message + .setEncryption(Message.ENCRYPTION_OTR); + } else { + this.message + .setEncryption(Message.ENCRYPTION_PGP); + } + } + } + } + if (supportedFile) { + long size = Long.parseLong(fileSize.getContent()); + message.setBody(Long.toString(size)); + conversation.add(message); + mXmppConnectionService.updateConversationUi(); + if (size <= this.mJingleConnectionManager + .getAutoAcceptFileSize()) { + Log.d(Config.LOGTAG, "auto accepting file from " + + packet.getFrom()); + this.acceptedAutomatically = true; + this.sendAccept(); + } else { + message.markUnread(); + Log.d(Config.LOGTAG, + "not auto accepting new file offer with size: " + + size + + " allowed size:" + + this.mJingleConnectionManager + .getAutoAcceptFileSize()); + this.mXmppConnectionService.getNotificationService() + .push(message); + } + this.file = this.mXmppConnectionService.getFileBackend() + .getFile(message, false); + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + byte[] key = conversation.getSymmetricKey(); + if (key == null) { + this.sendCancel(); + this.cancel(); + return; + } else { + this.file.setKey(key); + } + } + this.file.setExpectedSize(size); + } else { + this.sendCancel(); + this.cancel(); + } + } else { + this.sendCancel(); + this.cancel(); + } + } else { + this.sendCancel(); + this.cancel(); + } + } + + private void sendInitRequest() { + JinglePacket packet = this.bootstrapPacket("session-initiate"); + Content content = new Content(this.contentCreator, this.contentName); + if (message.getType() == Message.TYPE_IMAGE) { + content.setTransportId(this.transportId); + this.file = this.mXmppConnectionService.getFileBackend().getFile( + message, false); + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + Conversation conversation = this.message.getConversation(); + this.mXmppConnectionService.renewSymmetricKey(conversation); + content.setFileOffer(this.file, true); + this.file.setKey(conversation.getSymmetricKey()); + } else { + content.setFileOffer(this.file, false); + } + this.transportId = this.mJingleConnectionManager.nextRandomId(); + content.setTransportId(this.transportId); + content.socks5transport().setChildren(getCandidatesAsElements()); + packet.setContent(content); + this.sendJinglePacket(packet); + this.mJingleStatus = JINGLE_STATUS_INITIATED; + } + } + + private List getCandidatesAsElements() { + List elements = new ArrayList(); + for (JingleCandidate c : this.candidates) { + elements.add(c.toElement()); + } + return elements; + } + + private void sendAccept() { + mJingleStatus = JINGLE_STATUS_ACCEPTED; + this.mStatus = Downloadable.STATUS_DOWNLOADING; + mXmppConnectionService.updateConversationUi(); + this.mJingleConnectionManager.getPrimaryCandidate(this.account, + new OnPrimaryCandidateFound() { + + @Override + public void onPrimaryCandidateFound(boolean success, + final JingleCandidate candidate) { + final JinglePacket packet = bootstrapPacket("session-accept"); + final Content content = new Content(contentCreator, + contentName); + content.setFileOffer(fileOffer); + content.setTransportId(transportId); + if ((success) && (!equalCandidateExists(candidate))) { + final JingleSocks5Transport socksConnection = new JingleSocks5Transport( + JingleConnection.this, candidate); + connections.put(candidate.getCid(), socksConnection); + socksConnection.connect(new OnTransportConnected() { + + @Override + public void failed() { + Log.d(Config.LOGTAG, + "connection to our own primary candidate failed"); + content.socks5transport().setChildren( + getCandidatesAsElements()); + packet.setContent(content); + sendJinglePacket(packet); + connectNextCandidate(); + } + + @Override + public void established() { + Log.d(Config.LOGTAG, + "connected to primary candidate"); + mergeCandidate(candidate); + content.socks5transport().setChildren( + getCandidatesAsElements()); + packet.setContent(content); + sendJinglePacket(packet); + connectNextCandidate(); + } + }); + } else { + Log.d(Config.LOGTAG, + "did not find a primary candidate for ourself"); + content.socks5transport().setChildren( + getCandidatesAsElements()); + packet.setContent(content); + sendJinglePacket(packet); + connectNextCandidate(); + } + } + }); + + } + + private JinglePacket bootstrapPacket(String action) { + JinglePacket packet = new JinglePacket(); + packet.setAction(action); + packet.setFrom(account.getFullJid()); + packet.setTo(this.message.getCounterpart()); + packet.setSessionId(this.sessionId); + packet.setInitiator(this.initiator); + return packet; + } + + private void sendJinglePacket(JinglePacket packet) { + // Log.d(Config.LOGTAG,packet.toString()); + account.getXmppConnection().sendIqPacket(packet, responseListener); + } + + private boolean receiveAccept(JinglePacket packet) { + Content content = packet.getJingleContent(); + mergeCandidates(JingleCandidate.parse(content.socks5transport() + .getChildren())); + this.mJingleStatus = JINGLE_STATUS_ACCEPTED; + mXmppConnectionService.markMessage(message, Message.STATUS_UNSEND); + this.connectNextCandidate(); + return true; + } + + private boolean receiveTransportInfo(JinglePacket packet) { + Content content = packet.getJingleContent(); + if (content.hasSocks5Transport()) { + if (content.socks5transport().hasChild("activated")) { + if ((this.transport != null) + && (this.transport instanceof JingleSocks5Transport)) { + onProxyActivated.success(); + } else { + String cid = content.socks5transport() + .findChild("activated").getAttribute("cid"); + Log.d(Config.LOGTAG, "received proxy activated (" + cid + + ")prior to choosing our own transport"); + JingleSocks5Transport connection = this.connections + .get(cid); + if (connection != null) { + connection.setActivated(true); + } else { + Log.d(Config.LOGTAG, "activated connection not found"); + this.sendCancel(); + this.cancel(); + } + } + return true; + } else if (content.socks5transport().hasChild("proxy-error")) { + onProxyActivated.failed(); + return true; + } else if (content.socks5transport().hasChild("candidate-error")) { + Log.d(Config.LOGTAG, "received candidate error"); + this.receivedCandidate = true; + if ((mJingleStatus == JINGLE_STATUS_ACCEPTED) + && (this.sentCandidate)) { + this.connect(); + } + return true; + } else if (content.socks5transport().hasChild("candidate-used")) { + String cid = content.socks5transport() + .findChild("candidate-used").getAttribute("cid"); + if (cid != null) { + Log.d(Config.LOGTAG, "candidate used by counterpart:" + cid); + JingleCandidate candidate = getCandidate(cid); + candidate.flagAsUsedByCounterpart(); + this.receivedCandidate = true; + if ((mJingleStatus == JINGLE_STATUS_ACCEPTED) + && (this.sentCandidate)) { + this.connect(); + } else { + Log.d(Config.LOGTAG, + "ignoring because file is already in transmission or we havent sent our candidate yet"); + } + return true; + } else { + return false; + } + } else { + return false; + } + } else { + return true; + } + } + + private void connect() { + final JingleSocks5Transport connection = chooseConnection(); + this.transport = connection; + if (connection == null) { + Log.d(Config.LOGTAG, "could not find suitable candidate"); + this.disconnect(); + if (this.initiator.equals(account.getFullJid())) { + this.sendFallbackToIbb(); + } + } else { + this.mJingleStatus = JINGLE_STATUS_TRANSMITTING; + if (connection.needsActivation()) { + if (connection.getCandidate().isOurs()) { + Log.d(Config.LOGTAG, "candidate " + + connection.getCandidate().getCid() + + " was our proxy. going to activate"); + IqPacket activation = new IqPacket(IqPacket.TYPE_SET); + activation.setTo(connection.getCandidate().getJid()); + activation.query("http://jabber.org/protocol/bytestreams") + .setAttribute("sid", this.getSessionId()); + activation.query().addChild("activate") + .setContent(this.getCounterPart()); + this.account.getXmppConnection().sendIqPacket(activation, + new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, + IqPacket packet) { + if (packet.getType() == IqPacket.TYPE_ERROR) { + onProxyActivated.failed(); + } else { + onProxyActivated.success(); + sendProxyActivated(connection + .getCandidate().getCid()); + } + } + }); + } else { + Log.d(Config.LOGTAG, + "candidate " + + connection.getCandidate().getCid() + + " was a proxy. waiting for other party to activate"); + } + } else { + if (initiator.equals(account.getFullJid())) { + Log.d(Config.LOGTAG, "we were initiating. sending file"); + connection.send(file, onFileTransmissionSatusChanged); + } else { + Log.d(Config.LOGTAG, "we were responding. receiving file"); + connection.receive(file, onFileTransmissionSatusChanged); + } + } + } + } + + private JingleSocks5Transport chooseConnection() { + JingleSocks5Transport connection = null; + for (Entry cursor : connections + .entrySet()) { + JingleSocks5Transport currentConnection = cursor.getValue(); + // Log.d(Config.LOGTAG,"comparing candidate: "+currentConnection.getCandidate().toString()); + if (currentConnection.isEstablished() + && (currentConnection.getCandidate().isUsedByCounterpart() || (!currentConnection + .getCandidate().isOurs()))) { + // Log.d(Config.LOGTAG,"is usable"); + if (connection == null) { + connection = currentConnection; + } else { + if (connection.getCandidate().getPriority() < currentConnection + .getCandidate().getPriority()) { + connection = currentConnection; + } else if (connection.getCandidate().getPriority() == currentConnection + .getCandidate().getPriority()) { + // Log.d(Config.LOGTAG,"found two candidates with same priority"); + if (initiator.equals(account.getFullJid())) { + if (currentConnection.getCandidate().isOurs()) { + connection = currentConnection; + } + } else { + if (!currentConnection.getCandidate().isOurs()) { + connection = currentConnection; + } + } + } + } + } + } + return connection; + } + + private void sendSuccess() { + JinglePacket packet = bootstrapPacket("session-terminate"); + Reason reason = new Reason(); + reason.addChild("success"); + packet.setReason(reason); + this.sendJinglePacket(packet); + this.disconnect(); + this.mJingleStatus = JINGLE_STATUS_FINISHED; + this.message.setStatus(Message.STATUS_RECEIVED); + this.message.setDownloadable(null); + this.mXmppConnectionService.updateMessage(message); + this.mJingleConnectionManager.finishConnection(this); + } + + private void sendFallbackToIbb() { + Log.d(Config.LOGTAG, "sending fallback to ibb"); + JinglePacket packet = this.bootstrapPacket("transport-replace"); + Content content = new Content(this.contentCreator, this.contentName); + this.transportId = this.mJingleConnectionManager.nextRandomId(); + content.setTransportId(this.transportId); + content.ibbTransport().setAttribute("block-size", + Integer.toString(this.ibbBlockSize)); + packet.setContent(content); + this.sendJinglePacket(packet); + } + + private boolean receiveFallbackToIbb(JinglePacket packet) { + Log.d(Config.LOGTAG, "receiving fallack to ibb"); + String receivedBlockSize = packet.getJingleContent().ibbTransport() + .getAttribute("block-size"); + if (receivedBlockSize != null) { + int bs = Integer.parseInt(receivedBlockSize); + if (bs > this.ibbBlockSize) { + this.ibbBlockSize = bs; + } + } + this.transportId = packet.getJingleContent().getTransportId(); + this.transport = new JingleInbandTransport(this.account, + this.responder, this.transportId, this.ibbBlockSize); + this.transport.receive(file, onFileTransmissionSatusChanged); + JinglePacket answer = bootstrapPacket("transport-accept"); + Content content = new Content("initiator", "a-file-offer"); + content.setTransportId(this.transportId); + content.ibbTransport().setAttribute("block-size", + Integer.toString(this.ibbBlockSize)); + answer.setContent(content); + this.sendJinglePacket(answer); + return true; + } + + private boolean receiveTransportAccept(JinglePacket packet) { + if (packet.getJingleContent().hasIbbTransport()) { + String receivedBlockSize = packet.getJingleContent().ibbTransport() + .getAttribute("block-size"); + if (receivedBlockSize != null) { + int bs = Integer.parseInt(receivedBlockSize); + if (bs > this.ibbBlockSize) { + this.ibbBlockSize = bs; + } + } + this.transport = new JingleInbandTransport(this.account, + this.responder, this.transportId, this.ibbBlockSize); + this.transport.connect(new OnTransportConnected() { + + @Override + public void failed() { + Log.d(Config.LOGTAG, "ibb open failed"); + } + + @Override + public void established() { + JingleConnection.this.transport.send(file, + onFileTransmissionSatusChanged); + } + }); + return true; + } else { + return false; + } + } + + private void receiveSuccess() { + this.mJingleStatus = JINGLE_STATUS_FINISHED; + this.mXmppConnectionService.markMessage(this.message, + Message.STATUS_SEND); + this.disconnect(); + this.mJingleConnectionManager.finishConnection(this); + } + + public void cancel() { + this.mJingleStatus = JINGLE_STATUS_CANCELED; + this.disconnect(); + if (this.message != null) { + if (this.responder.equals(account.getFullJid())) { + this.mStatus = Downloadable.STATUS_FAILED; + this.mXmppConnectionService.updateConversationUi(); + } else { + if (this.mJingleStatus == JINGLE_STATUS_INITIATED) { + this.mXmppConnectionService.markMessage(this.message, + Message.STATUS_SEND_REJECTED); + } else { + this.mXmppConnectionService.markMessage(this.message, + Message.STATUS_SEND_FAILED); + } + } + } + this.mJingleConnectionManager.finishConnection(this); + } + + private void sendCancel() { + JinglePacket packet = bootstrapPacket("session-terminate"); + Reason reason = new Reason(); + reason.addChild("cancel"); + packet.setReason(reason); + this.sendJinglePacket(packet); + } + + private void connectNextCandidate() { + for (JingleCandidate candidate : this.candidates) { + if ((!connections.containsKey(candidate.getCid()) && (!candidate + .isOurs()))) { + this.connectWithCandidate(candidate); + return; + } + } + this.sendCandidateError(); + } + + private void connectWithCandidate(final JingleCandidate candidate) { + final JingleSocks5Transport socksConnection = new JingleSocks5Transport( + this, candidate); + connections.put(candidate.getCid(), socksConnection); + socksConnection.connect(new OnTransportConnected() { + + @Override + public void failed() { + Log.d(Config.LOGTAG, + "connection failed with " + candidate.getHost() + ":" + + candidate.getPort()); + connectNextCandidate(); + } + + @Override + public void established() { + Log.d(Config.LOGTAG, + "established connection with " + candidate.getHost() + + ":" + candidate.getPort()); + sendCandidateUsed(candidate.getCid()); + } + }); + } + + private void disconnect() { + Iterator> it = this.connections + .entrySet().iterator(); + while (it.hasNext()) { + Entry pairs = it.next(); + pairs.getValue().disconnect(); + it.remove(); + } + } + + private void sendProxyActivated(String cid) { + JinglePacket packet = bootstrapPacket("transport-info"); + Content content = new Content(this.contentCreator, this.contentName); + content.setTransportId(this.transportId); + content.socks5transport().addChild("activated") + .setAttribute("cid", cid); + packet.setContent(content); + this.sendJinglePacket(packet); + } + + private void sendCandidateUsed(final String cid) { + JinglePacket packet = bootstrapPacket("transport-info"); + Content content = new Content(this.contentCreator, this.contentName); + content.setTransportId(this.transportId); + content.socks5transport().addChild("candidate-used") + .setAttribute("cid", cid); + packet.setContent(content); + this.sentCandidate = true; + if ((receivedCandidate) && (mJingleStatus == JINGLE_STATUS_ACCEPTED)) { + connect(); + } + this.sendJinglePacket(packet); + } + + private void sendCandidateError() { + JinglePacket packet = bootstrapPacket("transport-info"); + Content content = new Content(this.contentCreator, this.contentName); + content.setTransportId(this.transportId); + content.socks5transport().addChild("candidate-error"); + packet.setContent(content); + this.sentCandidate = true; + if ((receivedCandidate) && (mJingleStatus == JINGLE_STATUS_ACCEPTED)) { + connect(); + } + this.sendJinglePacket(packet); + } + + public String getInitiator() { + return this.initiator; + } + + public String getResponder() { + return this.responder; + } + + public int getJingleStatus() { + return this.mJingleStatus; + } + + private boolean equalCandidateExists(JingleCandidate candidate) { + for (JingleCandidate c : this.candidates) { + if (c.equalValues(candidate)) { + return true; + } + } + return false; + } + + private void mergeCandidate(JingleCandidate candidate) { + for (JingleCandidate c : this.candidates) { + if (c.equals(candidate)) { + return; + } + } + this.candidates.add(candidate); + } + + private void mergeCandidates(List candidates) { + for (JingleCandidate c : candidates) { + mergeCandidate(c); + } + } + + private JingleCandidate getCandidate(String cid) { + for (JingleCandidate c : this.candidates) { + if (c.getCid().equals(cid)) { + return c; + } + } + return null; + } + + interface OnProxyActivated { + public void success(); + + public void failed(); + } + + public boolean hasTransportId(String sid) { + return sid.equals(this.transportId); + } + + public JingleTransport getTransport() { + return this.transport; + } + + public boolean start() { + if (account.getStatus() == Account.STATUS_ONLINE) { + if (mJingleStatus == JINGLE_STATUS_INITIATED) { + new Thread(new Runnable() { + + @Override + public void run() { + sendAccept(); + } + }).start(); + } + return true; + } else { + return false; + } + } + + @Override + public int getStatus() { + return this.mStatus; + } + + @Override + public long getFileSize() { + if (this.file != null) { + return this.file.getExpectedSize(); + } else { + return 0; + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java new file mode 100644 index 000000000..1e7c84d45 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -0,0 +1,163 @@ +package eu.siacs.conversations.xmpp.jingle; + +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import android.annotation.SuppressLint; +import android.util.Log; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.AbstractConnectionManager; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class JingleConnectionManager extends AbstractConnectionManager { + private List connections = new CopyOnWriteArrayList(); + + private HashMap primaryCandidates = new HashMap(); + + @SuppressLint("TrulyRandom") + private SecureRandom random = new SecureRandom(); + + public JingleConnectionManager(XmppConnectionService service) { + super(service); + } + + public void deliverPacket(Account account, JinglePacket packet) { + if (packet.isAction("session-initiate")) { + JingleConnection connection = new JingleConnection(this); + connection.init(account, packet); + connections.add(connection); + } else { + for (JingleConnection connection : connections) { + if (connection.getAccount() == account + && connection.getSessionId().equals( + packet.getSessionId()) + && connection.getCounterPart().equals(packet.getFrom())) { + connection.deliverPacket(packet); + return; + } + } + account.getXmppConnection().sendIqPacket( + packet.generateRespone(IqPacket.TYPE_ERROR), null); + } + } + + public JingleConnection createNewConnection(Message message) { + JingleConnection connection = new JingleConnection(this); + connection.init(message); + this.connections.add(connection); + return connection; + } + + public JingleConnection createNewConnection(JinglePacket packet) { + JingleConnection connection = new JingleConnection(this); + this.connections.add(connection); + return connection; + } + + public void finishConnection(JingleConnection connection) { + this.connections.remove(connection); + } + + public void getPrimaryCandidate(Account account, + final OnPrimaryCandidateFound listener) { + if (!this.primaryCandidates.containsKey(account.getJid())) { + String xmlns = "http://jabber.org/protocol/bytestreams"; + final String proxy = account.getXmppConnection() + .findDiscoItemByFeature(xmlns); + if (proxy != null) { + IqPacket iq = new IqPacket(IqPacket.TYPE_GET); + iq.setTo(proxy); + iq.query(xmlns); + account.getXmppConnection().sendIqPacket(iq, + new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, + IqPacket packet) { + Element streamhost = packet + .query() + .findChild("streamhost", + "http://jabber.org/protocol/bytestreams"); + if (streamhost != null) { + JingleCandidate candidate = new JingleCandidate( + nextRandomId(), true); + candidate.setHost(streamhost + .getAttribute("host")); + candidate.setPort(Integer + .parseInt(streamhost + .getAttribute("port"))); + candidate + .setType(JingleCandidate.TYPE_PROXY); + candidate.setJid(proxy); + candidate.setPriority(655360 + 65535); + primaryCandidates.put(account.getJid(), + candidate); + listener.onPrimaryCandidateFound(true, + candidate); + } else { + listener.onPrimaryCandidateFound(false, + null); + } + } + }); + } else { + listener.onPrimaryCandidateFound(false, null); + } + + } else { + listener.onPrimaryCandidateFound(true, + this.primaryCandidates.get(account.getJid())); + } + } + + public String nextRandomId() { + return new BigInteger(50, random).toString(32); + } + + public void deliverIbbPacket(Account account, IqPacket packet) { + String sid = null; + Element payload = null; + if (packet.hasChild("open", "http://jabber.org/protocol/ibb")) { + payload = packet + .findChild("open", "http://jabber.org/protocol/ibb"); + sid = payload.getAttribute("sid"); + } else if (packet.hasChild("data", "http://jabber.org/protocol/ibb")) { + payload = packet + .findChild("data", "http://jabber.org/protocol/ibb"); + sid = payload.getAttribute("sid"); + } + if (sid != null) { + for (JingleConnection connection : connections) { + if (connection.getAccount() == account + && connection.hasTransportId(sid)) { + JingleTransport transport = connection.getTransport(); + if (transport instanceof JingleInbandTransport) { + JingleInbandTransport inbandTransport = (JingleInbandTransport) transport; + inbandTransport.deliverPayload(packet, payload); + return; + } + } + } + Log.d(Config.LOGTAG, + "couldnt deliver payload: " + payload.toString()); + } else { + Log.d(Config.LOGTAG, "no sid found in incomming ibb packet"); + } + } + + public void cancelInTransmission() { + for (JingleConnection connection : this.connections) { + if (connection.getJingleStatus() == JingleConnection.JINGLE_STATUS_TRANSMITTING) { + connection.cancel(); + } + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInbandTransport.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInbandTransport.java new file mode 100644 index 000000000..cc1e92f62 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInbandTransport.java @@ -0,0 +1,191 @@ +package eu.siacs.conversations.xmpp.jingle; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import android.util.Base64; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class JingleInbandTransport extends JingleTransport { + + private Account account; + private String counterpart; + private int blockSize; + private int bufferSize; + private int seq = 0; + private String sessionId; + + private boolean established = false; + + private DownloadableFile file; + + private InputStream fileInputStream = null; + private OutputStream fileOutputStream; + private long remainingSize; + private MessageDigest digest; + + private OnFileTransmissionStatusChanged onFileTransmissionStatusChanged; + + private OnIqPacketReceived onAckReceived = new OnIqPacketReceived() { + @Override + public void onIqPacketReceived(Account account, IqPacket packet) { + if (packet.getType() == IqPacket.TYPE_RESULT) { + sendNextBlock(); + } + } + }; + + public JingleInbandTransport(Account account, String counterpart, + String sid, int blocksize) { + this.account = account; + this.counterpart = counterpart; + this.blockSize = blocksize; + this.bufferSize = blocksize / 4; + this.sessionId = sid; + } + + public void connect(final OnTransportConnected callback) { + IqPacket iq = new IqPacket(IqPacket.TYPE_SET); + iq.setTo(this.counterpart); + Element open = iq.addChild("open", "http://jabber.org/protocol/ibb"); + open.setAttribute("sid", this.sessionId); + open.setAttribute("stanza", "iq"); + open.setAttribute("block-size", Integer.toString(this.blockSize)); + + this.account.getXmppConnection().sendIqPacket(iq, + new OnIqPacketReceived() { + + @Override + public void onIqPacketReceived(Account account, + IqPacket packet) { + if (packet.getType() == IqPacket.TYPE_ERROR) { + callback.failed(); + } else { + callback.established(); + } + } + }); + } + + @Override + public void receive(DownloadableFile file, + OnFileTransmissionStatusChanged callback) { + this.onFileTransmissionStatusChanged = callback; + this.file = file; + try { + this.digest = MessageDigest.getInstance("SHA-1"); + digest.reset(); + file.getParentFile().mkdirs(); + file.createNewFile(); + this.fileOutputStream = file.createOutputStream(); + if (this.fileOutputStream == null) { + callback.onFileTransferAborted(); + return; + } + this.remainingSize = file.getExpectedSize(); + } catch (NoSuchAlgorithmException e) { + callback.onFileTransferAborted(); + } catch (IOException e) { + callback.onFileTransferAborted(); + } + } + + @Override + public void send(DownloadableFile file, + OnFileTransmissionStatusChanged callback) { + this.onFileTransmissionStatusChanged = callback; + this.file = file; + try { + this.digest = MessageDigest.getInstance("SHA-1"); + this.digest.reset(); + fileInputStream = this.file.createInputStream(); + if (fileInputStream == null) { + callback.onFileTransferAborted(); + return; + } + this.sendNextBlock(); + } catch (NoSuchAlgorithmException e) { + callback.onFileTransferAborted(); + } + } + + private void sendNextBlock() { + byte[] buffer = new byte[this.bufferSize]; + try { + int count = fileInputStream.read(buffer); + if (count == -1) { + file.setSha1Sum(CryptoHelper.bytesToHex(digest.digest())); + fileInputStream.close(); + this.onFileTransmissionStatusChanged.onFileTransmitted(file); + } else { + this.digest.update(buffer); + String base64 = Base64.encodeToString(buffer, Base64.NO_WRAP); + IqPacket iq = new IqPacket(IqPacket.TYPE_SET); + iq.setTo(this.counterpart); + Element data = iq.addChild("data", + "http://jabber.org/protocol/ibb"); + data.setAttribute("seq", Integer.toString(this.seq)); + data.setAttribute("block-size", + Integer.toString(this.blockSize)); + data.setAttribute("sid", this.sessionId); + data.setContent(base64); + this.account.getXmppConnection().sendIqPacket(iq, + this.onAckReceived); + this.seq++; + } + } catch (IOException e) { + this.onFileTransmissionStatusChanged.onFileTransferAborted(); + } + } + + private void receiveNextBlock(String data) { + try { + byte[] buffer = Base64.decode(data, Base64.NO_WRAP); + if (this.remainingSize < buffer.length) { + buffer = Arrays + .copyOfRange(buffer, 0, (int) this.remainingSize); + } + this.remainingSize -= buffer.length; + + this.fileOutputStream.write(buffer); + + this.digest.update(buffer); + if (this.remainingSize <= 0) { + file.setSha1Sum(CryptoHelper.bytesToHex(digest.digest())); + fileOutputStream.flush(); + fileOutputStream.close(); + this.onFileTransmissionStatusChanged.onFileTransmitted(file); + } + } catch (IOException e) { + this.onFileTransmissionStatusChanged.onFileTransferAborted(); + } + } + + public void deliverPayload(IqPacket packet, Element payload) { + if (payload.getName().equals("open")) { + if (!established) { + established = true; + this.account.getXmppConnection().sendIqPacket( + packet.generateRespone(IqPacket.TYPE_RESULT), null); + } else { + this.account.getXmppConnection().sendIqPacket( + packet.generateRespone(IqPacket.TYPE_ERROR), null); + } + } else if (payload.getName().equals("data")) { + this.receiveNextBlock(payload.getContent()); + this.account.getXmppConnection().sendIqPacket( + packet.generateRespone(IqPacket.TYPE_RESULT), null); + } else { + // TODO some sort of exception + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java new file mode 100644 index 000000000..1da2f0cdf --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java @@ -0,0 +1,212 @@ +package eu.siacs.conversations.xmpp.jingle; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.utils.CryptoHelper; + +public class JingleSocks5Transport extends JingleTransport { + private JingleCandidate candidate; + private String destination; + private OutputStream outputStream; + private InputStream inputStream; + private boolean isEstablished = false; + private boolean activated = false; + protected Socket socket; + + public JingleSocks5Transport(JingleConnection jingleConnection, + JingleCandidate candidate) { + this.candidate = candidate; + try { + MessageDigest mDigest = MessageDigest.getInstance("SHA-1"); + StringBuilder destBuilder = new StringBuilder(); + destBuilder.append(jingleConnection.getSessionId()); + if (candidate.isOurs()) { + destBuilder.append(jingleConnection.getAccount().getFullJid()); + destBuilder.append(jingleConnection.getCounterPart()); + } else { + destBuilder.append(jingleConnection.getCounterPart()); + destBuilder.append(jingleConnection.getAccount().getFullJid()); + } + mDigest.reset(); + this.destination = CryptoHelper.bytesToHex(mDigest + .digest(destBuilder.toString().getBytes())); + } catch (NoSuchAlgorithmException e) { + + } + } + + public void connect(final OnTransportConnected callback) { + new Thread(new Runnable() { + + @Override + public void run() { + try { + socket = new Socket(candidate.getHost(), + candidate.getPort()); + inputStream = socket.getInputStream(); + outputStream = socket.getOutputStream(); + byte[] login = { 0x05, 0x01, 0x00 }; + byte[] expectedReply = { 0x05, 0x00 }; + byte[] reply = new byte[2]; + outputStream.write(login); + inputStream.read(reply); + final String connect = Character.toString('\u0005') + + '\u0001' + '\u0000' + '\u0003' + '\u0028' + + destination + '\u0000' + '\u0000'; + if (Arrays.equals(reply, expectedReply)) { + outputStream.write(connect.getBytes()); + byte[] result = new byte[2]; + inputStream.read(result); + int status = result[1]; + if (status == 0) { + isEstablished = true; + callback.established(); + } else { + callback.failed(); + } + } else { + socket.close(); + callback.failed(); + } + } catch (UnknownHostException e) { + callback.failed(); + } catch (IOException e) { + callback.failed(); + } + } + }).start(); + + } + + public void send(final DownloadableFile file, + final OnFileTransmissionStatusChanged callback) { + new Thread(new Runnable() { + + @Override + public void run() { + InputStream fileInputStream = null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.reset(); + fileInputStream = file.createInputStream(); + if (fileInputStream == null) { + callback.onFileTransferAborted(); + return; + } + int count; + byte[] buffer = new byte[8192]; + while ((count = fileInputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, count); + digest.update(buffer, 0, count); + } + outputStream.flush(); + file.setSha1Sum(CryptoHelper.bytesToHex(digest.digest())); + if (callback != null) { + callback.onFileTransmitted(file); + } + } catch (FileNotFoundException e) { + callback.onFileTransferAborted(); + } catch (IOException e) { + callback.onFileTransferAborted(); + } catch (NoSuchAlgorithmException e) { + callback.onFileTransferAborted(); + } finally { + try { + if (fileInputStream != null) { + fileInputStream.close(); + } + } catch (IOException e) { + callback.onFileTransferAborted(); + } + } + } + }).start(); + + } + + public void receive(final DownloadableFile file, + final OnFileTransmissionStatusChanged callback) { + new Thread(new Runnable() { + + @Override + public void run() { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.reset(); + inputStream.skip(45); + socket.setSoTimeout(30000); + file.getParentFile().mkdirs(); + file.createNewFile(); + OutputStream fileOutputStream = file.createOutputStream(); + if (fileOutputStream == null) { + callback.onFileTransferAborted(); + return; + } + long remainingSize = file.getExpectedSize(); + byte[] buffer = new byte[8192]; + int count = buffer.length; + while (remainingSize > 0) { + count = inputStream.read(buffer); + if (count == -1) { + callback.onFileTransferAborted(); + return; + } else { + fileOutputStream.write(buffer, 0, count); + digest.update(buffer, 0, count); + remainingSize -= count; + } + } + fileOutputStream.flush(); + fileOutputStream.close(); + file.setSha1Sum(CryptoHelper.bytesToHex(digest.digest())); + callback.onFileTransmitted(file); + } catch (FileNotFoundException e) { + callback.onFileTransferAborted(); + } catch (IOException e) { + callback.onFileTransferAborted(); + } catch (NoSuchAlgorithmException e) { + callback.onFileTransferAborted(); + } + } + }).start(); + } + + public boolean isProxy() { + return this.candidate.getType() == JingleCandidate.TYPE_PROXY; + } + + public boolean needsActivation() { + return (this.isProxy() && !this.activated); + } + + public void disconnect() { + if (this.socket != null) { + try { + this.socket.close(); + } catch (IOException e) { + + } + } + } + + public boolean isEstablished() { + return this.isEstablished; + } + + public JingleCandidate getCandidate() { + return this.candidate; + } + + public void setActivated(boolean activated) { + this.activated = activated; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java new file mode 100644 index 000000000..1374e61cc --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java @@ -0,0 +1,13 @@ +package eu.siacs.conversations.xmpp.jingle; + +import eu.siacs.conversations.entities.DownloadableFile; + +public abstract class JingleTransport { + public abstract void connect(final OnTransportConnected callback); + + public abstract void receive(final DownloadableFile file, + final OnFileTransmissionStatusChanged callback); + + public abstract void send(final DownloadableFile file, + final OnFileTransmissionStatusChanged callback); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnFileTransmissionStatusChanged.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnFileTransmissionStatusChanged.java new file mode 100644 index 000000000..e45e7441d --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnFileTransmissionStatusChanged.java @@ -0,0 +1,9 @@ +package eu.siacs.conversations.xmpp.jingle; + +import eu.siacs.conversations.entities.DownloadableFile; + +public interface OnFileTransmissionStatusChanged { + public void onFileTransmitted(DownloadableFile file); + + public void onFileTransferAborted(); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnJinglePacketReceived.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnJinglePacketReceived.java new file mode 100644 index 000000000..2aaf62a1b --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnJinglePacketReceived.java @@ -0,0 +1,9 @@ +package eu.siacs.conversations.xmpp.jingle; + +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xmpp.PacketReceived; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; + +public interface OnJinglePacketReceived extends PacketReceived { + public void onJinglePacketReceived(Account account, JinglePacket packet); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java new file mode 100644 index 000000000..03a437b2b --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java @@ -0,0 +1,6 @@ +package eu.siacs.conversations.xmpp.jingle; + +public interface OnPrimaryCandidateFound { + public void onPrimaryCandidateFound(boolean success, + JingleCandidate canditate); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnTransportConnected.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnTransportConnected.java new file mode 100644 index 000000000..38f03c5d0 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/OnTransportConnected.java @@ -0,0 +1,7 @@ +package eu.siacs.conversations.xmpp.jingle; + +public interface OnTransportConnected { + public void failed(); + + public void established(); +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java new file mode 100644 index 000000000..bcadbe778 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java @@ -0,0 +1,102 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.xml.Element; + +public class Content extends Element { + + private String transportId; + + private Content(String name) { + super(name); + } + + public Content() { + super("content"); + } + + public Content(String creator, String name) { + super("content"); + this.setAttribute("creator", creator); + this.setAttribute("name", name); + } + + public void setTransportId(String sid) { + this.transportId = sid; + } + + public void setFileOffer(DownloadableFile actualFile, boolean otr) { + Element description = this.addChild("description", + "urn:xmpp:jingle:apps:file-transfer:3"); + Element offer = description.addChild("offer"); + Element file = offer.addChild("file"); + file.addChild("size").setContent(Long.toString(actualFile.getSize())); + if (otr) { + file.addChild("name").setContent(actualFile.getName() + ".otr"); + } else { + file.addChild("name").setContent(actualFile.getName()); + } + } + + public Element getFileOffer() { + Element description = this.findChild("description", + "urn:xmpp:jingle:apps:file-transfer:3"); + if (description == null) { + return null; + } + Element offer = description.findChild("offer"); + if (offer == null) { + return null; + } + return offer.findChild("file"); + } + + public void setFileOffer(Element fileOffer) { + Element description = this.findChild("description", + "urn:xmpp:jingle:apps:file-transfer:3"); + if (description == null) { + description = this.addChild("description", + "urn:xmpp:jingle:apps:file-transfer:3"); + } + description.addChild(fileOffer); + } + + public String getTransportId() { + if (hasSocks5Transport()) { + this.transportId = socks5transport().getAttribute("sid"); + } else if (hasIbbTransport()) { + this.transportId = ibbTransport().getAttribute("sid"); + } + return this.transportId; + } + + public Element socks5transport() { + Element transport = this.findChild("transport", + "urn:xmpp:jingle:transports:s5b:1"); + if (transport == null) { + transport = this.addChild("transport", + "urn:xmpp:jingle:transports:s5b:1"); + transport.setAttribute("sid", this.transportId); + } + return transport; + } + + public Element ibbTransport() { + Element transport = this.findChild("transport", + "urn:xmpp:jingle:transports:ibb:1"); + if (transport == null) { + transport = this.addChild("transport", + "urn:xmpp:jingle:transports:ibb:1"); + transport.setAttribute("sid", this.transportId); + } + return transport; + } + + public boolean hasSocks5Transport() { + return this.hasChild("transport", "urn:xmpp:jingle:transports:s5b:1"); + } + + public boolean hasIbbTransport() { + return this.hasChild("transport", "urn:xmpp:jingle:transports:ibb:1"); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java new file mode 100644 index 000000000..77a736437 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java @@ -0,0 +1,95 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class JinglePacket extends IqPacket { + Content content = null; + Reason reason = null; + Element jingle = new Element("jingle"); + + @Override + public Element addChild(Element child) { + if ("jingle".equals(child.getName())) { + Element contentElement = child.findChild("content"); + if (contentElement != null) { + this.content = new Content(); + this.content.setChildren(contentElement.getChildren()); + this.content.setAttributes(contentElement.getAttributes()); + } + Element reasonElement = child.findChild("reason"); + if (reasonElement != null) { + this.reason = new Reason(); + this.reason.setChildren(reasonElement.getChildren()); + this.reason.setAttributes(reasonElement.getAttributes()); + } + this.jingle.setAttributes(child.getAttributes()); + } + return child; + } + + public JinglePacket setContent(Content content) { + this.content = content; + return this; + } + + public Content getJingleContent() { + if (this.content == null) { + this.content = new Content(); + } + return this.content; + } + + public JinglePacket setReason(Reason reason) { + this.reason = reason; + return this; + } + + public Reason getReason() { + return this.reason; + } + + private void build() { + this.children.clear(); + this.jingle.clearChildren(); + this.jingle.setAttribute("xmlns", "urn:xmpp:jingle:1"); + if (this.content != null) { + jingle.addChild(this.content); + } + if (this.reason != null) { + jingle.addChild(this.reason); + } + this.children.add(jingle); + this.setAttribute("type", "set"); + } + + public String getSessionId() { + return this.jingle.getAttribute("sid"); + } + + public void setSessionId(String sid) { + this.jingle.setAttribute("sid", sid); + } + + @Override + public String toString() { + this.build(); + return super.toString(); + } + + public void setAction(String action) { + this.jingle.setAttribute("action", action); + } + + public String getAction() { + return this.jingle.getAttribute("action"); + } + + public void setInitiator(String initiator) { + this.jingle.setAttribute("initiator", initiator); + } + + public boolean isAction(String action) { + return action.equalsIgnoreCase(this.getAction()); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java new file mode 100644 index 000000000..610d5e760 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java @@ -0,0 +1,13 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import eu.siacs.conversations.xml.Element; + +public class Reason extends Element { + private Reason(String name) { + super(name); + } + + public Reason() { + super("reason"); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java new file mode 100644 index 000000000..154fadf65 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java @@ -0,0 +1,71 @@ +package eu.siacs.conversations.xmpp.pep; + +import eu.siacs.conversations.xml.Element; +import android.util.Base64; + +public class Avatar { + public String type; + public String sha1sum; + public String image; + public int height; + public int width; + public long size; + public String owner; + + public byte[] getImageAsBytes() { + return Base64.decode(image, Base64.DEFAULT); + } + + public String getFilename() { + if (type == null) { + return sha1sum; + } else if (type.equalsIgnoreCase("image/webp")) { + return sha1sum + ".webp"; + } else if (type.equalsIgnoreCase("image/png")) { + return sha1sum + ".png"; + } else { + return sha1sum; + } + } + + public static Avatar parseMetadata(Element items) { + Element item = items.findChild("item"); + if (item == null) { + return null; + } + Element metadata = item.findChild("metadata"); + if (metadata == null) { + return null; + } + String primaryId = item.getAttribute("id"); + if (primaryId == null) { + return null; + } + for (Element child : metadata.getChildren()) { + if (child.getName().equals("info") + && primaryId.equals(child.getAttribute("id"))) { + Avatar avatar = new Avatar(); + String height = child.getAttribute("height"); + String width = child.getAttribute("width"); + String size = child.getAttribute("bytes"); + try { + if (height != null) { + avatar.height = Integer.parseInt(height); + } + if (width != null) { + avatar.width = Integer.parseInt(width); + } + if (size != null) { + avatar.size = Long.parseLong(size); + } + } catch (NumberFormatException e) { + return null; + } + avatar.type = child.getAttribute("type"); + avatar.sha1sum = child.getAttribute("id"); + return avatar; + } + } + return null; + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractStanza.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractStanza.java new file mode 100644 index 000000000..eef41c791 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractStanza.java @@ -0,0 +1,34 @@ +package eu.siacs.conversations.xmpp.stanzas; + +import eu.siacs.conversations.xml.Element; + +public class AbstractStanza extends Element { + + protected AbstractStanza(String name) { + super(name); + } + + public String getTo() { + return getAttribute("to"); + } + + public String getFrom() { + return getAttribute("from"); + } + + public String getId() { + return this.getAttribute("id"); + } + + public void setTo(String to) { + setAttribute("to", to); + } + + public void setFrom(String from) { + setAttribute("from", from); + } + + public void setId(String id) { + setAttribute("id", id); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/IqPacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/IqPacket.java new file mode 100644 index 000000000..9df05e678 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/IqPacket.java @@ -0,0 +1,76 @@ +package eu.siacs.conversations.xmpp.stanzas; + +import eu.siacs.conversations.xml.Element; + +public class IqPacket extends AbstractStanza { + + public static final int TYPE_ERROR = -1; + public static final int TYPE_SET = 0; + public static final int TYPE_RESULT = 1; + public static final int TYPE_GET = 2; + + private IqPacket(String name) { + super(name); + } + + public IqPacket(int type) { + super("iq"); + switch (type) { + case TYPE_SET: + this.setAttribute("type", "set"); + break; + case TYPE_GET: + this.setAttribute("type", "get"); + break; + case TYPE_RESULT: + this.setAttribute("type", "result"); + break; + case TYPE_ERROR: + this.setAttribute("type", "error"); + break; + default: + break; + } + } + + public IqPacket() { + super("iq"); + } + + public Element query() { + Element query = findChild("query"); + if (query == null) { + query = addChild("query"); + } + return query; + } + + public Element query(String xmlns) { + Element query = query(); + query.setAttribute("xmlns", xmlns); + return query(); + } + + public int getType() { + String type = getAttribute("type"); + if ("error".equals(type)) { + return TYPE_ERROR; + } else if ("result".equals(type)) { + return TYPE_RESULT; + } else if ("set".equals(type)) { + return TYPE_SET; + } else if ("get".equals(type)) { + return TYPE_GET; + } else { + return 1000; + } + } + + public IqPacket generateRespone(int type) { + IqPacket packet = new IqPacket(type); + packet.setTo(this.getFrom()); + packet.setId(this.getId()); + return packet; + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java new file mode 100644 index 000000000..4e7b532bf --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java @@ -0,0 +1,66 @@ +package eu.siacs.conversations.xmpp.stanzas; + +import eu.siacs.conversations.xml.Element; + +public class MessagePacket extends AbstractStanza { + public static final int TYPE_CHAT = 0; + public static final int TYPE_NORMAL = 2; + public static final int TYPE_GROUPCHAT = 3; + public static final int TYPE_ERROR = 4; + public static final int TYPE_HEADLINE = 5; + + public MessagePacket() { + super("message"); + } + + public String getBody() { + Element body = this.findChild("body"); + if (body != null) { + return body.getContent(); + } else { + return null; + } + } + + public void setBody(String text) { + this.children.remove(findChild("body")); + Element body = new Element("body"); + body.setContent(text); + this.children.add(body); + } + + public void setType(int type) { + switch (type) { + case TYPE_CHAT: + this.setAttribute("type", "chat"); + break; + case TYPE_GROUPCHAT: + this.setAttribute("type", "groupchat"); + break; + case TYPE_NORMAL: + break; + default: + this.setAttribute("type", "chat"); + break; + } + } + + public int getType() { + String type = getAttribute("type"); + if (type == null) { + return TYPE_NORMAL; + } else if (type.equals("normal")) { + return TYPE_NORMAL; + } else if (type.equals("chat")) { + return TYPE_CHAT; + } else if (type.equals("groupchat")) { + return TYPE_GROUPCHAT; + } else if (type.equals("error")) { + return TYPE_ERROR; + } else if (type.equals("headline")) { + return TYPE_HEADLINE; + } else { + return TYPE_NORMAL; + } + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/PresencePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/PresencePacket.java new file mode 100644 index 000000000..7ea320995 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/PresencePacket.java @@ -0,0 +1,8 @@ +package eu.siacs.conversations.xmpp.stanzas; + +public class PresencePacket extends AbstractStanza { + + public PresencePacket() { + super("presence"); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java new file mode 100644 index 000000000..78ab66d8f --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/ActivePacket.java @@ -0,0 +1,10 @@ +package eu.siacs.conversations.xmpp.stanzas.csi; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class ActivePacket extends AbstractStanza { + public ActivePacket() { + super("active"); + setAttribute("xmlns", "urn:xmpp:csi:0"); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java new file mode 100644 index 000000000..f109280f1 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/csi/InactivePacket.java @@ -0,0 +1,10 @@ +package eu.siacs.conversations.xmpp.stanzas.csi; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class InactivePacket extends AbstractStanza { + public InactivePacket() { + super("inactive"); + setAttribute("xmlns", "urn:xmpp:csi:0"); + } +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java new file mode 100644 index 000000000..f93b5d870 --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/AckPacket.java @@ -0,0 +1,13 @@ +package eu.siacs.conversations.xmpp.stanzas.streammgmt; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class AckPacket extends AbstractStanza { + + public AckPacket(int sequence, int smVersion) { + super("a"); + this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion); + this.setAttribute("h", Integer.toString(sequence)); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java new file mode 100644 index 000000000..78cd81edc --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/EnablePacket.java @@ -0,0 +1,13 @@ +package eu.siacs.conversations.xmpp.stanzas.streammgmt; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class EnablePacket extends AbstractStanza { + + public EnablePacket(int smVersion) { + super("enable"); + this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion); + this.setAttribute("resume", "true"); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java new file mode 100644 index 000000000..98cfc748b --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/RequestPacket.java @@ -0,0 +1,12 @@ +package eu.siacs.conversations.xmpp.stanzas.streammgmt; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class RequestPacket extends AbstractStanza { + + public RequestPacket(int smVersion) { + super("r"); + this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion); + } + +} diff --git a/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java new file mode 100644 index 000000000..9cdcfa5ec --- /dev/null +++ b/conversations/src/main/java/eu/siacs/conversations/xmpp/stanzas/streammgmt/ResumePacket.java @@ -0,0 +1,14 @@ +package eu.siacs.conversations.xmpp.stanzas.streammgmt; + +import eu.siacs.conversations.xmpp.stanzas.AbstractStanza; + +public class ResumePacket extends AbstractStanza { + + public ResumePacket(String id, int sequence, int smVersion) { + super("resume"); + this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion); + this.setAttribute("previd", id); + this.setAttribute("h", Integer.toString(sequence)); + } + +} diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_add_group.png b/conversations/src/main/res/drawable-hdpi/ic_action_add_group.png new file mode 100644 index 0000000000000000000000000000000000000000..97640355484b5f5f8ad1e6a0a7b3e10fb8a10fe5 GIT binary patch literal 876 zcmV-y1C#uTP)RK++puY0p`9?KzxS+a04!2U4Zw_N^ie;dJ}s*wg;HJMeofs*y9;}jr1jj zJU)DLdAtL-v3Rx@Je^JR+;Q9dah0-~`ZyM%tTpmm<%#?Pdu>~W2 zIRU;;V~=@9)IwboVS;zxvEH(&RKZ?hr}_qqe{wRz29dZQ(uf6sJzUGU_!I9=gwYZt z&j;*Vft+_PC0Icmov--nhL~uF)7grkFHzg%Z6{2Z6}}#5{u|bcYw9X?)pc9F)Rc(> zRz~tKF9b_-_AK!^5g^Y18~TM;L5Dd2m%=?{FqhT-#sHxBQk2KJ2p%69xY}TcpbHcb zl_Hto_KA|c`Pm!ZS&3(vAcPS21D#>c01Qfq+N?~AzSIEM(Pia=tl%%hCP)@{>m~qQ zYWo7#Kgd!TNyd=wmWD=UjZjl#8BwLRmU2K7 zf3YUtb3pk)AV!ADPcN)I%d4z;M7fd-1^}MFwD^7gSTHiM{``uK&aAEq9(n>#SMJa{ zx$4;PXtOH<2(1E8GoNcRzO@2KjCE3#7NKTN>Nx=5IN0`eKn&&(uNfd2fCU(! z43G>!2O#fF-;)8FzBdE31ElXs-pc|EkPMIkQd=_82>}P3?+z3!SqSC8{`_y^c@s^f zMS|lckH;gDZf`<}>)-)^(kr+mz+uJDH{dhyk7+QXhvKO49(dm3_?9nR_sPfE#d4*&>GTE@JJHm*GI$|NtOP_zdC1TL7YUr=n@ z19N7thpv*Ci)Ld7;Ef$X&iS%H+3AGHAd$-o+o~ARfIY6 z&XL@{B0;x?LO0@CFa3skzcpMBmgR0Pb@@ow0~_weqlVse#|yv4`Y#r@vzR-CVoTO9 zDA9R1s9EB(v8NO3a%W%gCVDpd7hnK{lIR`?0bJAo0000~)nSPLy$xy&`@eRi+4`$0u-i@ZSFG}rM%CNWU z-s*Jro6RhXH|rcc`Ad$cF){L=f1CUx8KC9^|2Jt#8@lOto3AQAVadO~_w4NcvY?X- ze$2kIFlU|h%qhz&ZvL(AXIu5C+TcUro>J!cqtRvygz|4Tc-82-dHa3Z{8+}cX#1Px zE}O$@{0(=-FwYY7GGgi!{_&7A=6IH;#OmJ0otjI0RvzF8y>W`K^_W$SH}f=kiTZ%@CcWlz>SV-&cbcz5(=I`I&}eQ^}=yu%B}k<1Q=1Y&R^aMFu2pR zb*^YXHOHNPiGT=qf(r%bLeks`5&{7vF*7I$4>X*6E^A8mTr>efJ@E0d;v+zB*!|Z$ zVwF%Iaw4unkkHT?NXVyz*xYC>30bibh+RVXkU>HtB;<TUu!n(oHu3^#E4{%!fDX5>*93py`ZNJ;{oaQjCpqb(_(Z_$LV&4}NbF0082~ bTYv!o)Zbu6Ia}sl00000NkvXXu0mjfJR_Q8 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_discard.png b/conversations/src/main/res/drawable-hdpi/ic_action_discard.png new file mode 100644 index 0000000000000000000000000000000000000000..703b31f8027859b5810937a5c2da2b97428c68ed GIT binary patch literal 450 zcmV;z0X_bSP)3kAs1qih2LP}sp1f-fdKLlLI zKM9>014mq!#s;4VM$Y&&F`b*7>b5h szZOL!<(tn#I`>~o3WY)u2Oa_p0K4L5a@AHer~m)}07*qoM6N<$g3So4vj6}9 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_edit.png b/conversations/src/main/res/drawable-hdpi/ic_action_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..756db316e07cb4d6f04db480697de524d0bd1034 GIT binary patch literal 765 zcmVx z(n%-(wFGSdaXz1i7#7emr}of~APBP769D)KLjnyiEDMppAaW{tzwa4*jA2~>UStG) zBgu9V0H_Iu2a89A+zQW!tso#kpt7@jxW*jg4EkR2+MtMR;2J#rUAcq+PeJ5BH(3$v zB@x1pzAX?kFjY`ap|1e{0B|`zi$(H+cO$PZ#2x+vpewq~MkDov^(C{-qejroNdB@7 z^plz~`^fwVy4BW}+We=XBcB#HXn`s|{tQ3~-9_K=sgQ#u_C{c*oO8bO3#Q8-H-fGW zYeZCKi7hi##P2V@+-7M2os_JFhTAOo+`1_L-_(WZ%TptdzYur`caZf|Uo+fB`Fm*_ zaT5X;!0RIO2}z_nU3atM zk6oy{Lw&>GGp80{JJ1^NzqP=SrCwTqxa0(NbMUpY5P+-dcw+iXF?W>K*cW(f3$P>H zYEwdDCV9I?Of(L@P7F*hoNb?3{`Jn%Dfl`T$O?R2y2%wSF=S6Oa0TAV0xa``63fV; zW5Q1I;1ay83&j?fPAZUdm*6W2FnDbOG~~5O<`TS(1?m*7IopDnEATc1V$0t-s*5L^ zV_=hVS8%qHdNgg7?rJr+8F6bBgDqJXES!0PfUkwTBqa zF{?w2eS)kfkhongmouvbIE$k9=h6VaDms^7#dw18QA{lSH4yM^OXw~GQw62Oz&VH= zQ{xwCi!B{{^ zFyE0F5J}F^dHbf`%g3y0Z2?3A1bg1af{kP z#xpOg>jEzyYce{~fXR&ZIaZ6=iV~C6`J5|qKkgls48ix9=bwsfII}az;G;IX4l~aU zHQu2oZYd>^*E@XYw^0HF-wx$*AyO6H6P$Rz!F!KzZJ^6WQYJo6asB7IcU264*G<43 z0oYdnn`(ev@eq1ntnS)Oxz~D`j_Z{wceUsqRcwm!7 zOC3(VHU)m41Y{@mjaex$Sw= zWEdp>ISnF!2p|H803v`-4d6?NO)ry$g_?V{Y@n!*#(nESo!9&fQnjRZw0#o~t zn|e|{qsNkXWVz&pxF8Ux4?hD?zH(4L!Q2XKKh6UPn_s$+u^ti;n=JOuPhGo_S_sS3>L|NNdS56hPAgEq(0?2kwA%XIOKr%#mK4;Se8S09DotWbHcv(1tVRaCQ+}dkfHoMS){wmaGg~ zgwjD4gIH++w8V5*TH7HE&Por}^fn`*^k23xgH=Lm3eVWqfw^q%Q~+zsDr`Jh|5(Jz zmS(QCW{}*ut`5`|1!~t=QQU)@ZgCAOpAAhSd*=s+w&H%gfcGX>xsl0000~)nSPMBK|z4!;0>128*SSfO(WZG({C@e<@zQ{J^CA$J^9^h-4+HW z4uu9V;m}>FaPIT6gtre({HFI@`KRUogk{zopr05h*# A?EnA( literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_new_attachment.png b/conversations/src/main/res/drawable-hdpi/ic_action_new_attachment.png new file mode 100644 index 0000000000000000000000000000000000000000..c01c2b3822af20897f596c907282761e7e8a085f GIT binary patch literal 587 zcmV-R0<`^!P)^*`+#ouZTE6t`vL8}-M#&Q@d4!n`T@+|9LYin6euL9v1BE(BY1Wk$ByGz zvSew{g6atT28NuBHGse`fjz|%+>NI;MvQs{ z{{cYnV0-{hNQSzFaR&>*1Mh$j#F1o8%=Er7J0S5q#`#9Fbj{|W5eC!%?|>ZRzjY=W zbq$`vDO9W%p_3UI^96aNSdXKpZ3oIcFH4~k`6q>wv7WD#TksZqGkFBOYIA^fgBP;* zA%owwDI+X+3%&;fRO&Iw6124SrmryBC!r)GF(OE~QE`1W-7CmefKK z>$b4gnX4LkYWV-Yd$qa@Zt6r@v#1HA5 zo<<&lr*w&>z;g5&^cW&@zipAqrEDJ^2}144K(FHK)8=3fRA3&a)> zt;_Ql9ujbPW4=J7Ft?K-kJUL{v6>q^16Kp!1kCf~$CMF0XgYUw!tX0*ljrvOx6FIhtW z4Di*z5I*}EhHPX{1xmAE2@!w*1Rwwb2tWV=5P$##peTx>M%7(_0RX3XJ>7+I`se@v N002ovPDHLkV1nnKjOG9U literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_refresh.png b/conversations/src/main/res/drawable-hdpi/ic_action_refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..45b22282fc9a8955714ccc581926615b21ab9f8a GIT binary patch literal 678 zcmV;X0$KfuP)PBh z(#*x-0~LT~5_ps`Xg351_yTk;?Y2&)sIWeU@nE>t4gq+%fMH2H)>c9Vcs!BZDFFa~ zPvB2UNm>UY$Tva;DR(EJ0iS`UVB1B*dNiMvt)MZo1RkAo%ZwgV&HafJ0u(J`#VrJ$ zGM}-eNOcwyiWLt}Fcew@@5Y44ZiRF?H2@#iz?YghpPLXEFafM30wsXoc#+Hj__1R5 z6Q8nCIck)ueCCn~2t~;Bsvd}ZCm`oK-yPNnf^sfqrsF{>3N z_;I_f&Hus(X5yeO!{{IIiWI!yBx2{xTgE{z-E}2;0zH9iCh#M`04g=hy&bZ$h5!Hn M07*qoM6N<$g8Nk_lmGw# literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_remove.png b/conversations/src/main/res/drawable-hdpi/ic_action_remove.png new file mode 100644 index 0000000000000000000000000000000000000000..58a56e457b0bc39aade617acddcf52b501d495e7 GIT binary patch literal 448 zcmV;x0YCnUP)P4JAQ8dR_a&lVlZKy!ug}vT`lWF+@e-?m3aEez zsDKLml|Y)NTd8w2h>dAu`+jFU8`;ZcCv}}9$+52M2MS&;xzvrsT}s>&T)_3t&OI(r z5)Js3d$p3dcZqvOPoYxkrSWVV%tNMA3w-3-DB&Yud)oH6?Rhr>K_k%tUsP3fqpSgx z1Pb0;03m^dA0!YWfrcL}Fhqg@A0*%-!GgDC2HuEX0Np(W9wyKu5wYNdr_hJfX~BYr z3Uo-&;C~{WhK|6Sbr7KwK#D-eBEBMWC#pctZ?r@ufE0ngO$wdGLIpnEVVM$!4bPS^ zW56>d%vkU&2{R@Ao#mPCGKjsl?eHI=Kd5iCN1?loIzqOP;>% qXmRpIkJnW|1yn!17hKoI8QGX!~*m^_#PXe04cx%%--ZTFyA14Gmmwu;gv|$2 zxQ0c1*h&;@xVvikvml365O(6)A?9dQQ(07}_)*z$d0h1^~N?6?9btxc>Su#Z)2YWI$PL8H2%B-;qg5Cv_Au0jcmS*c76rF1PHD=qcO#5~|BiWF^iQr6!iVZ(}^ zM;hb3iwUjnM5gxke7I33GBwOD%Ciif^)Mr(y#ruCmdd1ci*m&L2_CEpy-ky5vCXMO z(Gt(lm`xSyN=~_Dv8PiI!Wo#5)X|M4wLqBYh3-|a6@i?O8Gud;p$$S90N_BmmB!M$ z;ba6rO%RJ)hhfI%0a$jUvD6)<2Q)n)4-#HSJ(1n<#Kw6PlAcfwzJiCFZ*h9(JO7F7 kpL`a81z-V~r5^zX03bgQxSV;wqW}N^07*qoM6N<$f~DIU2><{9 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_secure.png b/conversations/src/main/res/drawable-hdpi/ic_action_secure.png new file mode 100644 index 0000000000000000000000000000000000000000..4439d1aecdff690a389e5b19921b0bd78b0ab784 GIT binary patch literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWuD@j7pv^jv*Dd-pshj+hicnw({dt#sUVh1P1Yd`=tyL2UsHx zFqSaXhKm(6=sjRCa9}@>9VA>NcyNou;{Wqw^mGH>Y2TSD#3I1rc){MA$Fv~!y5Y_X zCQ}6-J+x(D-K#KtlCJc@{Yy@~OHpsq+{?V+!S*NLZ2j#8jvSS4&~Gm1Qj}qtKf!vR zrAlw6>aiV0O%u$%&$q5%(9C|Fa<=l)tR?48CY9xy-4r(PtWLeVds|ns!nrlo$&;@b zpT5~2x%)?m-r0?dl6yQEf9*McNu6QcrHhu68GG~?7!uA*o$>0&tUIeNF1_B^p_{ZS zdB%j$d0nd8^p0*{81`tz)5!;xskr3`sT2Xj2?=~Caz1Qs;Jl*f=Xa?l1&&|2wyY8i XlDAzMbuP@F22$tg>gTe~DWM4fdvcYL literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_send_now_away.png b/conversations/src/main/res/drawable-hdpi/ic_action_send_now_away.png new file mode 100644 index 0000000000000000000000000000000000000000..505cbe63abc1a4af6e3dd2eef57a82e53edd0e60 GIT binary patch literal 932 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 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_action_send_now_online.png b/conversations/src/main/res/drawable-hdpi/ic_action_send_now_online.png new file mode 100644 index 0000000000000000000000000000000000000000..48676f7bdfd6584d541b65a8d9768703f483e953 GIT binary patch literal 1095 zcmV-N1i1T&P)Bzu(8_v{Ca$>HtFh0dee$K?++Xgir^I z0Wnkz3{0RxLmL=S2WC*<4`5&@NBO4`3!uY^QOBx-mo$5hcFoVMtyR$4PS+ z-#N}_jL*+~KDzhyogL0Bl|NDC?~S{S7S17J5TV$;nWdTea4{^OwNP3ZJ`CSMtRHv( zVzO>#(RsNdEI%l}DgnOjd*z&j(994nhu;%+>Bu8SJcMS3>T=~X17=@(A@&snw zP}Qh)uhqzMuBRIS0O3;jHEC0MoG7o5o`C97rnELPt%iNDBV@JX0) zLyH=(#TEP&v^h6ypSt%2508`2X10O4|YpRg;L*rnjKAx8y#MA&rwgbxWi9#u}cBLb|*ndU%>3+*@>Oa?eB86OTkGYeE|Rv zJ`CR#)yn`KNT+}U;HY+Km>c{c1lwX7tsDwDGT_)D3zSNwG9iD9gTR3uN79*MQb`9N zO1jfL*(P#OJQrHsholNf!|dbPvH0KELE(TMw`+9l&_OT>z)|S{brepRnWUZXLxLRK z0RUTDTM}3xvMmA$q`;1V5LFVXb^Fl*w>zoo#6A6+m~kmLs{|w!_w)rQSmGHh z8yXq~(fS_=#XUU%*MGV`K0a~bwQSX1PFH*`ow%nbVBC#Q0XmdNg+awVJpn{qvYa1v zC}_w|F7yu>}-fK6<^&J2GOBG8neSYNrF zkGQ8BAk5|lHY{<^fjuzT4Q$xro&bRVdHoGQQx71}B!cMa+|y^7+a(|XfXda%m9*cW z&RX2FcLm64B>Y}5D&n5K0BGq%eM*Zvj8?x|UC&=DIRQylmMT-n-oQpw+!K@^lwUdW zf5am$?g`|Aln@{I^r5sUj8;D%ZE;V47}EgGowz3;R8c7Wa^A!}{{e3=9&B8Du*LuY N002ovPDHLkV1kGt>@5HQ literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_activity.png b/conversations/src/main/res/drawable-hdpi/ic_activity.png new file mode 100644 index 0000000000000000000000000000000000000000..613da683aa83c22f21816a53ab77069cd2208616 GIT binary patch literal 3040 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 S!sQPD0000;5spCy@rKA4@e+EicBPe8d-r{x z)7|+nubzA7K2{PERrpkmZck5lPoLkMbNVr}5?X71FWtqu)*bk?i({U=aPFQlZ9Cr) zT5rBq9lDkxEIp@P_1N8;o?nXcLKpk>!o8EWvOWfK*Q$e`2GN=o&t>PIj6AmthKmU) zzRPDE4uBApsH%s;bH~pV;>n|5J1{(|SzeoEtu~M5vlYArg5kmh<)Q1CJ<+^fY!^an zt)m#Uv``;eX#5{iHLqnquc_3VD=e+eur$9vioubKt=6Y=pJh0qw7eSC=DBQm9sycj zz0kTgUHbIN;>w&CrXv^gJn5wuzw+W(1#-?9d1nj7%p^Hy3zl#Z2mqRLnPZhluU2zLZZAq| z|6a-_XOFXQ^YPrG)-0d30dW31qJ#A`w;WgxMM*`qvBZZL ze}Yur;BIT^JMcV6YslDTCWih9OXQF(Xv%dyn*TX1Sxr!H_v7{_-*LT9{zgS>#nH2m zuygbtW|v+>xOFVMcO4UXV|gln#}`T~D#XuEOQzLrFr1+`mG_`A7yJGRK7 z6iC%zy7GJ~j9w5n;%5D>rHQX#`l>Wk-t)jpxS38|kJ3in*n)I-K<8FoVZAZO=Iocq z+9OncojOv#jf>si3BrK@cuk^ads7nRZEw6i-Bp5%xw_CO6Ne?VL z?6^^1=(s(jVuy=$1tfc0*e4N(Oc5fU^mmIj;J+aig5>BLxX-5iUM9-7Q7Vojb%UdG zKL)gqNXMYe1_lPy>)N2{LC#ZE7NP@+=wdmfT82NMR4Q3k|s##jV)MbO0*F zg3QjQ{VFxM7a$4^B}tX>Hz=^ z=O(#*$9LJk?b|F?PjII45~nU6;_T8JU11b5W8A*u@3>|AL#)-$FtPPz58U=6rnY@6I^Q#X9{{H=zQE7k{0_BNrDKE>fKp!K z{|a$R1;C1a(*}awth3U&hD|hnHv53z;%oxP|r(zoFTA<8?+nWBXqk^B0y^zDYes~%p8*mOVk=NPVYMBPDs3_uRwUX_7FmJ-@BNf^=Q=EEIt6`TTndTX{l;- z9uD!Lz5@XY58KL6$c$6WZ0&>>H2C59r+Mf2f2S)t|1qx}d4^^!+3x9!hk5nrPg3hF zuFUb`n?I+vW?Hh~!t#3@`t1)=>ol6{96bDQRM-4~n>e(;MS}sN=~aW8rj;grv+pQ& zVB&8nWX6%6Ma!%4#>sz0`P{T$f)6{fDgi!GaX^U_;S@INp93qvCs zH`jTOC*FLRXI}UZZrOV`Q+w`Wa?6dpa^x30^P3;y>VT`mB_Upb(5=^c=;y1nHJs$F;7< zvO!6OYY)@#RukpIbiSln5l{2ft55UOUqU^2K&4UGc4K;w&lRC&J;yU|{hG&rrKzv` zPq0{q?KfGmf>!JbZWg@pS9$KYFZ1LfO?}NTR455{?)RO-j3wc`uaFrh!k}dWUI;Xt zcXa~l0&0HkT}BF1fP?E485`cuOy%`dK%v4rZ}!uzhCc#ka@|KkfNb7o$CLo9;9b5T zo5#?OIDm#7&%%x=f1acycuh^VAUfJN*IQX<2ocPyr1n6I=(-OHgh*OYY78=Ouv9yV zCuy!bDa6*o#YuFm7&>!<5-k@KsIVYQ%)XoD0_5Bm=D z02fx@_2&!%94pJVa{OUHTHQh4cL`wykcgK6Iv#Gd4ozP~M?7BJFq_&tB1L^|G%sW1Rkd~|?b&d7<1*B{a45ZU~3}wU5S=;hM zXr`4#*8}BZw~N!JMTGUExU0i}Ejsj!tu&UN!+d)Ki|Z#@te)V)%DbFhcpr5c+ps!R zB;1RMVoGwz7e>d!nAGhZR$^&kj9gl4!eB0h#s{iXMz7BPc3o<8OCLS^1X_cW@jtig z^)@68R+R1th=vQ8n>ji;KN3NGbGUD>63=bovQld7AO;1Q9gqmNT+R0-srx~_tjKf| z$L-A9NDNA>pnC*qjR~_YK(`I|VShyEwqf6uZ>c$Fq(k4dhZxGhE|v-kurnaLSWYeZ^afJFHaqQnk?9hcz}d zWq^oBwKPecC=QbD$iolZmJOE3@Rg~5WdG#-SR&H_MFYy$HepGzHN|^YvouQZ= zLuqiV433qt?} zHWs^fDuQFc%Gg*$ ztD=;LC!3sK{*Y7iuN%QNUQ`MrJ_zBmbNmimJBJWHz$k2J5bh8er^x=v`|(tr3(N0u z{KBDVbXuv+;;AOK&R`3Nv7spgPki(i?$e=FKg=$GOG#7%A@J`tdJQ%x=cEGGv@0!U9WZ zT51*7jpr}XJl66Tm=MRFwZ?fI+hwhJftkwNELW#NXv+CXc8~udh0GWkCy!E1rWarK zhalr)gyrfC#q6m6a4Z+c${}^{zgJ7_C=Lp05NKvzW$iQztET}dW;e5?G{r=DihO2- z;_7a5KJMQi4`6 zzxojq8RwB+S)%TsB&<8>{_z&20|K{pIcHe@AOCs_Q=woq%mhG?P{;_$%$U`}I1%5`kPP zg(sWDi#Zau5iJ2W#6cUWWIFt#U-KKFc5Bl*`a|m&(7BH}n5O9Eo~NbTk7lbzII}j} z=q0|gn#ir_u5iC||9)}(kKb0$hiNOgs1H5Hj~34}m#oy@$fck61cj#Q4gTlhal=VC zrjoiVZ9m=ao*}gJC)LagkGFoRg9e20b(OFk4%~4+|FGi#UlkTsVvi@hkEtZ-=X!uL z;kmJ|MLts$mSsgA77pc>5Q%LgVOjCSPAj90IF|T(L2-Nub{WGhYNGg~6$ntCmdD;! zhhKTB^{>EN9e_eeL9)nI`d=A7MIwPU2tWps0Q7t5l>Z0MZB~kD2Wxx)0000)!*@cY#yR0o^_?f|Y$ zVPn9|a>y)&&=hbH7$J5VCA3F9rfyt8V$Z2R8_G#_L#c#rRDX&byH_|`52(LHd-tlD#Ryfa zM}Oh4x9o0tzdMDN=Db#aIIQ=NkwHla4=G!bVBQwf!7cg%tEpm*aO^L z4xx{M%lUkMXm)nC8>mfSjlj3S7@o!NI{C&>Z9a{rzv{^Z6jgdwP0412!l2-=>bK-^aGN z%G!WKgr&a=m@B5I-=b)obE z>x!kn0REEUsOpVvabx@kxB%Q99n90|ldaCCZ7ICy^|b*03E^$CE;b}P^*P`%pt;z_ zD=bsM7yq*)!goWIwh8sAV&Si(Ci~Dh;Vjz-yms}1l#&#~P3os=!)irnEezLM61R)h z9Ikf)XMv+hW2uCyfa_P7&@kaFycO6|E}^%9Z-5Vhhs$_qJ@6{c}r7eD+1yN3mZy74V?CUp;4d_g1~A-c(@y z5yMITb@cm>$Y;R$7tl_1Rz0ztDys&b0iGnh@!bzBG*)&KevrsT|F;8g00HxT?mFN{ z!lQE+@aL-Znv@=kBKNeq2g+V?Q$LA-pUGrk|^-g#g6FvGl=d+z(ryt((Dd!ACELWK$y zD%29n0}&8oBK%s$Y6GYRx4olQEM9S+#OSmj zb1Y6tT0%S(XwVZWOOjF$7idsi%G~IpKyxfENHC~dutD8Ho0APD1sn90`xuy_E~PNM zg8H^DNQ24+n`5yvNP{XhFsL##NU%XVTw-NG8WhvOplHy;{5QeoOa?v7lX?Xk)GJ{o z-I!!8^v7rHYr;ke29-N3R>yg_A?6d5kN#}?f=Z>NJDxlIiW+?E$0b|rekEuJw$8zz zpJ0a%@pq~Jx3lXcWfXJ4u!BiM9`7`)1#fLJhq+IpbSz2Ytka|6HgA!13-#fs)SZnx zNrv2e%H5iueDGkAIJcEx(a-%&5|bW*jV{F5q!JY#_V!$1pjC@^8p_%S}&j*!n#Jv|+K|MIhLwF58ujgYO{N7aS9|u&xw=2+3 zxmkI0n}M6RD>Mgj*%fdSY(Wz`{alh1I^<%c)8|L7h$e4c8k%F#<@E%#MPqX;TD&sn z2(OWJH)xj6#Z}QwDx(INV=>C-_u1rj$zso(%??<67-N4ky)3 zHE2Yw@UEaLgUzw1T8Tle8DLQBN>UhO8DP*@x~UP`IiL#BnBhb-VV5LSs8FGd)L$Fj VK7p!4^Hu-=002ovPDHLkV1n4Q%TfRU literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_received_indicator.png b/conversations/src/main/res/drawable-hdpi/ic_received_indicator.png new file mode 100644 index 0000000000000000000000000000000000000000..b1e3f27486a20085b651510660b0a07a5cf751ea GIT binary patch literal 686 zcmV;f0#W^mP)8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H10xL;GK~y-6tdah+q{2p_ba> z!5;j;lZRH6g5GLx0Z|VggenLgDu@RWX`$d3cu;H(6-7NL5wR3O)RaKdCe5bl?xxx7 zy5m7pYHaf%4!p~}H@}(p7(yw<|5)t593nl$79I#+X9Q+HEKE>a(56YVFY+AmgdF=E zlbqk$4*+^rkL#bxLrp>6L2tl*z!BrT+)e<{zLxGQ@0BxBOe!!9w?l}9-z(g%AjOaB zL~=ZNLW)TR0B9HroeN#&J6jF|fcCxgI5m+PqZ`_32x>nYJkIa1MFGIb=qjo$Y4btjLF3di*2)3zbBmFL04-x9$RfQc|0g9!jrkjev;#qQ`x( zZN}=i?x8x>mAD+$zG-pyv9@Ox!6MKf)XYDZdUKC*Z)-x7Qh1JfB4VF&hHYiT0AQ|} zWyZ!>0YFceUnDLi25KR|?_n=LS^lhikWZOKa}@w8$S+8rs)wvY0Fa%^&KBe9DAlP^ z*)vv*#nj8xbW;!jr0=Hh73b8bAsGo%HRW0beWxO!dg&2n4CPs_*$ UM9b_%mH+?%07*qoM6N<$g0yQUzW@LL literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/ic_secure_indicator.png b/conversations/src/main/res/drawable-hdpi/ic_secure_indicator.png new file mode 100644 index 0000000000000000000000000000000000000000..2a2934fb1f6ab2f8b92e34c3760c13e58a8fa8a8 GIT binary patch literal 294 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|wj^(N7l!{JxM1({$v_d#0*}aI z1_o|n5N2eUHAey{$X?><>&kwQQ$$2T-c%!eFHmT+r;B5V#p$969?FCFh(C$pS)U$ ziPK`$1Ff`7&w1-7G%i2aov~F*Dq(^pv#r39doP;z$WLrwHeg)5+i8l~$;p4%Q-GV+$~ngoLiuFDO^1A5YYJyp00i_>zopr0F~o!Qvd(} literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/tab_selected_conversations.9.png b/conversations/src/main/res/drawable-hdpi/tab_selected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..b8f44c21ea9cd6ef5d9cc62acaf87ef8ee4a46f4 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1i!3HFsuehcLq_jL;9780+lT#WR{s&087#caT xI+*bMQTLtj&|cuc7b7NLmf5BuEEkzs7-nD7VNSI5+yvCc;OXk;vd$@?2>?!!8Fv5x literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/tab_selected_focused_conversations.9.png b/conversations/src/main/res/drawable-hdpi/tab_selected_focused_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..5512dbd3061cfeb970bd17c37cd5f9d56da74922 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1i!3HFsuehcLq_jL;9780+lT#WR{s&087#caT x`b=OsKSAxp2YHT!Tat}xOgQm;OXk;vd$@?2>@dS8Os0w literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/tab_selected_pressed_conversations.9.png b/conversations/src/main/res/drawable-hdpi/tab_selected_pressed_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..e5f1df225b64b19b7c33ef91c1e41f7fd9fa5005 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1i!3HFsuehcLqzpV=9780+lT#WR{s&087#cOP z+}mG&pF>zgL}ZP+?}Uf;0tdDjGx@S)${RJkU}R$OH8f>@X(2rasF%Uh)z4*}Q$iB} D3;Y{< literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-hdpi/tab_unselected_conversations.9.png b/conversations/src/main/res/drawable-hdpi/tab_unselected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..7cd46d63d760d8cd6bbb154e9b1191fb373aac23 GIT binary patch literal 101 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1c!3HD^Kbl$tDIHH2#}JO_+ y_x9J{=MWYV5m}?|JArYN!0CyUdCLgTF6@F`C7RLrw<|qKZzb@&zb?M( zhSTXZpvx0!h*uS9?)!e-DFAZJ4yUBCVabj_EIR>!s6Ry1CMifWmuv)<>!udYS%#$P z-5H=r*}#~zPs&jo@M+AP)McjEaK3It`h6$4nWWFi$oU=lcqI^}sRT1k znoDHERR-XLlu%cmpi#fJM3qqY{w@-_N?{vNPu%B7-T4^d_|+g3U+0F%lqBdA44@!8 znc&z$QuC4yiXPEQ|>)iL~{J%ttk&@Zv60_qT8YGjAyO@J%#O#$Y86CA%p1mKA^{QNHa zfsY>ld8N~_;Vo_2VQqrQ6fX*7MK!Gyr8C)MZj2>>1ju!=;UR;2E<;J1j|L8K3|t14>)yh!ZgfA{{Y&s5w#9Ala{$~(PMnC z17X66HhfJsT>;Q#V1jlp=Rwu$&p9=hWrRH06Zqp Ugb*)q$p8QV07*qoM6N<$f@*LWg#Z8m literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_add_person.png b/conversations/src/main/res/drawable-mdpi/ic_action_add_person.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d8f46a9773894f19219cc1b4adee0aaed0478e GIT binary patch literal 469 zcmV;`0V@89P)5twHw6_%riuPs0{z5rfE zY&%b1TQ&x8`URG=w=*GLxfqZkrvY%unSu~mZ~^E70eBZ-`XHAL=xpik*U)O>)at6X z_^)H*5&(A43-mmeTi8n4m$Ub2I6Y|~Him5peJ6Vvs}m0NuC>}2^1Pz82K8|O7+o=^ zpc#QsT5$}d(R;0^Cnjw6CDn>#Wq=$O8qMG1mo(8*VA|DCy3GPW!H*$Lg=%E)Gn9m- z=)MAj|GW_ZASZtX^_36TPPmyFTuG)~SvfUVYyddKc{Ha6{hz<~QR>kzbSY>xYoHnC z1~-6<B3Edx+z`yTT}}1B}lL?P7vuSInZ&&(9G=H(&oAM&&{*lEq*M~ zaq^diTc0}IY1cX6a{K~YG~buMj~fdZ!cM<@$}neIvNyBA2eFR+$qAhMwtQxp{XwnsAeYfn?40oMn{1_ow^&HFU(vx!X50Xmt%)78&q Iol`;+0ER7O;Q#;t literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_copy.png b/conversations/src/main/res/drawable-mdpi/ic_action_copy.png new file mode 100644 index 0000000000000000000000000000000000000000..7134820209a2219c193c8e08a48c0e1a52aa3368 GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1Ghdf;zLn>}1B}lL`^GIyul#-S%X4I7AI$Kxt?aj^n7Lkn) z1lS|vd=@fSHU1D&boF7DWz}gBVQ4m-E5h7((R}hMJ_TjpFG??im8YmhbeK#@%S`&P zoH1&7CnJC3LUGm$38HL4VHtF4>9Waur;s5{tYCy^X zhxBqrM#j&in8!kfqlTV4U*;DIaLCs{^ql z0WHsg_$Cma1>!A)(gZOM*bk(5$qgl-0~{%h0$vJ2n3o!9;4B3WI7^js0GjvF(|{Bb z8x-%)(9-}g_-|3Av;sQ7msWa+4S_QxMhCc{0)~JIwF@F+YS#r!3igP)o>-REzS-HgGs|dn zw;P)^)Wmt7J9xN!JzAEPT+IQH3-O!QeFR?NNH(YgAouWCSA0GJE7b|xAn_RR(L4js zz(>ij8L&`0Y=euJi9Lq?ogrTVnKtgxxC2=EoJ}E&feD?M%D!vEe~fAX~%p_a@b zW+u2~K@5Rlh>7VL%q$lE1uV0(rw~J8Fo(dL*^(IqGZP~jW`cRZI-b0sc zlU7z=axKk1V6_WHT<&X@J+1rHssn%vNDFF1SIX=i2BO%CSb@jZf)H>4IDqb;7w8xY z2#r{;(jl=$U7Ui26$c2fGtf?<2_30Y;@G{jE@nLf{7oOn-4ZU%=W-u04wl@ONOk~x zY#OUz0j^rWSr3>6N6-`C)1>iSi*RXeF28`zSHUx-;*@Y{eSo7h9o-z@4v(T^#eogr zA!a|a;sD@fLq1;zkF&&8VNHe;=lc=5hYp!qt-)71fSc_cMU%(r8+za|^Am#Zd6S&t zuDRW$wYU{GYI32)X(K+h1H9ez$&Y3hmTvXjrL8ianoE}8hYG%>F^1Yzz&q(4&M7}7 zn%NFcdK64Jx#PL=9l95Zo<*D$a@a=z0y&_AD>;3D@z}1D#$?~xc!KnZ%a_B)H{u*j zlJYE1Fhcd3R@VrW0i6x({Qn!yYQF$BebyG|ET5t$l81PLPKHCEz1Rav?1KymyBL#^ zD9r*cmY`ZVa4zW;Xz3)i>f0bNBzuFcrRgz+zuE1(vSLIB-gC!*iSHWU^e)t3_$ k5Lgz|EVIn=Enfl*0OV7p^G0z|2LJ#707*qoM6N<$g5*NzJpcdz literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_new.png b/conversations/src/main/res/drawable-mdpi/ic_action_new.png new file mode 100644 index 0000000000000000000000000000000000000000..f17e7980ee9aee545cb23bf93e9b21613278514c GIT binary patch literal 185 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1G(Vi}jAr-gYOn2mC5a3}6{c~dRo|E${f{)I<>^0$x%HC5M zDbb1y96PG#E}b*crF~7D(!F;|OdJXg4Qn5Lbvcy#A}ws*s|Kr{TRL)zK;aKt8+M77 g+o>-wVv%4tbz9`!VZkj~Kx-I0UHx3vIVCg!07CUbG5`Po literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_new_attachment.png b/conversations/src/main/res/drawable-mdpi/ic_action_new_attachment.png new file mode 100644 index 0000000000000000000000000000000000000000..1d265aac640c1150acfee6c0c4162002c6de093f GIT binary patch literal 415 zcmV;Q0bu@#P)pyRRu*@TMcTIA zL-|1Cy6$sP13-R(I%rKbaE2kxnEBpJ_Q+ZUgy*RU zcfmTeVt^zE5>7%TN7gUvVb}gM*}o*Y@&9khL)P({&6yHf4SeSGu$S1}nt;qAhQ z#k+dWTTyV4$>$>P%K>$pW%?fS_pIc-FCy{EsadQ0-IQ>#conJfZ+R}1B}lL;%Nk5t{qg_*|5*-Q;-~(eIdP(*TaS++ z>J7t^uEq!<-&d9b-*b*D7(2~p?S86V4qdO6~@JU5~U0~T{nto s_HxTSu-u?gXLgHOiggErLjxm2+i|C-3qI&R2l|V_)78&qol`;+01n@4_5c6? literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_refresh.png b/conversations/src/main/res/drawable-mdpi/ic_action_refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..de008e51a306771d6820dcd24871bb30360e1525 GIT binary patch literal 507 zcmVJNRCwC#SG`WdFc5B07qXg>tYM9m ziH_{7U7Kg18{!q<8N%9?u3g(H60BL>HEUQoqpNf}e0J?L{HZ6M>?W7{?$3AbXmq0g zOhr*BpUm07;152zaiG!!mTTY)IU%n#@D6x`>m6_k{8-m@Bk7z3fZxJ$!$2ve$i~3V zAs1!pT@B{ zmb<_k2gfVe)N*O5T=QUjKn*NdC3EB6G1Ru>R(Wb9I0Y>ON(H&b*7afyXfOgs;Tl`( x6+Tl;&H!%vw(l>OQLO0S`{&|Bf0{l77yyZQf|ZLckgWg!002ovPDHLkV1gTK=LP@( literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_remove.png b/conversations/src/main/res/drawable-mdpi/ic_action_remove.png new file mode 100644 index 0000000000000000000000000000000000000000..342a79de6b7d3f18a7dbf7b97b6c8944e37d575a GIT binary patch literal 282 zcmV+#0pTpO6{U?C(jk_j4kWfkngEf(U{J>J_0T!D@Ue32A%s&x%(XKg8;9rG zqm(yRc%t|ei4vhyd`cXI8rV55iHS`7N*q`VN`$3$NOGGEv=SrY%?4@$sJO^LrN5B4 z*g(Mqq2iE%7ZZevw~V3gChV>Ss+Ul83{@AA>MBMEr8TuN6u;7x^Z1JX*#jQA*_jD% g|9u8S*SG~30I0v97|?&!(*OVf07*qoM6N<$f?e`)$^ZZW literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_search.png b/conversations/src/main/res/drawable-mdpi/ic_action_search.png new file mode 100644 index 0000000000000000000000000000000000000000..4edb1ff92fe4c88a984a299ae6ff643cb6e0cbf2 GIT binary patch literal 449 zcmV;y0Y3hTP)6(JFc6ISQ^2_Z8gK&? zKnh3$Pyh{301bo!NCCP3<^H4r7XSsgfG{hqB`d-vmX_Q-veIaUz4DA5@0g-kVu?Qv zHKk}628~n!8$k2$9q52xrPTdn0ziIb@>n?F0uhb40Fc*6hJO-43nI&NG+qKbp8v+& zN$lIu&Vd{718fp?hHI#{k|Ni<;4MWy65++JbGTNKalezsA9?aSD%B$qhxy)G0`#~?&hQ7DWg!+qj)|D%1X3cJ rV$Ps%4xE82Ipy;Ie~Et^PXPu1!=KTV_gtK200000NkvXXu0mjf`(nR7 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_secure.png b/conversations/src/main/res/drawable-mdpi/ic_action_secure.png new file mode 100644 index 0000000000000000000000000000000000000000..05332ebfa7e867bfde5f59d5d0456ae0d4eb4afc GIT binary patch literal 304 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1Gmpok@Ln>}1B}lL?PH13M-YNC}|NrycORg-q6W!C(Gymly zh6P6zyt^B3Ncg_e5%|s|ThyJ##}LK0X$yNtWuXRRs}tu1!St2y7&0d0J8k--BVz2} zd|2Y7VBHfpPCND=OdlmR@(w87Z%}#fy=JG6$__@$bJZJ9e18<+ZOZO(=rOPNhee&9 zmku$SFfzYm5M$!>=hC#Oytql(?m+jFXO;$4^CnK^ZERb@xYC5n){^^0L*(Mjge#1T z`6NmYtYAK&+qKkVWmF-FCG0p?&92J4fGv@r>mdKI;Vst0Mg-b AWdHyG literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_send_now_away.png b/conversations/src/main/res/drawable-mdpi/ic_action_send_now_away.png new file mode 100644 index 0000000000000000000000000000000000000000..0fdca901a5d8443e450e352629fc618bab00be46 GIT binary patch literal 650 zcmV;50(Jd~P)fmYp0YQc427$HC8vpC8)npkh5;e>STMV=E-st>=T|28e&?4j$z;N6 zu<#1bear1{tm8kF0YF%@oP^PGGC996XaJL7;jxI9^&<`&aA};YmP-Qx{Fsh%r%vyi z&I|}bNvMX=M-pRKEpQHHtbH1T`(fD-oMfs%hJ9$g4f~4Y?V6U{=1h}>q7l#ji zo%q%p@y|@^yVTth%{kT?pOi1cr#ME+Yt_nu+md3F#a=l)w8x_(_P0_)IYVm+fB;x) z)NVry9SzevYM8*m-DoB~)vPgQtTuW_;~OU*#2AR-Ea0n(z^Z{4-`QQ=27rb!=@ie6 z;XEM7oG&*}`L7~ literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_action_send_now_offline.png b/conversations/src/main/res/drawable-mdpi/ic_action_send_now_offline.png new file mode 100644 index 0000000000000000000000000000000000000000..7723f4aa94b7a5eec5fc5baf0ce02de7afebd030 GIT binary patch literal 535 zcmV+y0_gpTP)K}SIY zqM@NiLTrMyBwJtwc_*t#Hxy$>jx%v=XKb!CjqUOIJa=phcm#fpF=PIrYJh+jNfV%7 zHblS`@DMpxDnvjN_;8#n1tMb%l0JdPmTRT~WK<;J3Ao&GZ6S~la1Gq2oHGM@z91o> z4ZLTZ>je@51bhL9IqosTwq}?c03hiZxX5#peIQZ54RE^$AgnpIHK`y`Ko@xP0LTQ1 z0;<3__Vets=GfK@5&;3?6*%_{NQA@zEuak?Zy9;sl0?WFU{^`1C*b0vy5n{WpjXML zuLh$M8FGv?wFH)H8%|OOIKlpy;!o57>KVbV8erd!1#Is4JBr(c18nc-(Ss|1q#AHq zR`3P@=-~GoFvKpkGuQ?2we+ciU4SzW!F^-QEX$-CfTRl0Ei-rx0JMOEjB`iWrOp)` z2W08Lm*6-cP5(#OrJni-&H^YTcmjIcKX{|HEw4UYZob&(n^h{aV zD!&ln_vTtNz<(G91Zi}=vQ^peYF>HN0D51%M=~fOTgFiV--G2vOtxGYP6mg=c$4njB}9U&$NEu;bW8%pj=DD_}1I0k^&ji>S{L}r0YlqvuN z0E$FkYrr76e~!>^ue6W;oR0uYn@hG9^&XR7i5rD`04MiPioD40KY)s+Wu*S&FX0}5 zUYzIh{zu`}!Qmc22$qQK=TC#C=@$IPM&mHeIvW7EN#hmEGg71?ZxZPVe0lW0~ z-tM)B5sm<6+>{W?$ioqUA|<^J8~S^%MiY(zP$(^{I_mKC_zhonX2v_siiQ9H002ov JPDHLkV1nI8ONsyh literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_activity.png b/conversations/src/main/res/drawable-mdpi/ic_activity.png new file mode 100644 index 0000000000000000000000000000000000000000..c8727f572d0fb46ee0a4a8480a69d91586d25d2e GIT binary patch literal 1854 zcmV-E2f_G>P)f6x2Q%+6&mv%9dnvsV|{<+2*AHxRH2 zk%*E?BQ^nICB6*fxQ5- zF5TJP*_~mo?|J%RE_(s#Qfc}mPtJMIocH|y&-I-ns>=UUT-UKzjtxBf;OOwkCWqug zx64&&yRA|+nm*RMv&Uq(V0$>=En)ALyEQ!HL;sTp_UsY6rRk2RxfL5Q@rq8bkG0F2cb>U!0zcX(Kb%#|Hp!cO9b$=_*2+x;+;zm_k<0N! z;_(ODezAAP1b+X)`a}QX&6P&e*NZ)Iu-G(~K1{i<4aa1h$Z&ki>P0($bp_e!3;RBJ z^3}SUmX$XJzxrQ;1E@OmNA@!iJ&eop*rh@L&kuKhZ^bnUy!rm7ja9QNHanW)QtbUN zL?ADN(RMDyj$kC}=^1|GwO5Xd`*R7D6c_)@Z3kpD-bFOke?tfWd^&!JQ$w$ka(7o< zIL-E}5_r9R!?WeVit2dg66eSMbi=^&lFz5=2DZLb^GX9DavQ#F&xj{?Snst3ohF~&xX z3oXoZqY8~>4^v;h+_V4aw;RZ@!0n;-YM}FGZRRVj@3FjL7p0#0Q^4=6`vH%(y~py# zo%mh#sJc;OW2v8en_l9P`G>f>eg}d-N5VpljpL-Tq=>=6^NR{0Kq#3GZc7nM>tDic z`w5jSrQBZ3Z%^KW0%hL0ENOfWfV#4!Sjjrx`E(NiO_i&-HS`n!^}%ICM=d@c+lGo@ ziNR3?wMeI=w%`H3t07WTybPbKs^IQaCdu0;Uchm(<%-AA{QcwIs2V`& z>OMg{nzgV${4SsL{S5%eWccW#56NV*9&n6>8pWtXBoaGS@W7m^Rhhby20V6<{?P*% zlLhKZKF^(=FL`RXEtUnKuq^Lz5+#q5&G`yHTU0jl=Ic)-v{18Oh7w zn>9oY7RF>N!xArPC&}0)Z%2VZ*C!oswJfN4-0P~saln|YszRtWfiXQ)=AQGv6?uvY zwWfh#P%p*-a+&T6Som6_h!{lNs4<0(V+F5+9(@CjX1U%KK3h1d69w0F&vEnFcLpYUHM{)d~(}Wb6~oN zNbvcWzqaeItr{S56MkypJ>^B`(2A32-VN32^QD8!qGm& z%3GdIz=3dg!kw5ia^c+Ik)G~zNA6fM_v?*wD_aEIpn#G{4^bYN~JSB)jk%@$XpCY?6X$WXd(us?O?)X~eG@XubIaO;n2n@i{4 z_n7~grnbyOp=RpinJ^X1#pfwm4>Ke%$s-2n%;}8eV(-`~;As)@-}8`r-SRaZTkgB3 sX<^^la3^1$!R7RSAtF)=jH{~t8z`Z=zh($ literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_indicator.png b/conversations/src/main/res/drawable-mdpi/ic_indicator.png new file mode 100644 index 0000000000000000000000000000000000000000..bb4fee1053e0261d288dee8cb04ab82105341688 GIT binary patch literal 490 zcmVK~yM_bx<*D!cY+YB88U5P@0lj2o46Z6k;AKc6e*CgP?RN zICt<5bnB0Bv~#BxM_pXHbTFj|P7Z>DQK1S6ja~X$qOViJ6WW6tzWctr`|c#pIm9TJ z%cEkknDIPsGaiqhyWQ@k$j3xbsZ`$5>9o;qw^N-?=f*V6i(D>u{cnS=>(+ce9}NbB ziQ_mA)9KVtrBZjIMhYzvLXJzN(!1~bKd$Re0U()78mrani!pZCYPHq?umb?5X`WZB z)mKf^Bwg3{0Az%rO0U=RBuP>^=bIP+7>4nvX_^!&`u+aIw(XA)Q%VmgrT5VdilQ8e zJ{OCH!x;M%Yw>veEV2Y(Et0CLp31VUU^_O^hS_ZPA~KZH6GDg_;vfjHTrQhiU&`n6 zPlS*w%d&O>!1Fxr`~I3S77T~Orfu7|007Q;l!-(_ZZsOaUax;=vspvr!rTUcRIAn8 gLZPt#mzZ;oU-yr>(}!;civR!s07*qoM6N<$f`0eXzW@LL literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_launcher.png b/conversations/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..200daf4c951ab01ef7db40eea006578bd744e101 GIT binary patch literal 2726 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 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_notification.png b/conversations/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..5d1aca1037f82c232c4cae28fe4f6c41d397d8be GIT binary patch literal 681 zcmV;a0#^NrP)JJXw zdp_>D=XcI|-x`&9q5;?pOaLF}8-zNS)T(#X*XlRRcv2x|n$%Xv{~MSDo&#M#1{jR_ z90OW_HejsU3g#nc)M537x+iXrJ2$KM)w(K^G^x+jA$3P+O9aK#0at+9h?;6J2%G}O zf#b7L)Tw?^cP)Ufe=47P*rt9~uPs7PrpXjV1>hENy-I2-qNa*Ps|M$QUqC7`i^B%s zB(N#aH2^1o#z0pGGy}V1@h!knVCFr@=kwjF#bWViZ*Om-N}*7=sah(Piak9&hgFKj zVq3J`4h#&O4ZhK>-VJ-u4xFA!-`Cf7JD1C4a=BctwY7CUK!1P#y=*r7eq>~1IGIeY z14yUS4>Os}yV23n;Z!PBA5ySGR@<66&DZzEpcq0@A?#S*Gg05$-`fkdGR)P$Ve-^t8inVv#<65qJgE z#1q7E73c&u&f&9y@+sOB=!T;AeZZU-PE!4(j;W8;^D+Ao^@BPY`8-k&TE1qp>ZP#B zGvNZT6X>9PKOBhJUjh$+L%?NVCGb7+eGBZZRKY@|Lw%|)*=Y)}1{nVXx8HX#hW$Nf P00000NkvXXu0mjf{?sW= literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/ic_profile.png b/conversations/src/main/res/drawable-mdpi/ic_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..0d056c7ccb28ea462a8999b54eabfecd81579183 GIT binary patch literal 622 zcmV-!0+IcRP)_tBd!q8CGmg&)i=)(J3Onb z6E8XrD>in`4$ms;hpk281eNIvwY`p?*|%e2f$+4#OPvShwb#yq2MM2n`9AoNYOGdA zh-)Ne0auD<-*wy=dx%n8gRh9_bATisxjpw&Bv4vOqj1b0Y4h?n3?L6}O`W*?HpT67%rSHA2Y(wF;O1lJnE(I)07*qo IM6N<$g7^{>=-6~$u*PAB|qpZJhlS&=vBWRRN7tvRP>?wBdE|NfuA!%hAhOV(@= zY}dTb=jO&XEnj@Un$7HQIh?|JNB_wEaayzM4fo6l$)Ee?{h#zN=hkLb9w69ZI^%^y2wz|5p9;);(<>&kwQQ$$3OVWD+4Cs1gMr;B5V#`&oi485EkC0HKh&v%qt zvdwgAN9)qua}!SqAGu~{ev)-hW9!@md4q#73QGiBI$BaL|KXoDL%ibh86NA+zyHtv zbKF+W+27FFNzXa-L$FNEWA!gjjx5z{HjcZ%{l86gT_1O8pu{5ix)$Acrvin{Tz~TK zU%357vaFry!t|OW-18US-*|+NyGL|$++)QrZ#!l44K(W2v)%P}UJ{(keU|U{gIMo( li%y!fTE?9h$*MWXSgox3s^_WCI-vU*JYD@<);T3K0RW2kZBqaM literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/tab_selected_conversations.9.png b/conversations/src/main/res/drawable-mdpi/tab_selected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..09d42dc825e200ea22f36db829bba4373ffe318c GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^EI`b~!3HEJ|NhShq|`lK978ywlT#WR{s+vOD0qtT uVglzHCZ7L|3@T?it}ujo2{Wd&Gctrtkz;;&V6P-l3xlVtpUXO@geCwrS{b_l literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/tab_selected_focused_conversations.9.png b/conversations/src/main/res/drawable-mdpi/tab_selected_focused_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..20af01deafc19358befadd156b979db92945d2da GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^EI`b~!3HEJ|NhShq|`lK978ywlT#WR{s+vOD0s@S uk!#LDtpgwAIUKYk8l@bxGL#$M7%)U0kz?Mj_<1Ez3xlVtpUXO@geCw`9U931 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/tab_selected_pressed_conversations.9.png b/conversations/src/main/res/drawable-mdpi/tab_selected_pressed_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..13a878bed6bfe7407ea56840518fc04cc9cc05cf GIT binary patch literal 102 zcmeAS@N?(olHy`uVBq!ia0vp^EI`b~!3HEJ|NhShq;x%9978ywlT#WR{s+vOD0rmu z|G!#g4~Kw&4RWg)4%aI%tW03ZVpzGLmtoUkhFKF;n7_>ADgbI^@O1TaS?83{1OP-i B9)kb? literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-mdpi/tab_unselected_conversations.9.png b/conversations/src/main/res/drawable-mdpi/tab_unselected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..ad2dbae95ffb85c48def73f87395fe42698832f3 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^EI`c0!3HFsSlX8YDFaUz#}JO_VDb9>W4@H=j_USDE>S6G7^>bP0l+XkKHXRvP literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_add_group.png b/conversations/src/main/res/drawable-xhdpi/ic_action_add_group.png new file mode 100644 index 0000000000000000000000000000000000000000..c493aa5a46324be03003a85c99371076cdb92c38 GIT binary patch literal 1122 zcmV-o1fBbdP)-COR>^x*-*14sv;duK6u$RG?zd_CWo-UZeQ#|ti{=T4XPHTpp(~;JJRz~Yb7>0)r1po=YqAjKsY5qx0Yezujg9d;E zXPVwX<2Lj}AUq5b0GRul)}-ZC1+7~Gq8tnWBs}x))Zvw#gD`R^{FJt>WB!;6C0$y; z$l+bz9GJa~mawezxd0fLK|q$eaxrCfVER=$(tEK9Pr5W#2!2D`N~T%*9ZOaBcr@-f zSVv4?mL3sg6Ip5nPY-miXzc=Y*%lAgnKjeECvK)E$XK!PZq&fjBk>-$0)Q@HIeek? zA40lI|C9*FGlPoOx_pgl{9Q+*d4;jAiECZg&o{Of5C=UaQqIJnOpZ{I8Urv7`b(yz z^}+^#sISr^3plMc+7v<)p{4D!6&M4M8#40iM1toQ04S9sD}*w{0oH(gRaI9=g=M8) z$TZWHjt|eo^hfVKv36MzEK|r}K38BqM(dTOR!6NyG52VLA&uqs~XFQD1y8lJYOVVJe-S~WwG!3Lk- zO$4*d&<~Utf2}IlJiug4wZEwpW7iA-g+6Epie-5EltGDF&KD!y3ak*8`=!dylY zq+aOUM%V0NIm& zJRNmg474fEEhGil-@zz^N-C~b2vNR{ zr=2p3DSXd_O3d#HSi%bKV{;FSD;du)miPR_lQIh+h_&tO7d(?dd)Lr}Zqc$sz~jPC zTbAZb5ouFX6bcVL+m5SXYI{X+<0Gt@B#LKES^|G<6vWc#Z4&^troV%)b~?MS0I|00 zSii_X<^a3~W)oehZ-K!pxI&I7elQer4xfL6=Jt{^vYJCh(yvXSL(SBgD3AKNOapKa zo(W{xpak=~F03kaAiSwIN+G4r4P#AWGV$#kfR<^~Hm!X(t@b(r;0(fxbjMTWSpcvM zLar_0zgb14ZqO=+M;5}*&MZ7HaMqHaFVQWgy4Gnk6HJ1B(A?w;kP`rqqx&>AU!4?y zpno4tv_5{g1mG&@AGK1-*f97NJp^EK5&%$v-3A|wM!|(sI21-vQ~^5p44>9+s_0C{c}tVNa^ng9R*07*qoM6N<$f^5b3uK)l5 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_add_person.png b/conversations/src/main/res/drawable-xhdpi/ic_action_add_person.png new file mode 100644 index 0000000000000000000000000000000000000000..4e8de1b6174932146a6ed7dbdbf366c059f0682e GIT binary patch literal 798 zcmV+(1L6FMP)jLYnwJtvu0igI29&1>c=N3c68xV2%&;)?u zkMMZGQ;)$J)+-QEe>eg_d1K;x%gke8uEQn;37UcONlDO=${VGB2HbaFT!4TLB>`U| z_pL`z-XKS3%GUzW>HQi}rW&l^D`yK>jV#jxr4_^wupp4@i6sGO{uzN>3$7NBQ3-}* zvm+o^bjY~+fKP=`0E~AX`%PtL zvxq%MSX)Q%Z+%Y8Dz5C`V4De^)W7!D|I`)M_nvZWt*}>8c8P)sz>|J!sN!~!r*CSD z!use3`*IjaI5gb*!jORG?5q6NxlRY_9-;1eZ}*Nvyypt0*?DZ)o1^0P-V^NEDPr{P z3BE_dorp7r&I)}+j-K94bCW3Fn*fUl(;-^N#Cx=sL#j-0$hp8HlJKOu8>L3^1K#;s zC4?U1r8pgOCqq}f3YXNw5W#>^pg^Z~_xx4hJB(9YS?TYY{agSQ-#W>QT`V2jvN=@x z9u4NHeS;2E-gIl9mH#xTwkpVwZj!O69fhyZ0m|>*IrTB|+Llm}5s*lE5)!nh*}o)q z)*JUaI4$~Xr~~#YhVVz88APk5G`YYueBZ}9yQ;yp8gBgE2N|Z@bmcIfexdkaMM?lO z}>VHfjQx{A^Hj70e^-nNEdT%j07*qoM6N<$g64W_NB{r; literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_chat.png b/conversations/src/main/res/drawable-xhdpi/ic_action_chat.png new file mode 100644 index 0000000000000000000000000000000000000000..8a9a431411f0b633baabdfc9d7375e98a0dd6628 GIT binary patch literal 310 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5XZg{#lhE&{ob8RE$u<( zQ>J9oG;jX{;v4+#{GIctNt5rE0`&sH!8OnHp3FJlS9H%yb9%V=HAcHnIX^g9 z_wc^o+tB~eRy;uTUN+;^hqhu1xZba2D6Q~k)#0*#IraPfR9mUm8+9Mwl)h}f#l)6b zl{4|)t0$5VRXiB?%QQCsS+)BF>l?|&2g(0=5Nvp#iT_!=86cC_vUhLEcY{7ee&=_%o=+AcPP?&FYad*g4nB>3Q)r2k-q? zYciWSe!KbcVgn{Q7Oly;)|UVVv~GYsI)MbzJLd&T@O~hmWCC(4H(^#8*XsHNZ33$6 z6DWfiKozJv#gFz0ECCF_01UtY%HTTId->eo@W}u&00W9y0--d7Pcb z00013D4d29@BNw{`GK#!IOpow6F|uq^msku)shH!j2+*Z;nkUHI5}5Nsv{e#>K)mw zWRtU)n_#)>@#_cBs7qLY32z9U;|@$3;jm;{jsQ;j zp6rI~B688%utk!+Ns!?Ll3&qdHPOu25DA;93HY2;$~t%TUxm;jx4~Hw&`ubCYk`0V zvh4)PZ`rpE9l!!7Y=gx>1Bl9Kwu(=Ro%!Q1k&?5z%YNvC;tx_dEbp+-cN*UX0D)>h zam8c%$%8L*XMuqr3QKp#{VH+7>)R!ed)c?rsRAe_0kcqm5JDh8o&Zfn*jgl@Q6!*2 z3xEI+fFXz>h!y|=AYiFOu(L>j<`AsW0w4ecm?NN5jN9H2)ckZU=?g6Y0%TDt#9Ue? z;5mtV)2m`1a`Plcl-~Pr;qf~8{#hzj^$!|;@8SbB_Il;ZkNr>5;($tj-3}YOSP1|C h00000oPfOm0|4WXd=hHbFpB^H002ovPDHLkV1n%A=T`s# literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_edit.png b/conversations/src/main/res/drawable-xhdpi/ic_action_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..67e056fef01235120a1f3b94e0d6f548b741b258 GIT binary patch literal 994 zcmV<810DQ{P)SeFw*^{th5gwjC8QDoKX>P=I59ctb!zrq95i0nCE!_X|9xs!$vOj^H^7F-weNQ}S3t)LHA>g2C^L9qI=ZNq)gf;rd0GaPXfxIyf&j zMjJjK5J$ifz?V*T#1L>K@TG07h#YW4@W*S!JOOV#=dWs=YX<)%1o#ZTHUZRjKKKg$ zOL749=vbC^mED|P4ES0EV0oHXf|4o*K7e;7fc5B0R+7%y@bcBMJv}M-It1h;zA0Sz zR8MQv=LxMi@Jxh@I8yAu1Axc0UKnjC4zSefvCbHXTa`yCJ}rk0_bkuI!tr) zBqx&wzN!L3hqXOmkEjx9;L*NS*w?k@M*?tgIRi4Xb7lKls{kdxMA9VZ)C%Z3oO}1M zLXzC;DBw-mzoGe#YB_aDdo%@5yITf6qY#;keIcPn2oz0w7RuobZEr$=p1Hr~$b6>T zU*)-#DS$71O9Gv$YcWtjn)mw3wSW)G-n4G)0<8JlTth%ZKtsS)+g||&0Hot+A@;cO QtN;K207*qoM6N<$f>|BBCjbBd literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_edit_dark.png b/conversations/src/main/res/drawable-xhdpi/ic_action_edit_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8ab436d8746d96fa4e003d3b516bfc3d4960df4c GIT binary patch literal 1179 zcmV;M1Z4Y(P)Yzs0ki6E&g_H zZtn8-_O=&yV#N~KFN*h5UOEUDUOzTD0AbK%0@O86LSf%30+eJ|6Gv9>2nbJUN7uKR zkPIL5fn58^nj8?y^oZqIaaZDbP2885z7zLl|CDXBN%=s|_2inq6$HqGKF9-K$?gNe zO)D|IDs403U37DD?kRq2D4}f)0VJU(ej(saNWi?(46sX30LT*W$bLOW2?&9oPyN@F zf&F}+asQiD-J!IhxY<^^!8$Yi4hxy?ZYoS~F;(KT}ngAp=0dP(NYD(Ng)(vU&0NGw1_>Fj{(`2yxDf`oY_MG}DUS>^FPoq6>LM=YRvH0^tQ1Cqy+yI2_6fi zz+7&RIQemJBmo)0t0a6-#`ODP$qVpKR`4SyASZaWhiDP3&pAJbvd?W5!@&pEM9gy^ zVX%)kKa3ksSO8vYX2%!>j0ikzc@!_JVhQ+>S;Z3Y_bi5nZWFeE*IDE!gaDht=MDh@ zzhN`@+@nDmd38B%hdn5h6jp%G6#^(vqe@V!qGbd4%mgSixTwl*co?&|KI`^x8HcCO z3O-K`XcC~yx7`Y6XQ+x@8^C8GfNwaeDr+f2S19AVGKryM2hR;Ys{)o|(#hLC05?MxkgT)Hd-uS2qK;?H%;Ma}?Hl-o| zhDP%9ndH1Yixj3RU@fLC(-+#-5WvNVg$SAd3WmRq<(8xXwd)JM-13|;vLpgrj^?aM tYC|)WzDZwNmsVP7rIl8i-S$_20RXOO5G<352)Y0O002ovPDHLkV1kOuAU*&9 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_group.png b/conversations/src/main/res/drawable-xhdpi/ic_action_group.png new file mode 100644 index 0000000000000000000000000000000000000000..fa2af49744b978c535cc59a972fc77b0a16afe4f GIT binary patch literal 1048 zcmV+z1n2vSP)ME71D7)4D)ZKb5 z@nGG#JQx5B00sa9fC0b&U;r=x7yt|a_bH$HnDl%;PuOF~#uz`H*hp>LKHe+U<(0%nO;IT1kLGK8<`$++YzQZ0f zVH?Y~tu0k+tk5sbyq6WlFKn(9yUU`$o`}5@HwFNk%$J~_unA`UpAM_o*mww z)J_4=$&5`sF=Z=IP?0MT{e8mW#jBtu)NEq-^MqHT!Qduj&!)hh4I*GG>mXIB@B+MB zWPW3gj%E~??W>k#fge#h+B6!d04~yyhdpy2)Xt&(c?R1VT_z~#MR5G2b9a6!d#K?ouBj0SW*L&YLBfD z`X0C9dmzkHUx%yw3DA!UQpXb7))VxNq07zb@GhZ@$Xamk$n2qcewL+zT@r#TjqZ+$ zRmc^ku*M+?ff;6fy-*LTN{sYNRd)ejv$cK9LX;e!vjWOtMW&b_-)Efjr0Y?$h4z&q zfZImu$QG-Ak`z-XCnEk9FokbDjia$=@uVmZfR$DK5+lZ{wR(vm01?!P(mk_rDFfh8 zQ7ILfb&i2k^x{-cb{vUOVt>f@ISLsS)i)7f#nUR0+~ z#s4d=Y^Zj}i2%>K9`QN_0J-m85ddykd)!6@ zT;qjFtu(0nFR`Qb@2v1z_a SQg==O0000~)xp|PcL4n61(4g;$56@AbHzE3QUk+_FtgUDK zp!zi+YO;!F=<7&!_ahatOTI_)Ce$*l>#3Hx>JBsk12iP%PG5BXTiKs4zn|sZ&S7Er w!T#h);t>uEg+PG`7iU&{%bBF&*?E_Vhhc@a;7?}nD~uqOp00i_>zopr0NlMvD*ylh literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_new_attachment.png b/conversations/src/main/res/drawable-xhdpi/ic_action_new_attachment.png new file mode 100644 index 0000000000000000000000000000000000000000..41cbab203c21ff1c4dab07b2a4848800576b0b03 GIT binary patch literal 753 zcmVe`Px5>F7(Ndy5u&*bRxAVvB)_+>4Dw^c{>CB_tVy2QZR8tSFx54ak1hZ=@=*og zNIu#k*d%$KD1kDMeFli;0Zo(FF#=|Z8p#QCn2^sfTXdI<2V9uE#uQJ6|1UsW$k!5} zP2__GXdC&UMG!hzw2i#C0C-+69Orp<4Q;)u|4j^w=rz%x3{&rclTX~l)SM%Saw zK{n<{Bu4K&0J(kIs)$XVrq7`L>JkFbPJUUp5~WJ$SVh_v7J*`o{G43hUpf?CJJyNK z5${OK?L$4lFQ9;_0R*4mk|r@V`9-Sa>j-d5^L7>y^y0U$%-bZ%`%Zy_1+GTuoGf`? z0ZO_fc>IaMhP;;mxYbZEm>@Cst%_pD}@$%9JYzzg7?VlL9{~mcg!4@;Ny{XR00000NkvXXu0mjf9uY{i literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_not_secure.png b/conversations/src/main/res/drawable-xhdpi/ic_action_not_secure.png new file mode 100644 index 0000000000000000000000000000000000000000..c0902a03e89e77b3974e24bea99e80bd9ef781c0 GIT binary patch literal 482 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+7+XAD978H@y_tQ`_ppITo9wBnj0p{T7g%>O+}X{rPx%gk~5{onq7dUK=Cs#y3s5715s@CjSK zasP&Ouenz~3){DP(k9KWr0@ES9Uqs5Xlr&)E0bR7#rRR|ak;2q&wS6)>uEL$tcBKP zZgYBa;x5 zEN|nx%_kXVznsrJ{mO^tJ7=f2)lCn&D>1=2)^q>&m}d>4&6W34{(a7Sv;L=?;bhhg zJT=Sux2-Z&{hm9|>X?t1Lx#?D+h5aVlb5kO94mY=@p^V^dhY7JjhAEDc|xYB7M@~| zfp|yZF!RK@Z=dp)mt1B@@Qyq$02JPIVF9z?%{f3_kNRz2xSgKN2=zNQ>Vl*5eV)C$ r=C41Pl>Pd`$4C6aFjYR`@=QDo**-xQk9VEU0f~CL`njxgN@xNAIbge9 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_refresh.png b/conversations/src/main/res/drawable-xhdpi/ic_action_refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..cdc160d4c6210f179e79553093ebb2547f0330a5 GIT binary patch literal 901 zcmV;01A6?4P)RCwC#T0L*uFc4)8FYc<;-Dk=K$DH3;rA)DueC=? zXm8L8XnSb+cDvoT^n>sJsJ(mF_M9rl9h1gEE}$huS_XV}M&A>9AH~R^y#UFgg*;+1 zI13=+%Zi92pb+00oP01A0OWDTZ(0Uw3r@@nbeG-*pjp4InXwlbJ0c$w`AFgM zf@Z7=b|qO$Jvhc1KR@YJ046Xsfh3=+V8%SNYpfVoB`b67$P|_{y-~RCD>X5@2NE_lp6Lw}xphu;c=n z1^I<3d76;pxBw+0GiG^PTLe>Vk4!A#0_cZL9$*>+IMaX7iS&)AkMC{z*a(d@XDVW% zGiGi(A5&}H7{Dz}PrepbbOmVjeP3tal>pw7*Ak!>ssHo;vk-u?G%maI7S`FI?K{T<}=*z8~A{PJOq4@66w`lNW$0QwfuuD(_Cmjt0_1EckNy zq-M4olY`CL;cFYTmTDJU;h%=@WfrHpTuB~hKhx>Rg$oph* z$fAy&4H}&r&~lebHa!^h9(t(<(2vg)pId7Zuddi!3;6-;+O#C^LVynqS3#k63dCbF z^+X&#cKF)YOH00000NkvXXu0mjfAz_hF literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_remove.png b/conversations/src/main/res/drawable-xhdpi/ic_action_remove.png new file mode 100644 index 0000000000000000000000000000000000000000..58e2e3b4d86d43293595bfbcdf7aa14d462d7dfd GIT binary patch literal 513 zcmV+c0{;DpP)2M=_pR@i* zPEjZcR^hV+AS76ZFCxHFf_3;}0xTpT!WR`#QUWS`aRDVHAj6vo$dP~!Zzf=k1ds5h z0`8IE6+U}6SYkz=AE7zA$Wy1p8|=~!7V&gK%#^meXz0%8wWZ964n9e$r4n+ z9g84Tf;flor_-F)OM-ZYj|(39WC)F4ccon= z%S0lPNF)-8M88uiMW>g`Wde^Gv^o6>KYu~1pq-(elv3sB0Kmi-@VJMzXz@CNI6~V4 zjMJb2z{GK-H_)bmT@=vX07Nx705JJAJeCcwp}FS`fY2gd0YWhd0OaRe!_Vy%Me{E0 zRfX^A*zkA-2txQ60)VZ<*Grl})zNi&gP`q(K{z<_bpcCl9s z`rTL%YIGCm_nZ>IUBGuXj+O6huF%JZVu#kWMR4sG0Ibr3<*#Lk+5ezd+6}apKBO)E(gmB7F;Y~X8n9j5|zKL!0da@1;E^~=ciKX zI6Sf01@ua#Jh$6@PqyYy60G#^@OpbtCa{zE^7-T8{=ad1r zH2})YOe=UyF(9z4+yZdq;sW+yrj!}R){~>S!Un+J&J1HFD+KhvlsI*X%nJZjZ$GWw zU>#}l6Gy83&MF-?24%*Adn#sho&3&`YJac;KoQwagAu|u#2v`&_l{(%&U#Rnq9_(e zUL0oyOlJ>Gr7Vi}PYwD0Csm600r&xe263dUu5#@0bB}XGQV20FM!Mb#nJI96#UgMg z$jMFcz2{iP(pXnx==)1*zHtC2{jLL^qdI);RJw`*A@=2{uHh{jOkBH2;o9f>grHIe z7)qFIi>TY2?XTQCMTd|SKu374tuZ=;yNs`99`Ef!kK~*Il%BAHAaDloocYxrgCHoz z0L&c0r~2d-6F6Idz9d-%Si*7)F3<%+J{kal5Xw;j@Ptrj9%Zz_0B8vT|GxG)6b%YM z1B9BQzt^>$$_6SU1I@|ji`4&5Boc{4B9TZW8k7DBFaWjb9TtHJeu)48002ovPDHLk FV1n6#bKn2~ literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_secure.png b/conversations/src/main/res/drawable-xhdpi/ic_action_secure.png new file mode 100644 index 0000000000000000000000000000000000000000..4e08b95adc68fcf00e8c4cb32668e4d50f62b442 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+7|T3e978H@y_s<^_mF`|o9EH1+$9WR35;wD?nE}|U8p`S zTiDK8l`q-A8lk{^fzit0S9FBT0mkJAk0{<(kDS(&{Au5wboG~Cf%Y*lSb1JMGGEL! z@d~dW-}A_6f9=*Vu>M_>x5n%fOZ}7u+d^-99Q&8ZqVZ$qu9dSmZH}fLTCTDtTKB-x zU%Pj|{(ms-uuWR#5wQ&`1pfp!vFtgz>)7&+gZ}GT@?Y0JkE%<&bK>mPc{~Lx|Az*B zmfrgHrP6b;lQW|=YO@cixM?0a)llg9VcR_W8tpx{Y4w|vZJy5*GCavu@$qgY>yKJf zRsQ5{jm2u(2ezLNzj$hF^{n=6Tk1~S8=+t}$W(L)!xJ9wXleU3GJYD@<);T3K0RV9%z)JuC literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_send_now_away.png b/conversations/src/main/res/drawable-xhdpi/ic_action_send_now_away.png new file mode 100644 index 0000000000000000000000000000000000000000..bb999d85d4e128d988763034bd6735356693c388 GIT binary patch literal 1180 zcmV;N1Y`S&P)?qyPW_8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H11Q|(0K~#90<(oTm8&w#FpKrAZHui>)Vaz35tb~CSDFRJs(u9IE zX-EMyX&mA>nSmw^X@dC!6q$)6hcKR@`82)QZYD&CszO=rBP?XRW~xOr1ytlZaQ=wbPNj&ZfRbDy;A)@8OqGbHfCus} z$N4Vju~Q);0N@|~1`cu_dj@5d4O!!%d}zHhPc%mWKuK;8@X_FB;wgvJxUD;udhG9ymRM zUh0fMM4EuBz$KqebWz8nh+Y9@IgfDNNAV-5<3U8PfZt?+=iGN$G$(Z&is%&pP?5X9 zl8b@^BkZA6ZHq`3@JOx^bJ1oYYgiVME?`4GMfuuB0cThekuIPluM_aYFemngu!m6N zTiqJy@KgZ+8*&fjF3y5yCMyO{CBz!kvITY|0!;#C~gJNlR8D$lMmJjjEOipP&2F z5fNSx&>%qDZi1i);%FKv`zZVFM!*Akh1Xc*dD~x=wza}6ssW2A1vgEk5VJ*rDmV2R zqqIciyONVz!8>aYy&kp3dIW zW6q&maMqY>ge_iX2RCF3XiyxZUA%w#3~tC4AQY`N<%68T4e0_N%PHW5jlP^SU~oga zfJ4p<)szpEZH`f8Ww79ebOAzK z0S6SlelwVbvJKR@rFEBOdIba{-u^rVH}nb^)dJ`_xS>l0v1gBub7fAKzn#Y
OwbU-4&*8V>s!3~LkVHdy&3vOr%SeJk|SsrNqPiSyMQ^0B7p~3N7M=HB0 zHP-Zri^hD>6!0>~2cRhlZZK=W(N}<`EV!X5pulqG0yw3?4IyP&O8uWfVVB1A1~-HR uUEAM`1UH0eYyQtza6<^h#FS?(xZyvATHvM7m0EuQ0000?qyPW_8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H11sh33K~#90<(oTm8&w#F-}mfFwuz4?WD>`YF7fIDDLg}<2~COw zW=NBU6hM=P3?V6?NdrybUr_K5_yUoIYZLrKH!Z25|Tz2zPd zkrk^{^`xu_Zl5nsn-m`Gf8wNwpopN6D?S8xD&z$#o>JB65fwoZ!FINE+Y<8!yl^B% zP(-kiFI@FR-8|%gkrF`>K|Nc}lJZTD14lxH0)U_EOAQKM^fFM$LQ~{!Jzu=HnJbmz zX)k^#A^?ERT=|ZIywmSQLRl4IHM_C26j^r(&=OG37C$2S$x$a9HW4iWkLE4~#x(Cm zHSw^BQ0~s}YqJ}qG^3iB!axCEHS)##e-H3EqO}45pph@$hj1>YO0|@*a&Ev@M4N!E zT^htT=820Gs$dKIG;ajAlPLa15)#oVVCKD3 zCiV1ToJD(rtfjkZ6CCWmP5wVEw z0y5d(v6;(Fr1r)%)6`9aDdQQfEHxE11sbFlu^Ql802oDvh*(dH zRQ8MLS-@fWh4ely6egd&P+IxaG)-+Sr|c_HFNN7x!jOukiSD$Ch)qB*)Pi7qe8<-+ zt@NJR)5@bZ7E_AUR$huCp%Ba#Ew!;mzkXa>v6`f2vd8s-><{h;74SGWf95Hk-+)ky zf`(87GLcBP5n{mw&zS0LV`Z(|^PzkC6T5Q>ikCqE0(Ubs5L|#U)oe&`N2mZQ31pzQ z@x$SQhrQ$b2^C<;fJ5}A_Zr;MRlwui{22mM(X9T4q5{COfz5v+uyJRceDg3sz8o#)o0j( zJA456kyLjw#kqbKz_12)_yBMxQ@TJ(x|e|>ST>$l&9D8r5>IQ%-~)hCCXhq^ZRDr~ zclZEcttuR}{6{Ug!v_FT=EC;>Q4Q|!AI+4ek?Q=PK?;^V&#O+4XmE#*lnMabdf=o4 scld^;0O5%X?(hLxUwJ>BwBU~a0PdOErcF6>-~a#s07*qoM6N<$g8wFw7ytkO literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_action_send_now_offline.png b/conversations/src/main/res/drawable-xhdpi/ic_action_send_now_offline.png new file mode 100644 index 0000000000000000000000000000000000000000..6da9ff7bd40abf3ffa2437cc91fa4ae1724dc695 GIT binary patch literal 968 zcmV;(12_DMP)?qyPW_8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H114T(hK~#90<(s>1)Ib!5zmph3$VCC6NL;m{0E!eq15FwzNRx&X zK$8X}1O+r{pb794JWF^5o*-8zPmu~!%${V|J9g~dnaQmENtU9lJ#+pqGvoDSSAaLb z-z>}aX(0(9z()}|0RFU1gaFTg^`76gN`wH5z^3K3R)~-+%Mg(*V5Z=lMvG7Z8^H28 z=QdJ=3RnTwDqhnl5h~yg@TKOpjS!&%CV-#7)DUePVnpn@EiDX^WtsUw~ zM6ZCCz$*uBj+KaB0ZYIqhi#9Ah+Y8+@C)$}y z3(tL~L?j|F;34onP~RyLh{y}L0{j3b0@jdX7R%j$eV)=M06;{(0=F-qmXr$+VH2^9%~o<0|GqPqA=} zxoh-^s4bu>A~Ffg(l4b**d;GT*y$7DO+X#GkKuVKHd!;c8&$`^cF5;`D;Enuts>I0bCSEHLS-4huV&DR2xmSM=`Aag|V^=XDkOf-8jrMC3ZKN`I(n zpA~vy2Xh-dvb|U~0v-X6y_6J&(%?#&fF-x}1fx=LrAY#61y`&By7PbDMO6V9D!5WK z0`3oAPcU{dtNKvEl_CK#4}T}Ym2nc_G`KP#Kt$$%8!kH;5ih|N+X#r|ufK~~+)|&N z#3&$0a3v-IL4zxO0=m!TH$xV{1qD~~BcMaLZLB@a;+6*L&q`iEh~Xbaa3!RGsDdk} z0=kcEXwpCv@);-+V$0(h%6tQ>;R8_P#J2nrhI9-S6Qk(%p|DrBtdnEO zd#4xwDvhMw=lTD%yEk${I%V(Iv#@ zdhH%jYa&uQE-Qi)k0*wej|UBp91{@~5kzy*Lqbj^KBLb?P(&~}7v2zH>cD6AsfZl` zla~eYwL^{QGZA(RTp;ATmPYo82#N^!h$^};@d$#!Rwoc}T_~m1nbp-a8cZ9C2ml}| zMz<)EZ+D_z6;Tr~`OCy-;_>sNPS|TAY61##g}|VC&(*}eB0||4=l(kPr;t@w6B80B z!R=@^x*rz9(AB)IsjUD2h-Rbvq>QCeNQ4`rWU<>;M4f=J7+zI^D`^(&HW76K3iE|Y zW2!S*6z&!gbpnEg;KV@e|0E*nfH6)H;f67$RK8PQ$*SgF>I48lem4I-XwGNdNT);6@h34$4@a3eD+Js$VoK$2%ggPIPZR(ElXKCJ0y%S}?lOenX0;6wO$FrV@?Qhy zV<+8ZNUI{63W(;SPYLgs12Yg;-}GYS`ge+VoFO* zL^Ku9jFD#}CkOu?)Kt{)aGIb-3{4sh4;nGGAv7uPKtLxc96*DYin6$0M9TsWUGN#b z@qfmLlv0|YCaGa?8i+NxyP^PSLUO4zk<`~d5vd7igol!ib9)g4*XhTAI) z9yE=d0^O`CDDuzC?FOUuuJ!UE%>dY1-B#$=*9!kb#P0AfWmx1 zL(KawiyVctf?FC_fT;v~em2lCsIa0cxzf_zrHwS}Gzf^{b#(t{72J|^26W{8KkML@ zIsw5#a3Uw(xh%iO=~Qq_oq&PLz?i5wllTNym7iN&y4jiFmijHgIQ8Y zZrO7Ns3Tqf_gZj^4`BP}^5>(mb_DclaElKB;rZ|bzznoBlB&!KOE2!dSWBg|jPLNa3lI8xMlkcU_AcT)!%2qEj|Dk;aJlPpgs+5@xv$Ku=3IPpFyfJ6-zI^>CfO6 zUk$1NvDv-nm;|@@h%pY}*af%vgfZ~EC&w+g<$s?D*#5u^1oQv^002ovPDHLkV1j+> BaLE7w literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_activity.png b/conversations/src/main/res/drawable-xhdpi/ic_activity.png new file mode 100644 index 0000000000000000000000000000000000000000..95ffbecf99eb91f75ffe1082231a228f1a36f2f6 GIT binary patch literal 4349 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 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_launcher.png b/conversations/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..927a2d2a565590bf0545375c86fc6b3e6c59f507 GIT binary patch literal 6503 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 N002ovPDHLkV1o8MkUjta literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_notification.png b/conversations/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..dfa643d058aee5218e2556bc65edbe98977e1a3c GIT binary patch literal 1407 zcmV-_1%UdAP)7+GeBCaG;@MQWH6Wml@1_+x8nwoSLY`|XeC zz1!K@xz2O%yx#kTmvhd0p7XrV_q@;hzR&Z%o+L^c71u!LIAA`o5SRk=0SAHO*PyL2 zA#fY82DlfP3QTfk8sIsZjlhdz(AEg#OJExC6A&9k?rfgzWJSINx_~x}z&}6-a2h!8 z-kkyZfzLsPfF5Z z_gn~I&{j!=lj{SZFX0 zH~}Ppi`T-ku|lv1-o}gv*g=sS4`>1&~Um&PZAikeetAhX;{Pr~7+*dsl_%+!fT> z-QB$@OZy-D#Gbm+N}LUk2P?|U%VW*W&Fcfz54t};2+#8>y1Kev^E|K8S3McHKd7^^ zvT}7i9-kZ1evj|IKXVf#P>zKWBalj^z8Dx7NCm3Z)YR1OdEPi*-M*flo{r%6d-v}B z4Re_cE_8Nw?hDE#lgZZB*4FAP5V!>VnW^-TfiHo@*dSR#z_%+ZDt6Y_*RQUrsaYA1 z#}|~9m3dpXY-!%MZQCJ9eLm51b#-;MwY9bNi9}*SRaI40OH0eawQJYz0sivqOe7MI zH8eCdE?v5GQFV3oytcNs6OE0H-vYmf5SZky7qQI(VE96|8CV6Rf#-k^LzLio-dtcN zHjp}iqmup$k(-Y7W(>IO;}p9 z0)@*_%0Xw_aeuwvg?0m{fNEDfETk?LJOR`KxB4rCvC#Lxa%^L&2Kcp5*5!)eEVKh# zFs1+vg*PWUf<15p_SbL6W{l;)$?IU(RU_B~mw`{PUYG-HD!4@v5ta|!02~2&F*o|A z&_+c;wuELe7JT)Ey(}hVBT%duk9o!*N+BO81GWN(u~~A&%VI|wOCy)K>#!UqR|0R~ zOX~`NKiqW|mi!DN7XmK;>#+^6&wzLyhQ)wafnC4`{F&BpY{z=yMl7TlLJmRI5lLxZ zc63(K50W;>&J(U?7rRc%`}vTXPIn50y|fPW<= zCA~F@j;6qO7Xr^0gN91_#l1`Ve^M?kbOBfg+=-2a16UNB#uD!N?*2;HjO7xCuruTu z;C*ZX?ExlYNxmPMEGz~r%g2Sn%5+IN(sqC0y^^nVZbaopa`V@Ikn_GjrxUGyi(d zxc~-(!C){L3K$aqZMqWfVBcCBt$hXWCf7Z<3 z`H7FAW4oA)`J)KA5_t~!vYpWRl8*hG&sAiydgD{%9^`+W5Z#S*&MM?5CiDB~jjTXE z3PJcx(ls{nH4TV~$g2?upUEr82}u^7I>*(WY~wpix^{UIJL;Z1Qb`q1{PrWLACWzh zBL0WSV=)julgHRL)##4=Er#MdJis=pZS%LpSbV2B+N`xt_!jvp<_>tvHh!Z`{2|Cj z0pdGsWSiCCupsdr9?~NIbmVJ+4j`MSY3O**2o&Gp8R;D|Jwa~-JAiDTD%k-?1&i$3vexYfnBYES@5ynE>3gnHzF0@?Q+WKR?!n}>lC_*ok|1<@zYQP9;> z7kgHNB2Y@wSszZIPWj`AY@(>W2QGH}#Wt(LNMwr-C)9-e>Qk)e0_0o>=6cpMnM z0)NqRw;-HN@IJ_0;GQT#qn_EI97~XGKAb?`*@qky(Av}tWUa^bN+AKDCV-+Q{N@MW zLvDg}tWmz9;xvPu!hK7w)!7%wWstCZ;rTh*;cTQ?>TyVihm`Q-DN`_tUh9jT4C&Gb z^*+w~h;2zE#vmJ@V=I*Rk(YV+`UiL3*C4-$>;|?W(V!=`3Zl)Rq-*WwPT9a12k9m1 zFv}OD_>K9(TUUVa(w4LY{5{`HMj$uQgzxB5PYEynvl`*^bO=Co@kxpzi;yYo(FtQx`&e>sN(KCWE1EM zPjBM>A=XV!ITW@KYZQfESH-bOB7-VHc5R@1nxx9OIiUjLQ3W^3@|G2G5x*=UPky#H zp{&s2Q!ZF*0>yXO${G{|lm~V_!Qwl-&RRr{y(a$u+#9v{<3UCl;wU%y$44bT?WvwN z(X-~fMY7*KJ7Gi4e1@i>$Cc9Lr|{z3H)GI-uG__GD;zWt6$n zVgh@z1wzX5nQ!9yW+>6t2TfStJQ%e2^fR${OjzH%0|EcEE@hgu|NUNLZWe>!`Vt0% g!C){L3(6k!;K-*1}PnRVT=z-@M2BQvF>@Wv>LEU25H zl86o#N*85aSP(=QLDWqVWpx!99gq$bWKa=_)Lle^7L^?&$quZ$?z&pG=ly5?E^2DE zt!7>O?!NbXzxVlvfB1=Wj{jNWc!oJPI4T8)R4&;BH%XMQdA0N725{B8_Dx(m=^#mD z0RXOZ{?$9&o;83|W=-tIV)2Gc-2D zJ`8t$sS92UwgFk*fpb;CsnqafU*d7(QKTl=8tgGNVXWb5ow_Zj)Vkg1*+BA5_+fah zzt!K9>KhY4tybsT%5sm9NCW`dS8X8F5vmEa1^PyNGX~fz?fJ@F<${naOacHsuE)bq zLOTLC0v+l683Xbb=bexomNEc<4uqdY4)tH^Z~2oyBY?V9T_qPPn~Xm8F52Qd+jrz| zJEj3@xoWePT91-Q0sv?sZ7}{i-tezI(*S9ObU>UU%{Ka2Z}hnT*2q65U5Qjdl53Ic zusB(q#r*81=d|bIXp^hfRbgF_v(xC4e%9%I;B7Q>Fu_soT%R-7w#VX@S6WKsBY8{m zic$?kBuJ%lqhJxN0Kge%q3%cn@8Stl03uz+j~83Ci&O0jC!Qpt35LPB%em8{N|nD( zpS(5D?7MF!8~_C0Ip2k7ccj@6lwzgIwcFi9M1pgTbGo%SXMgHK%#*xqHXHzma}FvZ zrLbn&9eJ9(+!!Rhi5@|uAu(T?XJ~AQb-&-+TgExpO@9FZ@LnEEbj3Hb0rtWeSe%xb zso?;ieP&lO3I7QI0N+iYKlVIY&4$@;?+F0taXlJ(5o%61GX($u?^W;1=&MMr7S+O| zjmZGJH+XySV^#nF@SXELNW6{LbH=q1fKT!3nT7*DE;(H}Hab=)MapWj33IeT*6um! hY2ci*aRZE3eFME88hjGMgOUIM002ovPDHLkV1jY)e6auk literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/ic_secure_indicator.png b/conversations/src/main/res/drawable-xhdpi/ic_secure_indicator.png new file mode 100644 index 0000000000000000000000000000000000000000..1f4c9a32e377fc4859013dc6bd8230f720b933c7 GIT binary patch literal 410 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjY)RhkE)4%caKYZ?lYt_f1s;*b z3=G`DAk4@xYYs>cdx@v7EBif85fM4f-QhPQ85kJVJzX3_JdVGeYUsyoDBwE(x`M#O zj7t}mcps14!M>9@L`Kowb3RvL*O#15rACh%Ycl^W>U>%;*SB?wes5a&zccbTUw^dv zRNydSKXaaFJoDCvOw0wU{~2@|^*LE>^^e}Xv)GV5s&kuz^5%^{13u1lG5citL2b2i zURloucCCcOZBImJ%$U=eB)dPLS3k3WwN^v&z6X3Yxy|_gfY!ma zN8T1Og&yo$75qwH=((M_u-L-%vPry5A8H*mR5Oz2?-Z?g`?S05gmfK`%`DAhKA&%7 z*myo>t1z2YJz1h>>&5DGkM7u?z2csAM)vYTTLr22)tA3d+?^6$5EbJXd(qIA^Eumd zw*M;{yZ>c4KYZ-?My*o0p8wC6nA?^t4=!c>W4gK_p`iBDnO7_!${F{`|@&FVdQ&MBb@00ny; A761SM literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/tab_selected_pressed_conversations.9.png b/conversations/src/main/res/drawable-xhdpi/tab_selected_pressed_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..5c2440e4a8c32686d9ae57d80b58eb21ab24eaa1 GIT binary patch literal 110 zcmeAS@N?(olHy`uVBq!ia0vp^Y(Ol;0U|59*B=E^rk*a2Arj%qDGd$(1Ekyxjha~Q z@2|h#p`)mw5#gW8+4kR|A%|W0Le>P?#X=tf8reCwbucj`{&8dGQPFh*8o=P`>gTe~ HDWM4f-qjwJ literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/tab_unselected_conversations.9.png b/conversations/src/main/res/drawable-xhdpi/tab_unselected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..e9ab742e8c44b5ff24c73383f3093cb3b159db57 GIT binary patch literal 112 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ#0V1dK=^Fznb59q?5RT~Nl!k`?0a9*;Moz4b zCPqK}Q#nl@EA}x)|N8&`e^pAPo7*wA87y7JjY2#>R5w%#Ft8MhGdEPQ^#F}v@O1Ta JS?83{1OTsZ9)17- literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/tab_unselected_focused_conversations.9.png b/conversations/src/main/res/drawable-xhdpi/tab_unselected_focused_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..42a2191eef3295a7aa89d3de8b07bc62eada0adf GIT binary patch literal 93 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ#0V1dK=^Fzn6;Bt(5RT~Nl!k`?0a9*;Moz50 p7gWv#L^7r9F*LJYq0{)#k)gAVk9qdrkBmU|44$rjF6*2UngDMG7h3=T literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xhdpi/tab_unselected_pressed_conversations.9.png b/conversations/src/main/res/drawable-xhdpi/tab_unselected_pressed_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..a5a2c25efd72107bfbf82cf58e663faf6a43d8c8 GIT binary patch literal 101 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ#0V1dK=^Fzn9Zwg>5RT~Nl!k`?0a9*;MoldD y_t)R=&{5RTi11J4WLqH1d*jW5Wh`d=OboBB)R@~&|1t&YWAJqKb6Mw<&;$TX-5M?c literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_add_group.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_add_group.png new file mode 100644 index 0000000000000000000000000000000000000000..2b46dbb9aeca4b9b3d01e4f2b2b433a065444c46 GIT binary patch literal 1643 zcmV-x29)`UP)S&}7NhWg&~OaFi+)mMKi zl|l#tfj}S-2m}JrI-)*I^5f%UOn(gEVSg^@bNsb{D9Vul_W~c$ACL4&>62)Dg^|IC z*ik}`NPxZInXoeuye-c3*-#>ONPxcJnQNKTXX5a+jEH6EE-DH@_)Oau;NN{C8J?@3_OUyC(p& zZ`JXdClL7W(gJ24vmFyGLUBcLi`qYeO;!jPgff>03qdkJ`UE9{VMR%R<=&?f?GFhV zUCUO^is-*hp$w8p)>^>Y-d*mpBSQ^d5WU*W#Rdq>{og>`kBDzvn2;PSOO&c=m;Fk= zduYW4f!6;p5I)a1IMM)xPe9W-Mw-GW@VOQ2W&9EV!e3hmP^`fuBuu0g^EJt+mfi>e z*Ri(L-aZh(T@QhnRalac`Lcj{1J`kuOg2=Er6j^Cy|ztjsrT`u5R5V)|AF{n_Q zR?^y7GeEgoVCV##8oX!fnt+7d2V7a`(8dkLJ=R=TICn`VTEL~-8MQsQihxGl1rWsh z);2N>iGaf4V$J~zJ$GmUMKBg1xv>8OCg3PoY!eG8YCFB2}T2wdZg4MkFC`D`}VXbs?UA6<}M}MGY`-+b)TRNSRPvic~3C3TT6xQI} zfL99kwk?ryO*tW#NML#H8!uofCdUN7CYOgW8%405d)EH6MEl=`!^mTp;}0GL2V%nsGBdNVan#+;I%ILlzCSzBpd3u4w3e1Q2jO4lR9 z1@VLRi9(2H2yWZ5$$>jiwWc^BK_Pr*vCkFX(do`Uz}lQ74pD;zCgk!MkD231Cm7N1 z&WXBw+eo_<)~eN>2o^DxTxl+*t*#ImXg-_FL3aYMhy*5m(u^ze9^t!*HGopgD(YAT z2>)1Nm4%5`!&rl+2lJ6es+Ez?DjyF{hqB2ELQf^(mYS_*gDxL4=K;S&5Nv)^QeBO_ z3YNmYKn|)q80T2w))HwfzM0(GSVyGL-W92@h%$eF5>nK<0L>=45hn^HzYu@r?<;p^ za3|pi0v?e1QgZ?092ybOpbNsJ_VO$V#I)x~w=j0)!hrCF$8CpE5)mnC8$Q9TU}NaR zO$1-+5`gF*#Q$NFHVHBTE7I(OT%EgmgKc|V7Q%gcs;LY{ZG*zj*w>*Go6kQw8?;E$ zzO^U8Ti;p~Qo^zrlM~A_uwW4{o|KL$+R}u8wFiyZ*k%W9!muyY1Yhc`AeQocmx1<) z`FRa@Gx99l+V?|x{6yTJO=MaY;P|%U9%?OxMoEMFc;}a^g*N($5F_?=X`!| zJLQ`ppo7cUO;Y7~7r$vGLg8Pyr02Fth*0=;J6KnUJqSPRsqlGFU$uFMICd=j;C>Z= z1;(W2N-e7z(C0tlnT28jOL7~?>RNAtvjEsZj>+v{)++)?csJpjB5A`4Vu#v4B)5lI zUo2okZWD>`YHjR5`(2l^gA2%^Tfif^ZKRMo zO76t;9XS&4dmm%m7X)SM3j)g8?a>8c3_u_ONB{x}Kmrg*01|*e0+0X%5)c@$7-t8$ pfQRSe2m}IwKp+r^KH;YT0|0;J&PG&^m_Yyl002ovPDHLkV1mN<_?iF! literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_add_person.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_add_person.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a58eafc2870f380492f6bb1f1928a32bbbf9d3 GIT binary patch literal 1088 zcmV-G1i$-k7RCwC#T-|XRF%Z@v2w!5$#!ic{fdK!Lzjf?#%J8B^So3i1ZJ4r9vawY(;@SV*qbZ}8lSCD2(WEa~xU}v!c!iuw|ZLEH}`7-nV z2OnxjoeIEK@ZIw7KZRPZMPTw*#>YkP5%x(~i?A<0pI7}?m45^0U2SL=)zMXIM5zUd%FBKb3vGLDww;7Jp7i91vfW z7?T3BhNs{J;PCNL2`+xU!V6o&IS2tb^4Kc5eF3%;^iq-tgNEc2=^M|;S8S;(NiWp& zMr^C(_XOB5mGpB4g9fXA;fc>L!n~}9SpWac7-{bU@TR*^VPNf}1*}v!7_&9|Apo0T zs=~s&?*_g)7Lcj%Fj74?at|u5{yQbSRT?xXZ?aTCKLT*#>Hw;Qq#^(a5EPOaCaD97CO@NTp89Q9rbQ^d4jo3a&|IWY znnZ%7X%4s`>;VWvV?dLN2s2epm-==4&PsnIB*7ZAZrpFiN6R(}z2Jhd2S5M-0zd!& z0zd!&0zd!&0zd$uLkvNI6=nkO1WEt^00000fI#qDfB^t6wO<{j0;t{q0000A7Gke1PeeeCZ z`^^fsyICzGJ^Na}{PBg)j?7#uXY{Xp&#!t`iJw21RQmNDKH;Vg7@R#Wp3valQ*WC8 zLFW07$NP>M1Zk$)Z{2W@k%^;$foZ~$qp=s#y2|D6F>)v{_^xBTzW&8@@teuA$CI)c z^^$*TK8hL*eAbNo`jm4>%h#6dO1^^SJhb zY2DpRYPz2;UU(wZk!tU`|NS}lcK-T3_LrPXWPz42FdWEwGp%CszW4kupKkq-ZpeH1 z!`5%U-?{gm|MS7WmD^%@cFgZ(`jZZ96L4T)R9F!EZD;<3sNKm+_~%4DD;H#7n&s(u zv16K$t3$V7!uyQhA}Q}ZxPq90YFR2+#F7?@d+q$*=h7hJ!oZ=xz#_oF#NqH-`=NZ; z;`4k3dYP%UiVyzVSReT5bG==pWs|bP(utK{%GU%oa40x%t=h?5)v~Bx!}V>G+(AKA zO%L%U9Iu-cSX19L9eT^V^%`sIZRtr(E)9AdEFO+q%N*5`q|1TC1y;>N3JnZX?zZhK z{i&9eyvp!-7VDk)8@tN>d9(bPyK?v0oZ=6@2j1tt_>!u9F(3 zbuWRq^z53J#ecVNhPt`Lu{-GSy^Bv?GlK$*f$Oo>0fu?9F(>4X3aNqwJYD@<);T3K F0RTJkyr%#F literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_discard.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_discard.png new file mode 100644 index 0000000000000000000000000000000000000000..cb1260a4c68d931cb7dbe80341355c1c2438bd9a GIT binary patch literal 765 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 QLjV8(07*qoM6N<$f`u#-@c;k- literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_group.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_group.png new file mode 100644 index 0000000000000000000000000000000000000000..9289b1c8f26cae59fc1166a1f2e71f54dd15ebc0 GIT binary patch literal 1475 zcmV;!1w8tRP)xN?kRog$ zKr#6dSw1I}C#)gBA^8i6^~b=o6k!1Yx*^{y5qchwKk0ycg)u$*>w17BU?_jA7&cs+ zjrm8HX=>LVFz=9jLC?Pbk|BuF&Cr7_D5NU86MS=2tzOAt5}HzbU`rdGSp-dmKTxUf zET@K5T1_ece$rz9qh~eXjhtUX^PAGz>V(HuRd%lh`6~-6 zR=>Kns zB@M4%vI;T_+FQB62KU@8Eo9Q7=$W6&aW_#XdVg`xL*5LeDU zAp8Q>dts!`E;e*$0`wg%%xCDN4_e%(&mn*-*Y^2kdE&_6gl__fQ}TeOMHROIuN|j& z?8sUX4(gQJT-h|^;+%)rG@>pHSNE+Li;ehiYbg3zrP#Rus_Q4MSSM~T|8CZBu^aL6 zF2KZAd^ER?fw^G>TWzfynxl<}uSkG|?FEnO;mh$)8&Sji42=8eW5CV43Wr6{Rq>zR zQQO=v((W^2+l1*%jqY9%wQlQ7a*>tkWFbXvde2P03WX-YTg_?}f#bR&Xszq_&|`b< zs7V%dXQXR$If-SF<&w3j-v3y9KJ&9K>8C`@p~p-y^{|&cTZ!;k)Vm@tUTUEs>(4v*_33yD#quzxKG+(SZfN}btjSB6z`=s1 zH7eatZ^R5C|CC(sOauxQ^PG8Io5WeX5mVPawK-`QI9OQGyHk~>zR0no7@L;V2dk1t zv^q*i^P7wOvq!U{x`eJ%Yf|sKFSW2$^{)H40ywhHNyX-?NcyQ2N;bvcqM)tdJj5ME z09w?9EMrF&y05rKdBzRJ&8^5evtL7m_js-H^Qvl^Qo*Sx?n)kzuYHR!BEcW41gC2v zjf;(Kr5{)jKOlm_g3>#t$~d7g)Tin&EU>_5vg*_j;kIV%jm2TlJ`{&XNX+Yg>Eu_0 zD1BY8T9dA9T`>u~cP0Synf;k=Y2+;nhE|1o#v8t=7k{5**DXeu; z&{-?OQ7E_-{+$&%sFKf4fQ~*#wN8+s002ovPDHLkV1f}1vAzHR literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_new.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_new.png new file mode 100644 index 0000000000000000000000000000000000000000..c42c2bfb58ae772ea54d1f1b7a74a94e2c8a2b2f GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGok|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5X4tcsbhE&{obNe7?gMvsyVz1e%xpP*|%~IQ4!udtP@ymXL>W2A$!YSA2~)w76%4K0S5*a0S5fcdD8Rv zceE*+xnB=)Yj?TB;6H(ZLxCZ%&M`j0O9;PKB(w9LEqJYWi4o`^fwP(i7(N^q{1!7k Q^CifIp00i_>zopr0M9i`&Hw-a literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_new_attachment.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_new_attachment.png new file mode 100644 index 0000000000000000000000000000000000000000..ce7536cbdd755307df1806507498110477cd3d0d GIT binary patch literal 1048 zcmV+z1n2vSP)6BFc`(DAXg1w58wqn zK;66JyU}|*fP8N}Kzp|bsP9b%=m6CM8Xy`#1GxMgs$~gUrYK63$oBxkfI<=B?}_~Q zPht#$AP9mW2!bF8f*@!xC81U5a=9${&x$AdO~vJcr<_ufBoz$k4}8YcvLPG6KT{fu z5lJBMpZL$$7PpiJt97%)71SqE;yD-7rdK0W2{A`UDXirI4$e!&2D@TqVAE5pdNxKneo}fG?>)3-AY@G{0j=+y?XvU-!gUFu)Q% z<>%=KPpM(~G$ETQmvb0k318&DPO|QqAoA;G#Bb3X&;fp}MiUy!r5ms=^79_e6I!ozDEN9-=hJ5@6iCj_c#n46MWj5 zfm zM-u_xqXB^L(Ez~rXaL}QGyw2D8UXkn4QQzFEtqr=>L=j4n}}ta3YQIQD_g*KWB{-9 zlIc&DHEnP&Ow5W2ewP-oy;0#d>+MzS@U09GD~BuPa&EAmQmQRNw4`X^lO8SZS!u4B z9P6XAmuux3Ed`(I?#m~9QLbEOImBtX#Nr>NbCv4Z%Fia78vI^@Gc)a?hJ5mAhfIlU zCtluZj4?nBzD0{2rHq*!0}r~}aWxE;VVO0z+ixjG3E!0g?fSeAup4UP8Y3u{Y+`|L z8-!J*7A91}fRh+irMiF@I(*v}kZXpMX@?e3#R}infWmNSYOPq{yJ`W9>0kK){5}j2 z{kJ;nopEqmz#ncDr2E#wvj7PAeH$P?^`(2&|7c@EtnjwvAm0gKEJbS3N9Ad|0bymxB@MN0>uQmm77CD z_ugurGFA6=eZKg%n4FWnhL%jncz<1+*2{K}Rq$i->67J>#~vRz&GG+&pgoJ=hg4S) zjspIFe$Do3N{Y@7)gSo3P4Ef%d(+E8Az|KwYR!iyH*cAIXW>eh+evJHIO}rw@5}mW zZkh7S>RRf{UCy%a%haZD?BKMpT2q#iIWv<>Q`+T}&Mixw$khE4*z_H)|8#E`++Qxc zQR~s=M7Jfy?}cl2u;s`$%`fv5|8+CuyzHei0VXruOn0|`OFv)PH+S=Ot6y98)-cYh zn^jb5_QYxBbFJiA8~dv4Mewfk}XY!-3(>%;z(2Pq&_0`aQFV#iz&cnF|+4 zg+K$tjC4h#)Th6?&E+RN-&fi%_cSx|;-)BX`OpLJmE2Yu<}XoLhhz?lp}067W~}_b lY?g2mEMgcKIu@;FU}kuAu4M9W*_ibpaZgu2mvv4FO#o$q;A{W@ literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_refresh.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..cb847f3780ffe9c430bdd0e525ad2e48127b7b91 GIT binary patch literal 1274 zcmVFccPC7P6*ey@nC) zn9{LXVa*C#kF|e8t{bemZm_b3m5yZM&WvPDRFH@%(&t~i`}0))#%kwWkS60;UxvCJX9zDs zl(`>feupk3gqWHpGbuqn;b3PhfHggJ0ezuvNShzDAQl$n6RyfwQIG;);?I2gUX6`z zzjS-i?Xp1N_gVn+PP!mXJiYY9Gw~^??^AA#q1Pb!lM_2)dehJdIu z0?_0~On#&QzXD+Lzt?a%6umsBF@9cbuKO8s*m}s}=%D7K4LigzDll!In9KbfFGtfU%${)0U|EQW(&t1D3=q!k>URf$)&xW7631< zm?ED!=$5kpQ_{7RnoEHI?83B1P%R_?j($vYKF^?9&H_x@zm&*$A%Hi0G6?`QH){be zpxI9zkOxFR1#mGJR5$>4HT>j>$8^(H+ zwm-Bdq%B?Z`-3(MU@E<65r~Uw#s%#N`GeL9U`Jre_BRlKEKJ${3Ib?W0BZ<9E(tR& zHYIgtdm#WbjH`V}#N@P}*11g^&0+{Z4r7bYzybud|Eu9+i@K|Q5I{a?o26;1P3_GF z5Wsfrk1X6z;$owjk0QPIF?3X*SqG}P7~;Rw1!}heS8&`dXHU>zNKVt1SA$GaR*oO;B9FxBi()@|# zGu*oDI}`zkkYC}vqFdM27s|1fGXe6;p4>vmoW-tR%!dH#5_jX$=Vr=i$0fc-eqW@z z$AhJjqOKIa&I|hK$dV|h zSgK5EY3iOb889fR`CwMpc#!h>O$lJ5+J8FYzc^na z+G$zOV4a_ty!wgoq_KK8L>Hi2*8{Nfcz$5vB?zx00000 k000000001x#cu%y058#~Dpx%2*#H0l07*qoM6N<$f=F^#1poj5 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_remove.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_remove.png new file mode 100644 index 0000000000000000000000000000000000000000..331c545b8cb07a97ee63cb4f1256d1dba5557a82 GIT binary patch literal 681 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGok|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n7ln*978H@y_sX^mF+0u=KlZOu|s+fxN=%TuH_vu-63%O z0bh(`mh@(>)(|r>v-Oq66AmapP&R+I|NHM}^O&8P#J-=iwAdNbrpXGl5g7zooZfe! zXy=~Q^508s)?R;|&#w7(p8l)j$+D}f&aa<&y-Vlf4wmJ z{m;$!KDWm<)IWJYp?`yVy?a)Rgy+7R7WpU3-7WlQo&Q_^@0!|=9dkD{|8ah%BK}}y zUwJzR`>Fc-=T0VbW}WtqPP|eM_FyzT{_JE!R zD)mzsyaca2=@)ZQY+Cdukl9DlG4$UH#$_D~bm}KDB)bJnw~uTvRI)fR-^3w}Mf~Sv z_7fcsRN|EmXe#eGIi0hhi=*$;Ql>v@O{@PLixhm)QT}Dxr&MO1o`9IAr*$3O_1veg z`?Qpqrz1f--tK_DQpAbv90e{c(m#7YGyP$o^P)53LpC?loVb0lEiVH0g|-+>zh76d zzw5&@{nHH0@veKsKFU^vF}CfCD89*-Dfr;V_F3ly55?}QZh7Ijuejxf>%Q!k7oPi) zTVD9?i*9)lxG%WnMd&{7mKTxxoLgSR?yG8<(OmPC!%+O^D#0YbPom04?mSuT+F^Mr zylcW%?L%qnpM8_#v%0PH_*W!j7~dy$w}_iMj&Eb;mhJQVTeVv6|AD73EMLFabmp+t zH{}!WBYrC$Dt@-J@Me4I8>JZ0$#0vg1>-jHzhy>_Y6gY|m493Y3}5QcuZR|#@EIiM M>FVdQ&MBb@07pA8tN;K2 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_search.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_search.png new file mode 100644 index 0000000000000000000000000000000000000000..a10863887e448e59e0eb34d7d08eb5f273498628 GIT binary patch literal 1152 zcmV-`1b_R9P)No+02}}-E$v#0vapRMt1bOs<(e91z^0$N)!mjH zMGyo*5ClOG1VIo4K@bE%5M*63r;EAWZi)OcreA0BDC8mN_dJf{ZvZfBaAo8<89Qn4 z76s%=$CnR4Vh2W7BRrMIQXZ3zAE!W^B#0{jG&g+u5N1qCg{i|3LBu^7Uxk>Rfe~M) zi#+xcL;(Q*jlZE$-&DCgo@0B`!fVv~Hxhyf8UTeHW&A}0gQ<~wc@;wB=S^v{{-BAw zZnFL@KkpnQ05bluCbM~a0&Ov$?72g*1z`7L|<6>yvBE5F0kcj6zS9{zxP2ELYJ-4Fn<2{gWerk>R+%b7A~{Bk7*?tZBqQ zE0Nc?@)`hzovJ+lV%zu)A*7T^`3K-3E2*4tdyT1g2&alal@qp?9-vDHom8droEfFL z&sxx6@eaVUD$h5OYIe{`^ZLRo0F?PmS{R@&FSu&FVCkC&RJ8?iCdE&t$%v*t0hnnD zF84*wJp>jFuX(Tl60QKsxv1i`i5CE<`qaf=E)qU_KvhbYGfTBu-zBpFUMYDbutm zR*sB6)p%geZIPA{0MvIE8ZVeT_+Gk2TBnGaOcr7XoiTK6YfGblqe(rl9M_2&13<2Q z(S)L79}wg@mMI*b!-~~jr^@{j^essz$gG(1GLdp$0dZ+u;Eqf<5-jS zF8U0Et!!p76l`mXx6YgE2<9qkwtGemiZwS+u(?B~4nhecYqNlhQp`p4-Y!da5UlPI zqeWZ48jR3#|F6&#b!AVd>3-tDKKIKVHKHOAe+<3JwKgm5D=U^D}tJIifqoIfq)VgGOP0Auzv!pl2vf?5jK7N5@=0;|W>ScE*MM1Wn!G>XE|{LS}SZ=r+& zpv!7#s}Mo~&{ftTgaV-83Lz8#C$13UKmc?}N3CW@!2sY2At(TrApGdE89lfxsOEVR z@5~9E*1-muh8l)20RXoU1OT{$(0QS6@Re{!2pQ5)ciEAV$DpAX^qwb_|BFi4fhPzm zzzP897lJalTEk>?g*5=^A(TbdgyQ`bR)HW0f*=TjAP9mW2!bF8f(EL;0t^5du&;5r SwHQ+X0000|s!GPh5 zjO~TP3>OYFNjC5}uqiMbI81Qlm6<4-)Z97$yWjIe)1K6#qy@HMM9m&YwQ5T-#^cX`^1De>Ep}-JHl*Fh%lDq=@OyTTRLQtDf}-x<%gJ zc<%PS?+rJ8$s4uJTP^VA_Zg$r59X!uxVG2d81)#!}IyY2q010}!`njxgN@xNA?fUB0 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_away.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_away.png new file mode 100644 index 0000000000000000000000000000000000000000..12ec4d33f19d18ef0bde48e635cdc671609bcee5 GIT binary patch literal 1426 zcmV;D1#S9?P)ELnGt z|G(SkR?_Nh##?*={LSY0F$r}BfB`ni%oE^25}}J3Z}ATB`NVxmgd%3V#TsxY=dmP0 z0W%(tAv5=Zm4e?%B0ORR+yt&w{B9EA0W07-aKrIAPZ1FZ9_#82R=^eDcFpG|5e;Ak zECIg(=jPZ(60rax03b8p0&gs^ts(Gd5>byy0UrP#H?YkiNrW?#0Lv5II@*Qp9Ni0Gas-c%_9}dMP4LzzyJfE7b^xNNd__u?98r1Y84d zwo=V$dLbfDzzT4`m1^?ow1`;&0GYW1tf^O1PA5ea3HS_nSG^iHj*ydeCqxtpcnkPS zy_$M-LPU{(7lFI#)y&5c(wZK%UC2D-3joN>FThKx)htGoBFY4O3B0XZO*2F#qD;W_ zg00v%0{&uiyxVpmWt0iH1pKU8jUSGY5fP+_VgUe|xd*JORpXZ+MN|p+8hBH!8XbZX zQ6=C#;8V3~bO}O4m4JB`Y{f>aBB}%|0lxz0mFj7eV`SE}qq);^0no&Pt=Lc%;UeID z;1i8v0^fDq;VR%PvT#>(!B%XHkk(X7gqr|>%zT3^++D6+TED0z z!d-wxWVhfJ)$lEXIrXr zQAWtg`r+iAVF;*$ObA>=trF&>;gpE@1h|qJgS57)_IHtX-t8R$4?;Xbn!+F>z}`&3 ztu6im{*7yvB1iQy`R!)@btk$+Y48?z0h##`SSQTB8WK*yTigU>=6O>1Rn!XJ z;yMC0NZ}W;jhtLh=6SNK0AH6ch?#|gwh; zaHh$fMuwN*EiMB3+J3wSZ%G1XQ3>8sEO8lw-gJI4?i~4D)CQH!CT5kz#83HgcW4)mNEgcx1U{P zR&&&LA!U>ai1h^g2sz?|4&Krw0Z|2S$rq5Bmw^{_OPQM}gSQlofEbScJ>-H-Q3h{` zQ9vhxx5Ol%Q^8wi1!QIwxTIf7A$v%VKWw`YALNaIP>%ji2X6^cKre!~gd|`e>G8b? z-f~)iERwyfTthE|w@i7tW#&XdNl_~j~_O;&CW3ycvI~s1z&?E1*G8TKm;td0P6th@lTs7s!!MvaGCnw z=}N&jfE7@u{iNUg~cNbpZQ*3&y&1f<|SG9WW+$VDxtNx>^HVD{)w!7DN;AO+t7 g17HknrQqA)KP^!_fB_5oO#lD@07*qoM6N<$f`W2mhX4Qo literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_dnd.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_dnd.png new file mode 100644 index 0000000000000000000000000000000000000000..7719f81a9a42aeae09dcd7d226ec6c241b6d6162 GIT binary patch literal 1456 zcmV;h1yA~kP)KKRF@^wvqrdaiMsPgLj|_1vF5Q0w{nY z6i@&Kp@11kn1LoKpdegcf@i@z0&Q%^RuaXswuUDV#o6Q0WiMx?J-6NS&8Vy-30Ds zJQhS4pfj0FNTT=wu#xjSL4-!6fZM>0lHUy?G#~}s1a2ul=P5$OKx194K?=AA{8;h1 zK|~Em0c*f-z=b8Y5k#y&XEK=pkVNsC=>7{@VdEjjNp`rG6C-ZADb)3C{Bnd6R^yJE#I&zqD;UV@GEf9R6!OQA!bbn zCU;sW0BTvVMX)>w2Dv>a1Qu``WI~Zh87W} z0sxXI{tkGh&K-M9ts+zfm{_po8>fhq_5LawQ59fv!Ip2VBtliddBnn9lMA+dV~l7` zl|(2B07#Y4-A5oWp^Onk7l`-OE{c!qDCj?X>oe-#q>fz?3VMc^^0#r$&xPxeI zW$o`WcHZqB1A8tWA$8F~i~xD-lr^l-ngt1uJ1M4Nylil3uA&^EkY*hfr5$5{_n`oq%~VOarY1-#64Cz?cI@DgPK zNfdt%Y~yBMbqS^5B}xL4D1M$0ei@a5m#B__9YXkd>?2ODr^`H9Re-L`7x>Id!ApXG z>Kq_i%}T*bN(H3TNgH3>c8ywtm#B<@ZQxv;JM|1L!An#Gw6*hNuhxB zgOZm`6yg8}h*{0Z*z%~5+1WJ`d#1U^acuARnCbu7}!Ar^nG;uki6TwSz1*9K- z?3gP>J5B{JDH;KrG-DB#lfg@h1o+;54iU4O6WfIpQ6#|E6YwL%5$|;Hk|qi8DtJkz zfFz1v0J$)yUxF^IyZ~!}=kSp0000< KMNUMnLSTYAEOQ(H literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_offline.png b/conversations/src/main/res/drawable-xxhdpi/ic_action_send_now_offline.png new file mode 100644 index 0000000000000000000000000000000000000000..1889581322980550b9d312e62980aed751d33286 GIT binary patch literal 1433 zcmV;K1!nq*P)lIJ-h zau3+3xK9${5G~*~aHHYANrVHmfSbTA!^b>Ei8yjt*I>{Bt^q%^d~6b70WDw+_zk$Q z#5R(M6_7m7)ej7y-Yd zUY@W?hB`+aTHbCiI1>OMB6oo6j`o$2L@Y2b;6317$NS7kBF>l>@G|g~r+sH65eot? zpq>w`d92|H>SR4g5i<)C=~!8B0mAo`7LK9q7+dl;8WmrzvWmVDiL)8<`-#f32HTx$Wt^2 zI)vv7yQpdCr0m94e|YgEtSrE?fS38+i9S&qyvA67hyMblLWM957lZ`3SQGFpqNf}@xkrTuo}F^ zWCUyh=WOmYGMogjF%dA-_Tw~oO%kw(m*6$E0*W_EUi48&0PLY=H77oI+B5>L5$`)t zrtG7Rc(1{0ngsN@{d5|xdyrxn>@#e?2zfznNRPdU*5wJ-=7GVV$yrxb- z?CobCHLE%9x{x~R1jKp*eteU+x)2wXjet;&{$2;K2~ofxg4cv3 z;1Jd02NAqxMnJJh_NwO^1{u6&egtgeZvobzgV)Rp@X>w_Q9VBMu&?0pDIf)3!kK{L z-&na=fR%!;VR{5?(PFu}0Y|7FpIO{y@0bqUR{Kf8x4@==6#Np5VC4l^N2nhE*jiNs zLXUu}wEs+33f=-*K%4fHg11N;0S{5ZKXO>l;4l%8f_KPBL^e@hYB5g=-UA~RkNy}D)@eo+c_?;laAWFa;;6}mk1`!5O0&W4fHJ|ewDdNyzU9CY0xCZ=O z^0`4o1t}?;#=U&CAKvL{t6<>F)82!;NuFmIV6bCW>Ua) z;BJN69uhBR@jTSK{03eFRJz(8_Ik_}aM3#WhfSdNq z(c%bsvTlTkECFu=U)e9G6pav(CE!)yf&Fq8;t1KAp445)BBTlch+=UcxMa7SrSMWj zo`5fbckGr^30{fF6EM4A%QudIzx&&RgSrdJBTv8;;1|2)7~vQ>BAgVFDF7gf#jilm zUO7f_Qbdt}uYtGhm16~`L=*{l5BStxIaYB(M3I0+7Hs)Ots;s9tN_0O=WP{KBge>D z)4t7}<_dsH7Hs*3RS`M@-UmLhQH*1pAX}4B5jp}kfgem3;W$P`=mGxXZ&bNskEv9It^gYgwtV9Rd9vPHW+S=+Y%bXHjip5B3OI{gxNCF4mT!!Z zt*Mj^tEfJ*!lp%`6dFG|0-PVwo zOlTsxB8&>qC3(-ONAZZd0GzF<%w-uNPu7no?{q>y8IlQsj;J1PP8y~}cqc%YC>9-L zYb$Dh7pdpn-Vw0t;t^669png5w@${bF8&4n^J_jMhnf>#(55XIspiUV!K>xDh!G<1~qV5vVmc@dTtU|PV7Tz8^Lat`>IQ51+UN&5XItULik0L3SOZ*0yYWZXR(Joxt=WYWL*KKE??j?O9ih8 z0?MdBclvu8=!6CB84=dbe*6@E&u=k literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_activity.png b/conversations/src/main/res/drawable-xxhdpi/ic_activity.png new file mode 100644 index 0000000000000000000000000000000000000000..0b642d9bb579d455b0f5bc235ac772eddde0f6f6 GIT binary patch literal 7209 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 z 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 literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_notification.png b/conversations/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..ee1e95346cac58354d9aec67888d39fad1167f2c GIT binary patch literal 2250 zcmV;*2sQVKP)2|x*W%uhJ=kCtT-TRn3J2Rc^>^HfY`Q16c-|x(s^E>za&N;tvBr%C#Ou5huR61%d z;M@*yIdC%NbJ_XD2?c3?}lC~(T~m|qM021sLqI0!tA*_d~WIL}kSXMq0zUj_bBlu;#>`l^xi z6-f;xoTEijQW29*N`f$Kc?&H%4s8r)atz!OD4*8sl<8iDCq_s;_z zm<@cpkbw*Fq$|<;{yWeN{0cagb$=>wC2$D%WFZ5`{jwhb-ol)|O2c{JTfi59lWrT# z@0ttj20k5o)EEZqB1uOj4ZB8gSs4s`p;x`6XC<9gE|QX(@MI&G6qnt8lQ-*vgN~j7|%yBXLJPffqj@W8i{-a)B=1T zxHRit5?F<8B`O&MzzX0v=Anam?EA>q+m2ANS&}+DDtb!N4HdA2;#_kiosjfDNjJsm z8?_g^A85#Gr+`O+KgIYJD*;{DV?KdxMe;_Nqc|N?&3SHn2=iE1Vm7EM5N3n!B{xjb z3*12!2?v4&bP=!xnC7;JF~b&1JtqQ!1hfg5mDTP6zFT^BDi3}(D1pU-8{M{mCxP{4 zfogX%dL931T3zkh$wm)sgPcV}nk zT@n1Rk_7-q*_AXSn`XRAY>ZL!BArgJavW!Y&quR}Sb{Y*HT8S;?D=w`b9Z!fe9>{7 z=^-R(azn2UR#-8L@e1g7k_N*F+S}XPfe-q8bUK|*?+fdjPN(~K?AVcT9LGXa!VxLE$LB7f0T4bgo?6r=gwQZySq>Nsdy@t>TYgs{x)!( zU*ElZ_tqaieE7i7(9n=i_u=i^xBn8j6YwoUXl-qMu)n`QIrVP0N=rZ(6u;;T65Ty{TQhcD=G~+qO2~bx8*T`p!%w z5;w0~w{FpzHEWt$T3VXw>gwu_969pF#*G^f^!4?f1zIJY3t)dW@LMvG(^v4MsJ#N( z2yDe7ieM4M%fkfZGU{ND<2d#1{Zv$6U`PaMz ze!KZwup;i6ZaazDme9xfM1`LQJr87C{i(oBC& z*RSkvZGRdNJ)A}(!-LjS9Cf!>iUGFpUYC-@5+v6Wuwmx**-35X~;Mq zmSs{$&?P&1kwaH30U>FP>|94rfS`tDaq6U`W0D?|v|iG*nDdfj*=vEJkpDE#6NTP1ev;?BxZYlfK}w~#Y%9}o<1jm!#-m@O>XM>vtuX< z(2}@Ulcakky(C*+NoAHuQg+lc&Uo*W^s1zXBrWv$e72+qB)#ow)qtc;0b?yu?s@y9 zr0Ll-<=(8;Vo$iwxv#=PDy70QEL*$<`zs7DrU37dNtB+(H2?V#g2~;y{kO}e-x7ff}L2kYZ^Nfw>$g8$t7l(gV~-=%)=hVq<|tYxx^yy Yf4cX#4H+3V&Hw-a07*qoM6N<$g89!cIsgCw literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/ic_profile.png b/conversations/src/main/res/drawable-xxhdpi/ic_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..309dc51386e829580d61b203031a5e2540078104 GIT binary patch literal 2137 zcmV-f2&VUmP)3(R6vt0d1d%E(C@NCi5E0S1#0nZE7$Qng11>S55aENn#uyDKU^Fo?0haU)R%g})ta4VLW-8a8b=KbnMM?>)9jhB_7^{M{l(mVq zleLdk$7*P%I{n#B{ayK+SM@Wx>1URpd6P>iYY^)V){kjSVyim+O!@2qtoCT`WN;Mg zGS(#4wvbJePPMG9`eeJ5)f!>&k5;VytOcz75tt^Os#%|ESy~}1ei)}OYgvq@Nv9=R zHWskR7su{WlDBunqdd4#AL=(H~%*L;9YOMjPnWH5R5x z(bKR-Zk)y10mGzdhqevK!UUKlttP-8nUSQhDi|fLsKI^S`GWaKrj#t6q{DTK17RQ1Z#Z; z`w)EyBUo!T>_hY#j9{(Fun*BF7{OXk!9GNHzzEj574{+O0V7zeJM3c)4ZvnXyOUkp z!an9y(uFWYU@at^=z54TFhpQI1KTulH3ds+t=HdAvNG*bk)K}$+ca?uYjXet$?x`~ z)O$^nBH6N&=_LLQgafBim>BR0G&I3&F*eH+2*L04rj3TQs(Ybqa-$~{ku}rV-VX#Sh+Gz(d z$W!PW)=3Gd` zJuj(ETb5;3 z`4@{at<}1L2#{0S7f?94#j^S>d(0nYT|&W@3r=KRNoIrJ3hlJplwm&MvKu@G_ zj|i6Zke^v!uwEyV?Ee6n>_^cf+K+lmJLX&R{Qd)42dr_$h)l})ctz%RN_JYVSIAwW zb0~8%mMf~!vf}S) z0SJK17|Q|LlF+rA>}QE(@0A?J^q78LlxGUcclFTkUP`&|18YgUwJo|6wl`-*79C|t zAnb$3tI2GYkxhznJwRCj%HEaCUiZoRUDYAip-S5Z3$raCX%6?Xc2RVz0kz{`?q9vSAYLXac53cPC)=Xcg1xRGg1lIDx#IF)$_YUG+n(H><} zFs2}wv1y9W#*v)~axg(H0vVfXH3nYp61kYQ2SJQodo&haA-OEe5jwjggZ`O2J|T$$2d% zN7Fy#9(SZ?_>un6&&9saYy`&}&33IXbP<^&auFOqv|nT93>659cdBs3Jdy>s6#?;1 zTgj0OM!D3j4gv8_bsD?ahwifw3~x0n?jgP0!nY5B@K*aYhOsdeLGWI&-q$f50q|ba z;~afeA^_g2GOp2>9AQ$<>H~SLa*Foo8vpC8)X~^@ZWR2Uhl5G((M^Ms3_&2#B%VsENHwddyW&3_i>YX;)C z@b@qevje1$tjL%!04IkMuSY(Lyo$3toLCDCszENgiT=cb0RV9{bxJrb9L|PWGf?n} zvJ=h_^^iGNCgg9z?}cAnYOU>{(B4oxQ%mnN=W0wHnY}pMhA~$21`6&7Hqz^u6Uc$k z;#^M}VR=Y;CUG$lyHgF@Kn0r%3aRz3Zh|Fzi*rLU#N@B@JB2>s_vO^F4dlJwd(2tv zT%QpdfW^_&!Dv_X+vV1=1;n@WTV0iOn>kPaC7lsI5KgV6R?Z+_v(Hc0v&RvMGHPiF zZAKhQ9$IO=oI&mfxi_7KWVJb$uS&;e`)8-}3M$#bw=k7-yE#|F@=wBP;hojg%m5*T zC~uS3+N)$4zGk1_Q_plFXXd4eRji7m;!7A~eKjBw0HAbN>67%`%+boustO0|pi0_G zHpRMP-!HY0ZSou?15OKGnvl9;=VCvs1_N+whnOnnBy|_Hj`WdkL?NfU)^ie7A7&zvV3vqEO9o3T0&X>AqXMDUFUucIgzEg zldg$x%zQaBlM{#waiA;4gn5FhalKI5Uiz3h=Xdh^T;=pebFRkJsmOPck8{BQ01)$+ z=Hv26db^P_ZY-W~Fb>vpAJ-FjHqg9~c^~l>({;?jY=!i=){Fnda?T(C0HH^?oct}Z z8!MRw2{%#fZs5-Ht^8I10B?i$71E#i^#TAGX+sgm#7}d@0GK*Pet+nB?q2uXKtRTA zjdhIGxH?Yy$TEVmwxi0F{PpCK$rd|68Rt;s{pokpS4GQ#h8ze{0jfG11^_^wl+W6M z0m#x*BZP?ZL*;$03i_$#6i}kd^{JO9>sNaanuq1;z!)2;!PIkVRQ-851@s%*XLez- zf&c*G7vl5sW$9^cP90xru^|~!VmQ%l7v{Dg007~+vI8Gl94tv0069FERCmoq60#o y-Jlh{xpLK90M()zZA$xF92HmGeAy1l$M_c^7QnNP%a4!%0000|k1|%Oc%$NbB*pj^6T^Rm@;DWu&Cj&(|3p^r= z85p>QL70(Y)*K0-AbW|YuPggKP7x6W#`Ee~_ZS!$IXzt*Ln02po#DuLNI}3=e(SD_ zOIFJrl&%nc|76qm4?J;;#W!|xuii0JtwmvJ$FZY6P98HPD-P(JIP6>7=)0g+d7sW| zqoS;p{0HPV#NF&Z-|K5O*;0_ZC!ekKHgEhE*#e;%=?STSjE>Gx*}h_Jqn^4h4{U2*ZW@+k4ci{=Z PaANRu^>bP0l+XkKb#<3p literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/tab_selected_conversations.9.png b/conversations/src/main/res/drawable-xxhdpi/tab_selected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..e4439e7c9e7672433c3e4dbad339d5f7d73afbe1 GIT binary patch literal 108 zcmeAS@N?(olHy`uVBq!ia0vp^96&6^!3HF|1ZAaxl(DCaV~9p@a!NzP{{X4ENhvK} zO&K{4{!a)z@IjtqLuI4j3(pPT-9&rZm<=Qa=F5FZm0{qX5ym{pp#;^Kyf_5ul?S(PtLR*3J>lHg?zFjBCyeQ?@>f$2*IbDL%`3(yb- MPgg&ebxsLQ044q(hyVZp literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/tab_unselected_conversations.9.png b/conversations/src/main/res/drawable-xxhdpi/tab_unselected_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..566062f0d3eeb540aea2ecd6c0cbe3a58e4325e9 GIT binary patch literal 109 zcmeAS@N?(olHy`uVBq!ia0vp^96-#+!3HFS!eXZZDHBf@#}J9|lF^9@hBV2c(IfrK82B??TrHSs{F6*K>ZA!u6{1- HoD!M<`O+a? literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable-xxhdpi/tab_unselected_focused_conversations.9.png b/conversations/src/main/res/drawable-xxhdpi/tab_unselected_focused_conversations.9.png new file mode 100644 index 0000000000000000000000000000000000000000..432e68c4fc695e0d9506a4e0a67ae0ed0bc58dfd GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^96-#+!3HFS!eXZZDK$?Q#}J9|FVdQ&MBb@0J|m| AIsgCw literal 0 HcmV?d00001 diff --git a/conversations/src/main/res/drawable/actionbar_tab_indicator.xml b/conversations/src/main/res/drawable/actionbar_tab_indicator.xml new file mode 100644 index 000000000..5598ee424 --- /dev/null +++ b/conversations/src/main/res/drawable/actionbar_tab_indicator.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/drawable/es_slidingpane_shadow.xml b/conversations/src/main/res/drawable/es_slidingpane_shadow.xml new file mode 100644 index 000000000..44ffd4ea6 --- /dev/null +++ b/conversations/src/main/res/drawable/es_slidingpane_shadow.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/drawable/grey.xml b/conversations/src/main/res/drawable/grey.xml new file mode 100644 index 000000000..2e90d96d0 --- /dev/null +++ b/conversations/src/main/res/drawable/grey.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/drawable/greybackground.xml b/conversations/src/main/res/drawable/greybackground.xml new file mode 100644 index 000000000..bedc4b17a --- /dev/null +++ b/conversations/src/main/res/drawable/greybackground.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/drawable/infocard_border.xml b/conversations/src/main/res/drawable/infocard_border.xml new file mode 100644 index 000000000..af7d5d22b --- /dev/null +++ b/conversations/src/main/res/drawable/infocard_border.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/drawable/message_border.xml b/conversations/src/main/res/drawable/message_border.xml new file mode 100644 index 000000000..b35693d5c --- /dev/null +++ b/conversations/src/main/res/drawable/message_border.xml @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/drawable/snackbar.xml b/conversations/src/main/res/drawable/snackbar.xml new file mode 100644 index 000000000..138186184 --- /dev/null +++ b/conversations/src/main/res/drawable/snackbar.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout-w360dp/fragment_conversations_overview.xml b/conversations/src/main/res/layout-w360dp/fragment_conversations_overview.xml new file mode 100644 index 000000000..a600118db --- /dev/null +++ b/conversations/src/main/res/layout-w360dp/fragment_conversations_overview.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout-w384dp/fragment_conversations_overview.xml b/conversations/src/main/res/layout-w384dp/fragment_conversations_overview.xml new file mode 100644 index 000000000..c3aa67ae6 --- /dev/null +++ b/conversations/src/main/res/layout-w384dp/fragment_conversations_overview.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout-w600dp/fragment_conversations_overview.xml b/conversations/src/main/res/layout-w600dp/fragment_conversations_overview.xml new file mode 100644 index 000000000..331fb1f06 --- /dev/null +++ b/conversations/src/main/res/layout-w600dp/fragment_conversations_overview.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout-w960dp/fragment_conversations_overview.xml b/conversations/src/main/res/layout-w960dp/fragment_conversations_overview.xml new file mode 100644 index 000000000..2744f38ef --- /dev/null +++ b/conversations/src/main/res/layout-w960dp/fragment_conversations_overview.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout/account_row.xml b/conversations/src/main/res/layout/account_row.xml new file mode 100644 index 000000000..2d1190a3a --- /dev/null +++ b/conversations/src/main/res/layout/account_row.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout/actionview_search.xml b/conversations/src/main/res/layout/actionview_search.xml new file mode 100644 index 000000000..64b75f0ed --- /dev/null +++ b/conversations/src/main/res/layout/actionview_search.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout/activity_choose_contact.xml b/conversations/src/main/res/layout/activity_choose_contact.xml new file mode 100644 index 000000000..248a7822c --- /dev/null +++ b/conversations/src/main/res/layout/activity_choose_contact.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout/activity_contact_details.xml b/conversations/src/main/res/layout/activity_contact_details.xml new file mode 100644 index 000000000..f7cb2198c --- /dev/null +++ b/conversations/src/main/res/layout/activity_contact_details.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/conversations/src/main/res/layout/activity_edit_account.xml b/conversations/src/main/res/layout/activity_edit_account.xml new file mode 100644 index 000000000..97289628c --- /dev/null +++ b/conversations/src/main/res/layout/activity_edit_account.xml @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +