diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4aea7f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+/*
+!/.gitignore
+!/build.gradle
+!/COPYING
+!/extra
+!/gradle
+!/gradle.properties
+!/gradlew
+!/gradlew.bat
+!/metadata
+!/proguard.pro
+!/README.md
+!/src
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..356a745
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+# Foxy Droid
+
+Yet another F-Droid client.
+
+## Description
+
+Unofficial F-Droid client that resembles classic F-Droid client.
+
+## License
+
+Licensed under the terms of GNU GPL version 3 or later.
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..36c89ef
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,117 @@
+buildscript {
+ ext.versions = [
+ android: '3.4.1',
+ kotlin: '1.3.72'
+ ]
+
+ repositories {
+ google()
+ jcenter()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:' + versions.android
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin
+ }
+}
+
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion 29
+ buildToolsVersion '29.0.3'
+
+ defaultConfig {
+ archivesBaseName = 'foxy-droid'
+ applicationId 'nya.kitsunyan.foxydroid'
+ minSdkVersion 21
+ targetSdkVersion 29
+ versionCode 1
+ versionName '1.0'
+
+ def languages = [ 'en' ]
+ buildConfigField 'String[]', 'LANGUAGES', '{ "' + languages.join('", "') + '" }'
+ resConfigs languages
+ }
+
+ sourceSets.all {
+ def javaDir = it.java.srcDirs.find { it.name == 'java' }
+ it.java.srcDirs += new File(javaDir.parentFile, 'kotlin')
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = compileOptions.sourceCompatibility.toString()
+ }
+
+ buildTypes {
+ debug {
+ minifyEnabled false
+ shrinkResources false
+ }
+ release {
+ minifyEnabled true
+ shrinkResources true
+ }
+ all {
+ crunchPngs false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.pro'
+ }
+ }
+
+ lintOptions {
+ warning 'InvalidPackage'
+ ignore 'InvalidVectorPath'
+ }
+
+ def signingPropertiesFile = rootProject.file('keystore.properties')
+ if (signingPropertiesFile.exists()) {
+ def signingProperties = new Properties()
+ signingProperties.load(signingPropertiesFile.newDataInputStream())
+
+ def signing = [
+ storeFile: signingProperties['store.file'],
+ storePassword: signingProperties['store.password'],
+ keyAlias: signingProperties['key.alias'],
+ keyPassword: signingProperties['key.password']
+ ]
+
+ if (!signing.any { _, v -> v == null }) {
+ signingConfigs {
+ primary {
+ storeFile file(signing.storeFile)
+ storePassword signing.storePassword
+ keyAlias signing.keyAlias
+ keyPassword signing.keyPassword
+ }
+ }
+
+ buildTypes {
+ debug.signingConfig signingConfigs.primary
+ release.signingConfig signingConfigs.primary
+ }
+ }
+ }
+}
+
+repositories {
+ google()
+ jcenter()
+}
+
+dependencies {
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+ implementation 'androidx.appcompat:appcompat:1.1.0'
+ implementation 'androidx.preference:preference:1.1.1'
+ implementation 'com.google.android.material:material:1.1.0'
+ implementation 'com.squareup.okhttp3:okhttp:4.7.2'
+ implementation 'io.reactivex.rxjava3:rxjava:3.0.4'
+ implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
+ implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0'
+ implementation 'io.coil-kt:coil:0.11.0'
+}
diff --git a/extra/launcher-make.sh b/extra/launcher-make.sh
new file mode 100755
index 0000000..3cfaff5
--- /dev/null
+++ b/extra/launcher-make.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -e
+cd "`dirname "$0"`"
+
+dimensions=(mdpi:1 hdpi:1.5 xhdpi:2 xxhdpi:3 xxxhdpi:4)
+res='../src/main/res'
+
+cp 'launcher.svg' 'launcher-foreground.svg'
+inkscape --select circle --verb EditDelete --verb=FileSave --verb=FileQuit \
+'launcher-foreground.svg'
+
+for dimension in ${dimensions[@]}; do
+ resource="${dimension%:*}"
+ scale="${dimension#*:}"
+ mkdir -p "$res/mipmap-$resource" "$res/drawable-$resource"
+ size="`bc <<< "48 * $scale"`"
+ inkscape 'launcher.svg' -a 15:15:93:93 -w "$size" -h "$size" \
+ -e "$res/mipmap-$resource/ic_launcher.png"
+ optipng "$res/mipmap-$resource/ic_launcher.png"
+ size="`bc <<< "108 * $scale"`"
+ inkscape 'launcher-foreground.svg' -w "$size" -h "$size" \
+ -e "$res/drawable-$resource/ic_launcher_foreground.png"
+ optipng "$res/drawable-$resource/ic_launcher_foreground.png"
+done
+
+rm 'launcher-foreground.svg'
diff --git a/extra/launcher.svg b/extra/launcher.svg
new file mode 100644
index 0000000..e9f1150
--- /dev/null
+++ b/extra/launcher.svg
@@ -0,0 +1,273 @@
+
+
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..5bac8ac
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+android.useAndroidX=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..87b738c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..558870d
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..af6708f
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..0f8d593
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt
new file mode 100644
index 0000000..d5cff96
--- /dev/null
+++ b/metadata/en-US/full_description.txt
@@ -0,0 +1 @@
+Unofficial F-Droid client that resembles classic F-Droid client.
diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt
new file mode 100644
index 0000000..2220928
--- /dev/null
+++ b/metadata/en-US/short_description.txt
@@ -0,0 +1 @@
+Yet another F-Droid client
diff --git a/proguard.pro b/proguard.pro
new file mode 100644
index 0000000..c4b4024
--- /dev/null
+++ b/proguard.pro
@@ -0,0 +1,5 @@
+-dontobfuscate
+
+# Disable ServiceLoader reproducibility-breaking optimizations
+-keep class kotlinx.coroutines.CoroutineExceptionHandler
+-keep class kotlinx.coroutines.internal.MainDispatcherFactory
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b33f72f
--- /dev/null
+++ b/src/main/AndroidManifest.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/Common.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/Common.kt
new file mode 100644
index 0000000..03dc6d8
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/Common.kt
@@ -0,0 +1,13 @@
+package nya.kitsunyan.foxydroid
+
+object Common {
+ const val NOTIFICATION_CHANNEL_SYNCING = "syncing"
+ const val NOTIFICATION_CHANNEL_UPDATES = "updates"
+ const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading"
+
+ const val NOTIFICATION_ID_SYNCING = 1
+ const val NOTIFICATION_ID_UPDATES = 2
+ const val NOTIFICATION_ID_DOWNLOADING = 3
+
+ const val JOB_ID_SYNC = 1
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/MainActivity.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/MainActivity.kt
new file mode 100644
index 0000000..9b03823
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/MainActivity.kt
@@ -0,0 +1,21 @@
+package nya.kitsunyan.foxydroid
+
+import android.content.Intent
+import nya.kitsunyan.foxydroid.screen.ScreenActivity
+
+class MainActivity: ScreenActivity() {
+ companion object {
+ const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
+ const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
+ const val EXTRA_CACHE_FILE_NAME = "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
+ }
+
+ override fun handleIntent(intent: Intent?) {
+ when (intent?.action) {
+ ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
+ ACTION_INSTALL -> handleSpecialIntent(SpecialIntent.Install(intent.packageName,
+ intent.getStringExtra(EXTRA_CACHE_FILE_NAME)))
+ else -> super.handleIntent(intent)
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt
new file mode 100644
index 0000000..ed703e9
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt
@@ -0,0 +1,176 @@
+package nya.kitsunyan.foxydroid
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.app.job.JobInfo
+import android.app.job.JobScheduler
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInfo
+import coil.Coil
+import coil.ImageLoader
+import nya.kitsunyan.foxydroid.content.Cache
+import nya.kitsunyan.foxydroid.content.Preferences
+import nya.kitsunyan.foxydroid.content.ProductPreferences
+import nya.kitsunyan.foxydroid.database.Database
+import nya.kitsunyan.foxydroid.entity.InstalledItem
+import nya.kitsunyan.foxydroid.index.RepositoryUpdater
+import nya.kitsunyan.foxydroid.network.CoilDownloader
+import nya.kitsunyan.foxydroid.network.Downloader
+import nya.kitsunyan.foxydroid.service.Connection
+import nya.kitsunyan.foxydroid.service.SyncService
+import nya.kitsunyan.foxydroid.utility.Utils
+import nya.kitsunyan.foxydroid.utility.extension.android.*
+import java.net.InetSocketAddress
+import java.net.Proxy
+
+@Suppress("unused")
+class MainApplication: Application() {
+ private fun PackageInfo.toInstalledItem(): InstalledItem? {
+ val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty()
+ return InstalledItem(packageName, versionName, versionCodeCompat, signatureString)
+ }
+
+ override fun attachBaseContext(base: Context) {
+ super.attachBaseContext(Utils.configureLocale(base))
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val databaseUpdated = Database.init(this)
+ Preferences.init(this)
+ ProductPreferences.init(this)
+ RepositoryUpdater.init(this)
+ listenApplications()
+
+ Coil.setImageLoader(ImageLoader.Builder(this)
+ .callFactory(CoilDownloader.Factory(Cache.getImagesDir(this))).build())
+
+ updateProxy()
+ var lastAutoSync = Preferences[Preferences.Key.AutoSync]
+ var lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable]
+ Preferences.observable.subscribe {
+ if (it == Preferences.Key.ProxyType || it == Preferences.Key.ProxyHost || it == Preferences.Key.ProxyPort) {
+ updateProxy()
+ } else if (it == Preferences.Key.AutoSync) {
+ val autoSync = Preferences[Preferences.Key.AutoSync]
+ if (lastAutoSync != autoSync) {
+ lastAutoSync = autoSync
+ updateSyncJob()
+ }
+ } else if (it == Preferences.Key.UpdateUnstable) {
+ val updateUnstable = Preferences[Preferences.Key.UpdateUnstable]
+ if (lastUpdateUnstable != updateUnstable) {
+ lastUpdateUnstable = updateUnstable
+ forceSyncAll()
+ }
+ }
+ }
+
+ if (databaseUpdated) {
+ forceSyncAll()
+ }
+
+ Cache.cleanup(this)
+ updateSyncJob()
+ }
+
+ private fun listenApplications() {
+ registerReceiver(object: BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val packageName = intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null }
+ if (packageName != null) {
+ when (intent.action.orEmpty()) {
+ Intent.ACTION_PACKAGE_ADDED -> {
+ val installedItem = packageManager.getPackageInfo(packageName,
+ Android.PackageManager.signaturesFlag)?.toInstalledItem()
+ installedItem?.let(Database.InstalledAdapter::put)
+ }
+ Intent.ACTION_PACKAGE_REMOVED -> {
+ Database.InstalledAdapter.delete(packageName)
+ }
+ }
+ }
+ }
+ }, IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addDataScheme("package")
+ })
+ val installedItems = packageManager.getInstalledPackages(Android.PackageManager.signaturesFlag)
+ .mapNotNull { it.toInstalledItem() }
+ Database.InstalledAdapter.putAll(installedItems)
+ }
+
+ private fun updateSyncJob() {
+ val autoSync = Preferences[Preferences.Key.AutoSync]
+ val jobScheduler = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler
+ when (autoSync) {
+ Preferences.AutoSync.Never -> {
+ jobScheduler.cancel(Common.JOB_ID_SYNC)
+ }
+ Preferences.AutoSync.Wifi, Preferences.AutoSync.Always -> {
+ val period = 12 * 60 * 60 * 1000L // 12 hours
+ val wifiOnly = autoSync == Preferences.AutoSync.Wifi
+ jobScheduler.schedule(JobInfo
+ .Builder(Common.JOB_ID_SYNC, ComponentName(this, SyncService.Job::class.java))
+ .setRequiredNetworkType(if (wifiOnly) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY)
+ .apply {
+ if (Android.sdk(26)) {
+ setRequiresBatteryNotLow(true)
+ setRequiresStorageNotLow(true)
+ }
+ if (Android.sdk(24)) {
+ setPeriodic(period, JobInfo.getMinFlexMillis())
+ } else {
+ setPeriodic(period)
+ }
+ }
+ .build())
+ Unit
+ }
+ }::class.java
+ }
+
+ private fun updateProxy() {
+ val type = Preferences[Preferences.Key.ProxyType].proxyType
+ val host = Preferences[Preferences.Key.ProxyHost]
+ val port = Preferences[Preferences.Key.ProxyPort]
+ val socketAddress = when (type) {
+ Proxy.Type.DIRECT -> {
+ null
+ }
+ Proxy.Type.HTTP, Proxy.Type.SOCKS -> {
+ try {
+ InetSocketAddress.createUnresolved(host, port)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+ }
+ val proxy = socketAddress?.let { Proxy(type, socketAddress) }
+ Downloader.proxy = proxy
+ }
+
+ private fun forceSyncAll() {
+ Database.RepositoryAdapter.getAll(null).forEach {
+ if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
+ Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
+ }
+ }
+ Connection(SyncService::class.java, onBind = {
+ it.binder.sync(SyncService.SyncRequest.FORCE)
+ it.connection.unbind(this)
+ }).bind(this)
+ }
+
+ class BootReceiver: BroadcastReceiver() {
+ @SuppressLint("UnsafeProtectedBroadcastReceiver")
+ override fun onReceive(context: Context, intent: Intent) = Unit
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt
new file mode 100644
index 0000000..3fa94eb
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt
@@ -0,0 +1,179 @@
+package nya.kitsunyan.foxydroid.content
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import android.provider.OpenableColumns
+import android.system.Os
+import nya.kitsunyan.foxydroid.utility.extension.android.*
+import java.io.File
+import java.util.UUID
+import kotlin.concurrent.thread
+
+object Cache {
+ private fun ensureCacheDir(context: Context, name: String): File {
+ return File(context.cacheDir, name).apply { isDirectory || mkdirs() || throw RuntimeException() }
+ }
+
+ private fun applyOrMode(file: File, mode: Int) {
+ val oldMode = Os.stat(file.path).st_mode and 0b111111111111
+ val newMode = oldMode or mode
+ if (newMode != oldMode) {
+ Os.chmod(file.path, newMode)
+ }
+ }
+
+ private fun subPath(dir: File, file: File): String {
+ val dirPath = "${dir.path}/"
+ val filePath = file.path
+ filePath.startsWith(dirPath) || throw RuntimeException()
+ return filePath.substring(dirPath.length)
+ }
+
+ fun getImagesDir(context: Context): File {
+ return ensureCacheDir(context, "images")
+ }
+
+ fun getPartialReleaseFile(context: Context, cacheFileName: String): File {
+ return File(ensureCacheDir(context, "partial"), cacheFileName)
+ }
+
+ fun getReleaseFile(context: Context, cacheFileName: String): File {
+ return File(ensureCacheDir(context, "releases"), cacheFileName).apply {
+ if (!Android.sdk(24)) {
+ // Make readable for package installer
+ val cacheDir = context.cacheDir.parentFile!!.parentFile!!
+ generateSequence(this) { it.parentFile!! }.takeWhile { it != cacheDir }.forEach {
+ when {
+ it.isDirectory -> applyOrMode(it, 0b001001001)
+ it.isFile -> applyOrMode(it, 0b100100100)
+ }
+ }
+ }
+ }
+ }
+
+ fun getReleaseUri(context: Context, cacheFileName: String): Uri {
+ val file = getReleaseFile(context, cacheFileName)
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PROVIDERS)
+ val authority = packageInfo.providers.find { it.name == Provider::class.java.name }!!.authority
+ return Uri.Builder().scheme("content").authority(authority)
+ .encodedPath(subPath(context.cacheDir, file)).build()
+ }
+
+ fun getTemporaryFile(context: Context): File {
+ return File(ensureCacheDir(context, "temporary"), UUID.randomUUID().toString())
+ }
+
+ fun cleanup(context: Context) {
+ thread { cleanup(context, Pair("images", 0), Pair("partial", 24), Pair("releases", 24), Pair("temporary", 1)) }
+ }
+
+ private fun cleanup(context: Context, vararg dirHours: Pair) {
+ val knownNames = dirHours.asSequence().map { it.first }.toSet()
+ val files = context.cacheDir.listFiles().orEmpty()
+ files.asSequence().filter { it.name !in knownNames }.forEach {
+ if (it.isDirectory) {
+ cleanupDir(it, 0)
+ it.delete()
+ } else {
+ it.delete()
+ }
+ }
+ dirHours.forEach { (name, hours) ->
+ if (hours > 0) {
+ val file = File(context.cacheDir, name)
+ if (file.exists()) {
+ if (file.isDirectory) {
+ cleanupDir(file, hours)
+ } else {
+ file.delete()
+ }
+ }
+ }
+ }
+ }
+
+ private fun cleanupDir(dir: File, hours: Int) {
+ dir.listFiles()?.forEach {
+ val older = hours <= 0 || run {
+ val olderThan = System.currentTimeMillis() / 1000L - hours * 60 * 60
+ try {
+ val stat = Os.lstat(it.path)
+ stat.st_atime < olderThan
+ } catch (e: Exception) {
+ false
+ }
+ }
+ if (older) {
+ if (it.isDirectory) {
+ cleanupDir(it, hours)
+ if (it.isDirectory) {
+ it.delete()
+ }
+ } else {
+ it.delete()
+ }
+ }
+ }
+ }
+
+ class Provider: ContentProvider() {
+ companion object {
+ private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
+ }
+
+ private fun getFileAndTypeForUri(uri: Uri): Pair {
+ return when (uri.pathSegments?.firstOrNull()) {
+ "releases" -> Pair(File(context!!.cacheDir, uri.encodedPath!!), "application/vnd.android.package-archive")
+ else -> throw SecurityException()
+ }
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun query(uri: Uri, projection: Array?,
+ selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? {
+ val file = getFileAndTypeForUri(uri).first
+ val columns = (projection ?: defaultColumns).mapNotNull {
+ when (it) {
+ OpenableColumns.DISPLAY_NAME -> Pair(it, file.name)
+ OpenableColumns.SIZE -> Pair(it, file.length())
+ else -> null
+ }
+ }.unzip()
+ return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) }
+ }
+
+ override fun getType(uri: Uri): String? = getFileAndTypeForUri(uri).second
+
+ private val unsupported: Nothing
+ get() = throw UnsupportedOperationException()
+
+ override fun insert(uri: Uri, contentValues: ContentValues?): Uri? = unsupported
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = unsupported
+ override fun update(uri: Uri, contentValues: ContentValues?,
+ selection: String?, selectionArgs: Array?): Int = unsupported
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ val openMode = when (mode) {
+ "r" -> ParcelFileDescriptor.MODE_READ_ONLY
+ "w", "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
+ ParcelFileDescriptor.MODE_TRUNCATE
+ "wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
+ ParcelFileDescriptor.MODE_APPEND
+ "rw" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE
+ "rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE or
+ ParcelFileDescriptor.MODE_TRUNCATE
+ else -> throw IllegalArgumentException()
+ }
+ val file = getFileAndTypeForUri(uri).first
+ return ParcelFileDescriptor.open(file, openMode)
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt
new file mode 100644
index 0000000..d839e44
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt
@@ -0,0 +1,124 @@
+package nya.kitsunyan.foxydroid.content
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.preference.PreferenceManager
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.subjects.PublishSubject
+import nya.kitsunyan.foxydroid.R
+import java.net.Proxy
+
+object Preferences {
+ private lateinit var preferences: SharedPreferences
+
+ private val subject = PublishSubject.create>()
+
+ private val keys = sequenceOf(Key.AutoSync, Key.IncompatibleVersions, Key.ProxyHost, Key.ProxyPort, Key.ProxyType,
+ Key.Theme, Key.UpdateNotify, Key.UpdateUnstable).map { Pair(it.name, it) }.toMap()
+
+ fun init(context: Context) {
+ preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ preferences.registerOnSharedPreferenceChangeListener { _, keyString -> keys[keyString]?.let(subject::onNext) }
+ }
+
+ val observable: Observable>
+ get() = subject
+
+ sealed class Value {
+ abstract val value: T
+
+ internal abstract fun get(preferences: SharedPreferences, key: String, defaultValue: Value): T
+ internal abstract fun set(preferences: SharedPreferences, key: String, value: T)
+
+ class BooleanValue(override val value: Boolean): Value() {
+ override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): Boolean {
+ return preferences.getBoolean(key, defaultValue.value)
+ }
+
+ override fun set(preferences: SharedPreferences, key: String, value: Boolean) {
+ preferences.edit().putBoolean(key, value).apply()
+ }
+ }
+
+ class IntValue(override val value: Int): Value() {
+ override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): Int {
+ return preferences.getInt(key, defaultValue.value)
+ }
+
+ override fun set(preferences: SharedPreferences, key: String, value: Int) {
+ preferences.edit().putInt(key, value).apply()
+ }
+ }
+
+ class StringValue(override val value: String): Value() {
+ override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): String {
+ return preferences.getString(key, defaultValue.value) ?: defaultValue.value
+ }
+
+ override fun set(preferences: SharedPreferences, key: String, value: String) {
+ preferences.edit().putString(key, value).apply()
+ }
+ }
+
+ class EnumerationValue>(override val value: T): Value() {
+ override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): T {
+ val value = preferences.getString(key, defaultValue.value.valueString)
+ return defaultValue.value.values.find { it.valueString == value } ?: defaultValue.value
+ }
+
+ override fun set(preferences: SharedPreferences, key: String, value: T) {
+ preferences.edit().putString(key, value.valueString).apply()
+ }
+ }
+ }
+
+ interface Enumeration {
+ val values: List
+ val valueString: String
+ }
+
+ sealed class Key(val name: String, val default: Value) {
+ object IncompatibleVersions: Key("incompatible_versions", Value.BooleanValue(false))
+ object ProxyHost: Key("proxy_host", Value.StringValue("localhost"))
+ object ProxyPort: Key("proxy_port", Value.IntValue(9050))
+ object ProxyType: Key("proxy_type", Value.EnumerationValue(Preferences.ProxyType.Direct))
+ object Theme: Key("theme", Value.EnumerationValue(Preferences.Theme.Light))
+ object AutoSync: Key("auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi))
+ object UpdateNotify: Key("update_notify", Value.BooleanValue(true))
+ object UpdateUnstable: Key("update_unstable", Value.BooleanValue(false))
+ }
+
+ sealed class AutoSync(override val valueString: String): Enumeration {
+ override val values: List
+ get() = listOf(Never, Wifi, Always)
+
+ object Never: AutoSync("never")
+ object Wifi: AutoSync("wifi")
+ object Always: AutoSync("always")
+ }
+
+ sealed class ProxyType(override val valueString: String, val proxyType: Proxy.Type): Enumeration {
+ override val values: List
+ get() = listOf(Direct, Http, Socks)
+
+ object Direct: ProxyType("direct", Proxy.Type.DIRECT)
+ object Http: ProxyType("http", Proxy.Type.HTTP)
+ object Socks: ProxyType("socks", Proxy.Type.SOCKS)
+ }
+
+ sealed class Theme(override val valueString: String, val resId: Int): Enumeration {
+ override val values: List
+ get() = listOf(Light, Dark)
+
+ object Light: Theme("light", R.style.Theme_Main_Light)
+ object Dark: Theme("dark", R.style.Theme_Main_Dark)
+ }
+
+ operator fun get(key: Key): T {
+ return key.default.get(preferences, key.name, key.default)
+ }
+
+ operator fun set(key: Key, value: T) {
+ key.default.set(preferences, key.name, value)
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt
new file mode 100644
index 0000000..4dcf578
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt
@@ -0,0 +1,64 @@
+package nya.kitsunyan.foxydroid.content
+
+import android.content.Context
+import android.content.SharedPreferences
+import io.reactivex.rxjava3.schedulers.Schedulers
+import io.reactivex.rxjava3.subjects.PublishSubject
+import nya.kitsunyan.foxydroid.database.Database
+import nya.kitsunyan.foxydroid.entity.ProductPreference
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+import java.io.ByteArrayOutputStream
+import java.nio.charset.Charset
+
+object ProductPreferences {
+ private val defaultProductPreference = ProductPreference(false, 0L)
+ private lateinit var preferences: SharedPreferences
+ private val subject = PublishSubject.create>()
+
+ fun init(context: Context) {
+ preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
+ Database.LockAdapter.putAll(preferences.all.keys
+ .mapNotNull { packageName -> this[packageName].databaseVersionCode?.let { Pair(packageName, it) } })
+ subject
+ .observeOn(Schedulers.io())
+ .subscribe { (packageName, versionCode) ->
+ if (versionCode != null) {
+ Database.LockAdapter.put(Pair(packageName, versionCode))
+ } else {
+ Database.LockAdapter.delete(packageName)
+ }
+ }
+ }
+
+ private val ProductPreference.databaseVersionCode: Long?
+ get() = when {
+ ignoreUpdates -> 0L
+ ignoreVersionCode > 0L -> ignoreVersionCode
+ else -> null
+ }
+
+ operator fun get(packageName: String): ProductPreference {
+ return if (preferences.contains(packageName)) {
+ try {
+ Json.factory.createParser(preferences.getString(packageName, "{}"))
+ .use { it.parseDictionary(ProductPreference.Companion::deserialize) }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ defaultProductPreference
+ }
+ } else {
+ defaultProductPreference
+ }
+ }
+
+ operator fun set(packageName: String, productPreference: ProductPreference) {
+ val oldProductPreference = this[packageName]
+ preferences.edit().putString(packageName, ByteArrayOutputStream()
+ .apply { Json.factory.createGenerator(this).use { it.writeDictionary(productPreference::serialize) } }
+ .toByteArray().toString(Charset.defaultCharset())).apply()
+ if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
+ oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode) {
+ subject.onNext(Pair(packageName, productPreference.databaseVersionCode))
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt
new file mode 100644
index 0000000..7477994
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt
@@ -0,0 +1,97 @@
+package nya.kitsunyan.foxydroid.database
+
+import android.database.Cursor
+import android.os.Bundle
+import androidx.fragment.app.Fragment
+import androidx.loader.app.LoaderManager
+import androidx.loader.content.Loader
+
+class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks {
+ sealed class Request {
+ internal abstract val id: Int
+
+ data class ProductsAvailable(val searchQuery: String, val category: String): Request() {
+ override val id: Int
+ get() = 1
+ }
+
+ data class ProductsInstalled(val searchQuery: String, val category: String): Request() {
+ override val id: Int
+ get() = 2
+ }
+
+ data class ProductsUpdates(val searchQuery: String, val category: String): Request() {
+ override val id: Int
+ get() = 3
+ }
+
+ object Repositories: Request() {
+ override val id: Int
+ get() = 4
+ }
+ }
+
+ interface Callback {
+ fun onCursorData(request: Request, cursor: Cursor?)
+ }
+
+ private data class ActiveRequest(val request: Request, val callback: Callback?, val cursor: Cursor?)
+
+ init {
+ retainInstance = true
+ }
+
+ private val activeRequests = mutableMapOf()
+
+ fun attach(callback: Callback, request: Request) {
+ val oldActiveRequest = activeRequests[request.id]
+ if (oldActiveRequest?.callback != null &&
+ oldActiveRequest.callback != callback && oldActiveRequest.cursor != null) {
+ oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
+ }
+ val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
+ callback.onCursorData(request, oldActiveRequest.cursor)
+ oldActiveRequest.cursor
+ } else {
+ null
+ }
+ activeRequests[request.id] = ActiveRequest(request, callback, cursor)
+ if (cursor == null) {
+ LoaderManager.getInstance(this).restartLoader(request.id, null, this)
+ }
+ }
+
+ fun detach(callback: Callback) {
+ for (id in activeRequests.keys) {
+ val activeRequest = activeRequests[id]!!
+ if (activeRequest.callback == callback) {
+ activeRequests[id] = activeRequest.copy(callback = null)
+ }
+ }
+ }
+
+ override fun onCreateLoader(id: Int, args: Bundle?): Loader {
+ val request = activeRequests[id]!!.request
+ return QueryLoader(requireContext()) {
+ when (request) {
+ is Request.ProductsAvailable -> Database.ProductAdapter
+ .query(false, false, request.searchQuery, request.category, it)
+ is Request.ProductsInstalled -> Database.ProductAdapter
+ .query(true, false, request.searchQuery, request.category, it)
+ is Request.ProductsUpdates -> Database.ProductAdapter
+ .query(true, true, request.searchQuery, request.category, it)
+ is Request.Repositories -> Database.RepositoryAdapter.query(it)
+ }
+ }
+ }
+
+ override fun onLoadFinished(loader: Loader, data: Cursor?) {
+ val activeRequest = activeRequests[loader.id]
+ if (activeRequest != null) {
+ activeRequests[loader.id] = activeRequest.copy(cursor = data)
+ activeRequest.callback?.onCursorData(activeRequest.request, data)
+ }
+ }
+
+ override fun onLoaderReset(loader: Loader) = onLoadFinished(loader, null)
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt
new file mode 100644
index 0000000..5ee374b
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt
@@ -0,0 +1,618 @@
+package nya.kitsunyan.foxydroid.database
+
+import android.annotation.SuppressLint
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteOpenHelper
+import android.os.CancellationSignal
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import io.reactivex.rxjava3.core.Observable
+import nya.kitsunyan.foxydroid.entity.InstalledItem
+import nya.kitsunyan.foxydroid.entity.Product
+import nya.kitsunyan.foxydroid.entity.ProductItem
+import nya.kitsunyan.foxydroid.entity.Repository
+import nya.kitsunyan.foxydroid.utility.extension.android.*
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+import java.io.ByteArrayOutputStream
+
+object Database {
+ fun init(context: Context): Boolean {
+ val helper = Helper(context)
+ db = helper.writableDatabase
+ if (helper.created) {
+ for (repository in Repository.defaultRepositories) {
+ RepositoryAdapter.put(repository)
+ }
+ }
+ return helper.created || helper.updated
+ }
+
+ private lateinit var db: SQLiteDatabase
+
+ private interface Table {
+ val memory: Boolean
+ val innerName: String
+ val createTable: String
+ val createIndex: String?
+ get() = null
+
+ val databasePrefix: String
+ get() = if (memory) "memory." else ""
+
+ val name: String
+ get() = "$databasePrefix$innerName"
+
+ fun formatCreateTable(name: String): String {
+ return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})"
+ }
+
+ val createIndexPairFormatted: Pair?
+ get() = createIndex?.let { Pair("CREATE INDEX ${innerName}_index ON $innerName ($it)",
+ "CREATE INDEX ${name}_index ON $innerName ($it)") }
+ }
+
+ private object Schema {
+ object Repository: Table {
+ const val ROW_ID = "_id"
+ const val ROW_ENABLED = "enabled"
+ const val ROW_DELETED = "deleted"
+ const val ROW_DATA = "data"
+
+ override val memory = false
+ override val innerName = "repository"
+ override val createTable = """
+ $ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT,
+ $ROW_ENABLED INTEGER NOT NULL,
+ $ROW_DELETED INTEGER NOT NULL,
+ $ROW_DATA BLOB NOT NULL
+ """
+ }
+
+ object Product: Table {
+ const val ROW_REPOSITORY_ID = "repository_id"
+ const val ROW_PACKAGE_NAME = "package_name"
+ const val ROW_NAME = "name"
+ const val ROW_SUMMARY = "summary"
+ const val ROW_VERSION_CODE = "version_code"
+ const val ROW_SIGNATURE = "signature"
+ const val ROW_COMPATIBLE = "compatible"
+ const val ROW_DATA = "data"
+ const val ROW_DATA_ITEM = "data_item"
+
+ override val memory = false
+ override val innerName = "product"
+ override val createTable = """
+ $ROW_REPOSITORY_ID INTEGER NOT NULL,
+ $ROW_PACKAGE_NAME TEXT NOT NULL,
+ $ROW_NAME TEXT NOT NULL,
+ $ROW_SUMMARY TEXT NOT NULL,
+ $ROW_VERSION_CODE INTEGER NOT NULL,
+ $ROW_SIGNATURE TEXT NOT NULL,
+ $ROW_COMPATIBLE INTEGER NOT NULL,
+ $ROW_DATA BLOB NOT NULL,
+ $ROW_DATA_ITEM BLOB NOT NULL,
+ PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME)
+ """
+ override val createIndex = ROW_PACKAGE_NAME
+ }
+
+ object Category: Table {
+ const val ROW_REPOSITORY_ID = "repository_id"
+ const val ROW_PACKAGE_NAME = "package_name"
+ const val ROW_NAME = "name"
+
+ override val memory = false
+ override val innerName = "category"
+ override val createTable = """
+ $ROW_REPOSITORY_ID INTEGER NOT NULL,
+ $ROW_PACKAGE_NAME TEXT NOT NULL,
+ $ROW_NAME TEXT NOT NULL,
+ PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME)
+ """
+ override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME"
+ }
+
+ object Installed: Table {
+ const val ROW_PACKAGE_NAME = "package_name"
+ const val ROW_VERSION = "version"
+ const val ROW_VERSION_CODE = "version_code"
+ const val ROW_SIGNATURE = "signature"
+
+ override val memory = true
+ override val innerName = "installed"
+ override val createTable = """
+ $ROW_PACKAGE_NAME TEXT PRIMARY KEY,
+ $ROW_VERSION TEXT NOT NULL,
+ $ROW_VERSION_CODE INTEGER NOT NULL,
+ $ROW_SIGNATURE TEXT NOT NULL
+ """
+ }
+
+ object Lock: Table {
+ const val ROW_PACKAGE_NAME = "package_name"
+ const val ROW_VERSION_CODE = "version_code"
+
+ override val memory = true
+ override val innerName = "lock"
+ override val createTable = """
+ $ROW_PACKAGE_NAME TEXT PRIMARY KEY,
+ $ROW_VERSION_CODE INTEGER NOT NULL
+ """
+ }
+
+ object Synthetic {
+ const val ROW_CAN_UPDATE = "can_update"
+ }
+ }
+
+ private class Helper(context: Context): SQLiteOpenHelper(context, "foxydroid", null, 1) {
+ var created = false
+ private set
+ var updated = false
+ private set
+
+ override fun onCreate(db: SQLiteDatabase) = Unit
+ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db)
+ override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db)
+
+ private fun onVersionChange(db: SQLiteDatabase) {
+ handleTables(db, true, Schema.Product, Schema.Category)
+ this.updated = true
+ }
+
+ override fun onOpen(db: SQLiteDatabase) {
+ val create = handleTables(db, false, Schema.Repository)
+ val updated = handleTables(db, create, Schema.Product, Schema.Category)
+ db.execSQL("ATTACH DATABASE ':memory:' AS memory")
+ handleTables(db, false, Schema.Installed, Schema.Lock)
+ handleIndexes(db, Schema.Repository, Schema.Product, Schema.Category, Schema.Installed, Schema.Lock)
+ dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
+ this.created = this.created || create
+ this.updated = this.updated || create || updated
+ }
+ }
+
+ private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
+ val shouldRecreate = recreate || tables.any {
+ val sql = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("sql"),
+ selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName)))
+ .use { it.firstOrNull()?.getString(0) }.orEmpty()
+ it.formatCreateTable(it.innerName) != sql
+ }
+ return shouldRecreate && run {
+ val shouldVacuum = tables.map {
+ db.execSQL("DROP TABLE IF EXISTS ${it.name}")
+ db.execSQL(it.formatCreateTable(it.name))
+ !it.memory
+ }
+ if (shouldVacuum.any { it }) {
+ db.execSQL("VACUUM")
+ }
+ true
+ }
+ }
+
+ private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
+ val shouldVacuum = tables.map {
+ val sqls = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"),
+ selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName)))
+ .use { it.asSequence().mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }.toList() }
+ .filter { !it.first.startsWith("sqlite_") }
+ val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
+ createIndexes.map { it.first } != sqls.map { it.second } && run {
+ for (name in sqls.map { it.first }) {
+ db.execSQL("DROP INDEX IF EXISTS $name")
+ }
+ for (createIndexPair in createIndexes) {
+ db.execSQL(createIndexPair.second)
+ }
+ !it.memory
+ }
+ }
+ if (shouldVacuum.any { it }) {
+ db.execSQL("VACUUM")
+ }
+ }
+
+ private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
+ val tables = db.query("sqlite_master", columns = arrayOf("name"),
+ selection = Pair("type = ?", arrayOf("table")))
+ .use { it.asSequence().mapNotNull { it.getString(0) }.toList() }
+ .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
+ .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }
+ if (tables.isNotEmpty()) {
+ for (table in tables) {
+ db.execSQL("DROP TABLE IF EXISTS $table")
+ }
+ db.execSQL("VACUUM")
+ }
+ }
+
+ sealed class Subject {
+ object Repositories: Subject()
+ data class Repository(val id: Long): Subject()
+ object Products: Subject()
+ }
+
+ private val observers = mutableMapOf Unit>>()
+
+ private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit = { register, observer ->
+ synchronized(observers) {
+ val set = observers[subject] ?: run {
+ val set = mutableSetOf<() -> Unit>()
+ observers[subject] = set
+ set
+ }
+ if (register) {
+ set += observer
+ } else {
+ set -= observer
+ }
+ }
+ }
+
+ fun observable(subject: Subject): Observable {
+ return Observable.create {
+ val callback: () -> Unit = { it.onNext(Unit) }
+ val dataObservable = dataObservable(subject)
+ dataObservable(true, callback)
+ it.setCancellable { dataObservable(false, callback) }
+ }
+ }
+
+ private fun notifyChanged(vararg subjects: Subject) {
+ synchronized(observers) {
+ subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() }
+ }
+ }
+
+ private fun SQLiteDatabase.insertOrReplace(replace: Boolean, table: String, contentValues: ContentValues): Long {
+ return if (replace) replace(table, null, contentValues) else insert(table, null, contentValues)
+ }
+
+ private fun SQLiteDatabase.query(table: String, columns: Array? = null,
+ selection: Pair>? = null, orderBy: String? = null,
+ signal: CancellationSignal? = null): Cursor {
+ return query(false, table, columns, selection?.first, selection?.second, null, null, orderBy, null, signal)
+ }
+
+ private fun Cursor.observable(subject: Subject): ObservableCursor {
+ return ObservableCursor(this, dataObservable(subject))
+ }
+
+ private fun ByteArray.jsonParse(callback: (JsonParser) -> T): T {
+ return Json.factory.createParser(this).use { it.parseDictionary(callback) }
+ }
+
+ private fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray {
+ val outputStream = ByteArrayOutputStream()
+ Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) }
+ return outputStream.toByteArray()
+ }
+
+ object RepositoryAdapter {
+ internal fun putWithoutNotification(repository: Repository, shouldReplace: Boolean): Long {
+ return db.insertOrReplace(shouldReplace, Schema.Repository.name, ContentValues().apply {
+ if (shouldReplace) {
+ put(Schema.Repository.ROW_ID, repository.id)
+ }
+ put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
+ put(Schema.Repository.ROW_DELETED, 0)
+ put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
+ })
+ }
+
+ fun put(repository: Repository): Repository {
+ val shouldReplace = repository.id >= 0L
+ val newId = putWithoutNotification(repository, shouldReplace)
+ val id = if (shouldReplace) repository.id else newId
+ notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
+ return if (newId != repository.id) repository.copy(id = newId) else repository
+ }
+
+ fun get(id: Long): Repository? {
+ return db.query(Schema.Repository.name,
+ selection = Pair("${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
+ arrayOf(id.toString())))
+ .use { it.firstOrNull()?.let(::transform) }
+ }
+
+ fun getAll(signal: CancellationSignal?): List {
+ return db.query(Schema.Repository.name,
+ selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
+ signal = signal).use { it.asSequence().map(::transform).toList() }
+ }
+
+ fun getAllDisabledDeleted(signal: CancellationSignal?): Set> {
+ return db.query(Schema.Repository.name,
+ columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
+ selection = Pair("${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0", emptyArray()),
+ signal = signal).use { it.asSequence().map { Pair(it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)),
+ it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0) }.toSet() }
+ }
+
+ fun markAsDeleted(id: Long) {
+ db.update(Schema.Repository.name, ContentValues().apply {
+ put(Schema.Repository.ROW_DELETED, 1)
+ }, "${Schema.Repository.ROW_ID} = ?", arrayOf(id.toString()))
+ notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
+ }
+
+ fun cleanup(pairs: Set>) {
+ val result = pairs.windowed(10, 10, true).map {
+ val idsString = it.joinToString(separator = ", ") { it.first.toString() }
+ val productsCount = db.delete(Schema.Product.name,
+ "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null)
+ val categoriesCount = db.delete(Schema.Category.name,
+ "${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)", null)
+ val deleteIdsString = it.asSequence().filter { it.second }
+ .joinToString(separator = ", ") { it.first.toString() }
+ if (deleteIdsString.isNotEmpty()) {
+ db.delete(Schema.Repository.name, "${Schema.Repository.ROW_ID} IN ($deleteIdsString)", null)
+ }
+ productsCount != 0 || categoriesCount != 0
+ }
+ if (result.any { it }) {
+ notifyChanged(Subject.Products)
+ }
+ }
+
+ fun query(signal: CancellationSignal?): Cursor {
+ return db.query(Schema.Repository.name,
+ selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
+ signal = signal).observable(Subject.Repositories)
+ }
+
+ fun transform(cursor: Cursor): Repository {
+ return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA))
+ .jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)), it) }
+ }
+ }
+
+ object ProductAdapter {
+ fun get(packageName: String, signal: CancellationSignal?): List {
+ return db.query(Schema.Product.name,
+ columns = arrayOf(Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_DATA),
+ selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
+ signal = signal).use { it.asSequence().map(::transform).toList() }
+ }
+
+ fun getCount(repositoryId: Long): Int {
+ return db.query(Schema.Product.name, columns = arrayOf("COUNT (*)"),
+ selection = Pair("${Schema.Product.ROW_REPOSITORY_ID} = ?", arrayOf(repositoryId.toString())))
+ .use { it.firstOrNull()?.getInt(0) ?: 0 }
+ }
+
+ @SuppressLint("Recycle")
+ fun query(installed: Boolean, updates: Boolean, searchQuery: String,
+ category: String, signal: CancellationSignal?): Cursor {
+ val builder = QueryBuilder()
+
+ builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID},
+ product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME},
+ product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION},
+ (COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND
+ product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} >
+ COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND
+ product.${Schema.Product.ROW_SIGNATURE} = installed.${Schema.Installed.ROW_SIGNATURE} AND
+ product.${Schema.Product.ROW_SIGNATURE} != '') AS ${Schema.Synthetic.ROW_CAN_UPDATE},
+ product.${Schema.Product.ROW_COMPATIBLE}, product.${Schema.Product.ROW_DATA_ITEM},
+ MAX((product.${Schema.Product.ROW_COMPATIBLE} << 32) | product.${Schema.Product.ROW_VERSION_CODE})
+ FROM ${Schema.Product.name} AS product"""
+
+ builder += """JOIN ${Schema.Repository.name} AS repository
+ ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}"""
+ builder += """LEFT JOIN ${Schema.Lock.name} AS lock
+ ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}"""
+ if (!installed && !updates) {
+ builder += "LEFT"
+ }
+ builder += """JOIN ${Schema.Installed.name} AS installed
+ ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}"""
+ if (category.isNotEmpty()) {
+ builder += """JOIN ${Schema.Category.name} AS category
+ ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}"""
+ }
+
+ builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
+ repository.${Schema.Repository.ROW_DELETED} == 0"""
+ if (category.isNotEmpty()) {
+ builder += "AND category.${Schema.Category.ROW_NAME} = ?"
+ builder %= category
+ }
+ if (searchQuery.isNotEmpty()) {
+ builder += """AND (product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ? OR
+ product.${Schema.Product.ROW_NAME} LIKE ? OR
+ product.${Schema.Product.ROW_SUMMARY} LIKE ?)"""
+ builder %= List(3) { "%$searchQuery%" }
+ }
+
+ builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1"
+ if (updates) {
+ builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}"
+ }
+ builder += "ORDER BY product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"
+
+ return builder.query(db, signal).observable(Subject.Products)
+ }
+
+ private fun transform(cursor: Cursor): Product {
+ return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA))
+ .jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), it) }
+ }
+
+ fun transformItem(cursor: Cursor): ProductItem {
+ return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM))
+ .jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)),
+ cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)),
+ cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)),
+ cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)),
+ cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)).orEmpty(),
+ cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0,
+ cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, it) }
+ }
+ }
+
+ object CategoryAdapter {
+ fun getAll(signal: CancellationSignal?): Set {
+ val builder = QueryBuilder()
+
+ builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME}
+ FROM ${Schema.Category.name} AS category
+ JOIN ${Schema.Repository.name} AS repository
+ ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}
+ WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
+ repository.${Schema.Repository.ROW_DELETED} == 0"""
+
+ return builder.query(db, signal).use { it.asSequence()
+ .map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet() }
+ }
+ }
+
+ object InstalledAdapter {
+ fun get(packageName: String, signal: CancellationSignal?): InstalledItem? {
+ return db.query(Schema.Installed.name,
+ columns = arrayOf(Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_VERSION,
+ Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_SIGNATURE),
+ selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
+ signal = signal).use { it.firstOrNull()?.let(::transform) }
+ }
+
+ private fun put(installedItem: InstalledItem, notify: Boolean) {
+ db.insertOrReplace(true, Schema.Installed.name, ContentValues().apply {
+ put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName)
+ put(Schema.Installed.ROW_VERSION, installedItem.version)
+ put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
+ put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
+ })
+ if (notify) {
+ notifyChanged(Subject.Products)
+ }
+ }
+
+ fun put(installedItem: InstalledItem) = put(installedItem, true)
+
+ fun putAll(installedItems: List) {
+ db.beginTransaction()
+ try {
+ db.delete(Schema.Installed.name, null, null)
+ installedItems.forEach { put(it, false) }
+ db.setTransactionSuccessful()
+ } finally {
+ db.endTransaction()
+ }
+ }
+
+ fun delete(packageName: String) {
+ db.delete(Schema.Installed.name, "${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName))
+ notifyChanged(Subject.Products)
+ }
+
+ private fun transform(cursor: Cursor): InstalledItem {
+ return InstalledItem(cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)),
+ cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)),
+ cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)),
+ cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE)))
+ }
+ }
+
+ object LockAdapter {
+ private fun put(lock: Pair, notify: Boolean) {
+ db.insertOrReplace(true, Schema.Lock.name, ContentValues().apply {
+ put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
+ put(Schema.Lock.ROW_VERSION_CODE, lock.second)
+ })
+ if (notify) {
+ notifyChanged(Subject.Products)
+ }
+ }
+
+ fun put(lock: Pair) = put(lock, true)
+
+ fun putAll(locks: List>) {
+ db.beginTransaction()
+ try {
+ db.delete(Schema.Lock.name, null, null)
+ locks.forEach { put(it, false) }
+ db.setTransactionSuccessful()
+ } finally {
+ db.endTransaction()
+ }
+ }
+
+ fun delete(packageName: String) {
+ db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName))
+ notifyChanged(Subject.Products)
+ }
+ }
+
+ object UpdaterAdapter {
+ private val Table.temporaryName: String
+ get() = "${name}_temporary"
+
+ fun createTemporaryTable() {
+ db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
+ db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
+ db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName))
+ db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName))
+ }
+
+ fun putTemporary(products: List) {
+ db.beginTransaction()
+ try {
+ for (product in products) {
+ db.insertOrReplace(true, Schema.Product.temporaryName, ContentValues().apply {
+ put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId)
+ put(Schema.Product.ROW_PACKAGE_NAME, product.packageName)
+ put(Schema.Product.ROW_NAME, product.name)
+ put(Schema.Product.ROW_SUMMARY, product.summary)
+ put(Schema.Product.ROW_VERSION_CODE, product.versionCode)
+ put(Schema.Product.ROW_SIGNATURE, product.signature)
+ put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0)
+ put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
+ put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize))
+ })
+ for (category in product.categories) {
+ db.insertOrReplace(true, Schema.Category.temporaryName, ContentValues().apply {
+ put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
+ put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
+ put(Schema.Category.ROW_NAME, category)
+ })
+ }
+ }
+ db.setTransactionSuccessful()
+ } finally {
+ db.endTransaction()
+ }
+ }
+
+ fun finishTemporary(repository: Repository, success: Boolean) {
+ if (success) {
+ db.beginTransaction()
+ try {
+ db.delete(Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?",
+ arrayOf(repository.id.toString()))
+ db.delete(Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?",
+ arrayOf(repository.id.toString()))
+ db.execSQL("INSERT INTO ${Schema.Product.name} SELECT * FROM ${Schema.Product.temporaryName}")
+ db.execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}")
+ RepositoryAdapter.putWithoutNotification(repository, true)
+ db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
+ db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
+ db.setTransactionSuccessful()
+ } finally {
+ db.endTransaction()
+ }
+ if (success) {
+ notifyChanged(Subject.Repositories, Subject.Repository(repository.id), Subject.Products)
+ }
+ } else {
+ db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
+ db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt
new file mode 100644
index 0000000..db3bf18
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt
@@ -0,0 +1,57 @@
+package nya.kitsunyan.foxydroid.database
+
+import android.database.ContentObservable
+import android.database.ContentObserver
+import android.database.Cursor
+import android.database.CursorWrapper
+
+class ObservableCursor(cursor: Cursor, private val observable: (register: Boolean,
+ observer: () -> Unit) -> Unit): CursorWrapper(cursor) {
+ private var registered = false
+ private val contentObservable = ContentObservable()
+
+ private val onChange: () -> Unit = {
+ contentObservable.dispatchChange(false, null)
+ }
+
+ init {
+ observable(true, onChange)
+ registered = true
+ }
+
+ override fun registerContentObserver(observer: ContentObserver) {
+ super.registerContentObserver(observer)
+ contentObservable.registerObserver(observer)
+ }
+
+ override fun unregisterContentObserver(observer: ContentObserver) {
+ super.unregisterContentObserver(observer)
+ contentObservable.unregisterObserver(observer)
+ }
+
+ @Suppress("DEPRECATION")
+ override fun requery(): Boolean {
+ if (!registered) {
+ observable(true, onChange)
+ registered = true
+ }
+ return super.requery()
+ }
+
+ @Suppress("DEPRECATION")
+ override fun deactivate() {
+ super.deactivate()
+ deactivateOrClose()
+ }
+
+ override fun close() {
+ super.close()
+ contentObservable.unregisterAll()
+ deactivateOrClose()
+ }
+
+ private fun deactivateOrClose() {
+ observable(false, onChange)
+ registered = false
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt
new file mode 100644
index 0000000..b3c7ce6
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt
@@ -0,0 +1,47 @@
+package nya.kitsunyan.foxydroid.database
+
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.os.CancellationSignal
+import nya.kitsunyan.foxydroid.BuildConfig
+import nya.kitsunyan.foxydroid.utility.extension.android.*
+import nya.kitsunyan.foxydroid.utility.extension.text.*
+
+class QueryBuilder {
+ companion object {
+ fun trimQuery(query: String): String {
+ return query.lines().map { it.trim() }.filter { it.isNotEmpty() }.joinToString(separator = " ")
+ }
+ }
+
+ private val builder = StringBuilder()
+ private val arguments = mutableListOf()
+
+ operator fun plusAssign(query: String) {
+ if (builder.isNotEmpty()) {
+ builder.append(" ")
+ }
+ builder.append(trimQuery(query))
+ }
+
+ operator fun remAssign(argument: String) {
+ this.arguments += argument
+ }
+
+ operator fun remAssign(arguments: List) {
+ this.arguments += arguments
+ }
+
+ fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor {
+ val query = builder.toString()
+ val arguments = arguments.toTypedArray()
+ if (BuildConfig.DEBUG) {
+ synchronized(QueryBuilder::class.java) {
+ debug(query)
+ db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { it.asSequence()
+ .forEach { debug(":: ${it.getString(it.getColumnIndex("detail"))}") } }
+ }
+ }
+ return db.rawQuery(query, arguments, signal)
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryLoader.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryLoader.kt
new file mode 100644
index 0000000..ce6c7e3
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryLoader.kt
@@ -0,0 +1,94 @@
+package nya.kitsunyan.foxydroid.database
+
+import android.content.Context
+import android.database.Cursor
+import android.os.CancellationSignal
+import android.os.OperationCanceledException
+import androidx.loader.content.AsyncTaskLoader
+
+class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?):
+ AsyncTaskLoader(context) {
+ private val observer = ForceLoadContentObserver()
+ private var cancellationSignal: CancellationSignal? = null
+ private var cursor: Cursor? = null
+
+ override fun loadInBackground(): Cursor? {
+ val cancellationSignal = synchronized(this) {
+ if (isLoadInBackgroundCanceled) {
+ throw OperationCanceledException()
+ }
+ val cancellationSignal = CancellationSignal()
+ this.cancellationSignal = cancellationSignal
+ cancellationSignal
+ }
+ try {
+ val cursor = query(cancellationSignal)
+ if (cursor != null) {
+ try {
+ cursor.count // Ensure the cursor window is filled
+ cursor.registerContentObserver(observer)
+ } catch (e: Exception) {
+ cursor.close()
+ throw e
+ }
+ }
+ return cursor
+ } finally {
+ synchronized(this) {
+ this.cancellationSignal = null
+ }
+ }
+ }
+
+ override fun cancelLoadInBackground() {
+ super.cancelLoadInBackground()
+
+ synchronized(this) {
+ cancellationSignal?.cancel()
+ }
+ }
+
+ override fun deliverResult(data: Cursor?) {
+ if (isReset) {
+ data?.close()
+ } else {
+ val oldCursor = cursor
+ cursor = data
+ if (isStarted) {
+ super.deliverResult(data)
+ }
+ if (oldCursor != data) {
+ oldCursor.closeIfNeeded()
+ }
+ }
+ }
+
+ override fun onStartLoading() {
+ cursor?.let(this::deliverResult)
+ if (takeContentChanged() || cursor == null) {
+ forceLoad()
+ }
+ }
+
+ override fun onStopLoading() {
+ cancelLoad()
+ }
+
+ override fun onCanceled(data: Cursor?) {
+ data.closeIfNeeded()
+ }
+
+ override fun onReset() {
+ super.onReset()
+
+ stopLoading()
+ cursor.closeIfNeeded()
+ cursor = null
+ }
+
+ private fun Cursor?.closeIfNeeded() {
+ if (this != null && !isClosed) {
+ close()
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/InstalledItem.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/InstalledItem.kt
new file mode 100644
index 0000000..a050302
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/InstalledItem.kt
@@ -0,0 +1,3 @@
+package nya.kitsunyan.foxydroid.entity
+
+class InstalledItem(val packageName: String, val version: String, val versionCode: Long, val signature: String)
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt
new file mode 100644
index 0000000..d7c2d1f
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt
@@ -0,0 +1,218 @@
+package nya.kitsunyan.foxydroid.entity
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.core.JsonToken
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+
+data class Product(val repositoryId: Long, val packageName: String, val name: String, val summary: String,
+ val description: String, val whatsNew: String, val icon: String, val author: Author,
+ val source: String, val changelog: String, val web: String, val tracker: String,
+ val added: Long, val updated: Long, val suggestedVersionCode: Long,
+ val categories: List, val antiFeatures: List, val licenses: List,
+ val donates: List, val screenshots: List, val releases: List) {
+ data class Author(val name: String, val email: String, val web: String)
+
+ sealed class Donate {
+ data class Regular(val url: String): Donate()
+ data class Bitcoin(val address: String): Donate()
+ data class Litecoin(val address: String): Donate()
+ data class Flattr(val id: String): Donate()
+ data class Liberapay(val id: String): Donate()
+ }
+
+ class Screenshot(val locale: String, val type: Type, val path: String) {
+ enum class Type(val jsonName: String) {
+ PHONE("phone"),
+ SMALL_TABLET("smallTablet"),
+ LARGE_TABLET("largeTablet")
+ }
+
+ val identifier: String
+ get() = "$locale.${type.name}.$path"
+ }
+
+ val selectedRelease: Release?
+ get() = releases.find { it.selected }
+
+ val displayRelease: Release?
+ get() = selectedRelease ?: releases.firstOrNull()
+
+ val version: String
+ get() = displayRelease?.version.orEmpty()
+
+ val versionCode: Long
+ get() = selectedRelease?.versionCode ?: 0L
+
+ val compatible: Boolean
+ get() = selectedRelease?.incompatibilities?.isEmpty() == true
+
+ val signature: String
+ get() = selectedRelease?.signature.orEmpty()
+
+ fun item(): ProductItem {
+ return ProductItem(repositoryId, packageName, name, summary, icon, version, "", compatible, false)
+ }
+
+ fun canUpdate(installedItem: InstalledItem?): Boolean {
+ return installedItem != null && compatible && versionCode > installedItem.versionCode &&
+ signature.isNotEmpty() && signature == installedItem.signature
+ }
+
+ fun serialize(generator: JsonGenerator) {
+ generator.writeNumberField("serialVersion", 1)
+ generator.writeStringField("packageName", packageName)
+ generator.writeStringField("name", name)
+ generator.writeStringField("summary", summary)
+ generator.writeStringField("description", description)
+ generator.writeStringField("whatsNew", whatsNew)
+ generator.writeStringField("icon", icon)
+ generator.writeStringField("authorName", author.name)
+ generator.writeStringField("authorEmail", author.email)
+ generator.writeStringField("authorWeb", author.web)
+ generator.writeStringField("source", source)
+ generator.writeStringField("changelog", changelog)
+ generator.writeStringField("web", web)
+ generator.writeStringField("tracker", tracker)
+ generator.writeNumberField("added", added)
+ generator.writeNumberField("updated", updated)
+ generator.writeNumberField("suggestedVersionCode", suggestedVersionCode)
+ generator.writeArray("categories") { categories.forEach(::writeString) }
+ generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) }
+ generator.writeArray("licenses") { licenses.forEach(::writeString) }
+ generator.writeArray("donates") {
+ donates.forEach {
+ writeDictionary {
+ when (it) {
+ is Donate.Regular -> {
+ writeStringField("type", "")
+ writeStringField("url", it.url)
+ }
+ is Donate.Bitcoin -> {
+ writeStringField("type", "bitcoin")
+ writeStringField("address", it.address)
+ }
+ is Donate.Litecoin -> {
+ writeStringField("type", "litecoin")
+ writeStringField("address", it.address)
+ }
+ is Donate.Flattr -> {
+ writeStringField("type", "flattr")
+ writeStringField("id", it.id)
+ }
+ is Donate.Liberapay -> {
+ writeStringField("type", "liberapay")
+ writeStringField("id", it.id)
+ }
+ }::class
+ }
+ }
+ }
+ generator.writeArray("screenshots") {
+ screenshots.forEach {
+ writeDictionary {
+ writeStringField("locale", it.locale)
+ writeStringField("type", it.type.jsonName)
+ writeStringField("path", it.path)
+ }
+ }
+ }
+ generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } }
+ }
+
+ companion object {
+ fun findSuggested(products: List, extract: (T) -> Product): T? {
+ return products.maxWith(compareBy({ extract(it).compatible }, { extract(it).versionCode }))
+ }
+
+ fun deserialize(repositoryId: Long, parser: JsonParser): Product {
+ var packageName = ""
+ var name = ""
+ var summary = ""
+ var description = ""
+ var whatsNew = ""
+ var icon = ""
+ var authorName = ""
+ var authorEmail = ""
+ var authorWeb = ""
+ var source = ""
+ var changelog = ""
+ var web = ""
+ var tracker = ""
+ var added = 0L
+ var updated = 0L
+ var suggestedVersionCode = 0L
+ var categories = emptyList()
+ var antiFeatures = emptyList()
+ var licenses = emptyList()
+ var donates = emptyList()
+ var screenshots = emptyList()
+ var releases = emptyList()
+ parser.forEachKey {
+ when {
+ it.string("packageName") -> packageName = valueAsString
+ it.string("name") -> name = valueAsString
+ it.string("summary") -> summary = valueAsString
+ it.string("description") -> description = valueAsString
+ it.string("whatsNew") -> whatsNew = valueAsString
+ it.string("icon") -> icon = valueAsString
+ it.string("authorName") -> authorName = valueAsString
+ it.string("authorEmail") -> authorEmail = valueAsString
+ it.string("authorWeb") -> authorWeb = valueAsString
+ it.string("source") -> source = valueAsString
+ it.string("changelog") -> changelog = valueAsString
+ it.string("web") -> web = valueAsString
+ it.string("tracker") -> tracker = valueAsString
+ it.number("added") -> added = valueAsLong
+ it.number("updated") -> updated = valueAsLong
+ it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong
+ it.array("categories") -> categories = collectNotNullStrings()
+ it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
+ it.array("licenses") -> licenses = collectNotNullStrings()
+ it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) {
+ var type = ""
+ var url = ""
+ var address = ""
+ var id = ""
+ forEachKey {
+ when {
+ it.string("type") -> type = valueAsString
+ it.string("url") -> url = valueAsString
+ it.string("address") -> address = valueAsString
+ it.string("id") -> id = valueAsString
+ else -> skipChildren()
+ }
+ }
+ when (type) {
+ "" -> Donate.Regular(url)
+ "bitcoin" -> Donate.Bitcoin(address)
+ "litecoin" -> Donate.Litecoin(address)
+ "flattr" -> Donate.Flattr(id)
+ "liberapay" -> Donate.Liberapay(id)
+ else -> null
+ }
+ }
+ it.array("screenshots") -> screenshots = collectNotNull(JsonToken.START_OBJECT) {
+ var locale = ""
+ var type = ""
+ var path = ""
+ forEachKey {
+ when {
+ it.string("locale") -> locale = valueAsString
+ it.string("type") -> type = valueAsString
+ it.string("path") -> path = valueAsString
+ else -> skipChildren()
+ }
+ }
+ Screenshot.Type.values().find { it.jsonName == type }?.let { Screenshot(locale, it, path) }
+ }
+ it.array("releases") -> releases = collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
+ else -> skipChildren()
+ }
+ }
+ return Product(repositoryId, packageName, name, summary, description, whatsNew, icon,
+ Author(authorName, authorEmail, authorWeb), source, changelog, web, tracker, added, updated,
+ suggestedVersionCode, categories, antiFeatures, licenses, donates, screenshots, releases)
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/ProductItem.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/ProductItem.kt
new file mode 100644
index 0000000..d198247
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/ProductItem.kt
@@ -0,0 +1,32 @@
+package nya.kitsunyan.foxydroid.entity
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+
+data class ProductItem(val repositoryId: Long, val packageName: String,
+ val name: String, val summary: String, val icon: String, val version: String, val installedVersion: String,
+ val compatible: Boolean, val canUpdate: Boolean) {
+ fun serialize(generator: JsonGenerator) {
+ generator.writeNumberField("serialVersion", 1)
+ generator.writeStringField("icon", icon)
+ generator.writeStringField("version", version)
+ }
+
+ companion object {
+ fun deserialize(repositoryId: Long, packageName: String, name: String, summary: String,
+ installedVersion: String, compatible: Boolean, canUpdate: Boolean, parser: JsonParser): ProductItem {
+ var icon = ""
+ var version = ""
+ parser.forEachKey {
+ when {
+ it.string("icon") -> icon = valueAsString
+ it.string("version") -> version = valueAsString
+ else -> skipChildren()
+ }
+ }
+ return ProductItem(repositoryId, packageName, name, summary, icon,
+ version, installedVersion, compatible, canUpdate)
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/ProductPreference.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/ProductPreference.kt
new file mode 100644
index 0000000..fc9896d
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/ProductPreference.kt
@@ -0,0 +1,31 @@
+package nya.kitsunyan.foxydroid.entity
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+
+data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) {
+ fun shouldIgnoreUpdate(versionCode: Long): Boolean {
+ return ignoreUpdates || ignoreVersionCode == versionCode
+ }
+
+ fun serialize(generator: JsonGenerator) {
+ generator.writeBooleanField("ignoreUpdates", ignoreUpdates)
+ generator.writeNumberField("ignoreVersionCode", ignoreVersionCode)
+ }
+
+ companion object {
+ fun deserialize(parser: JsonParser): ProductPreference {
+ var ignoreUpdates = false
+ var ignoreVersionCode = 0L
+ parser.forEachKey {
+ when {
+ it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean
+ it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong
+ else -> skipChildren()
+ }
+ }
+ return ProductPreference(ignoreUpdates, ignoreVersionCode)
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt
new file mode 100644
index 0000000..6556b98
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt
@@ -0,0 +1,156 @@
+package nya.kitsunyan.foxydroid.entity
+
+import android.net.Uri
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.core.JsonToken
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+
+data class Release(val selected: Boolean, val version: String, val versionCode: Long,
+ val added: Long, val size: Long, val minSdkVersion: Int, val targetSdkVersion: Int, val maxSdkVersion: Int,
+ val source: String, val release: String, val hash: String, val hashType: String, val signature: String,
+ val obbMain: String, val obbMainHash: String, val obbMainHashType: String,
+ val obbPatch: String, val obbPatchHash: String, val obbPatchHashType: String,
+ val permissions: List, val features: List, val platforms: List,
+ val incompatibilities: List) {
+ sealed class Incompatibility {
+ object MinSdk: Incompatibility()
+ object MaxSdk: Incompatibility()
+ object Platform: Incompatibility()
+ class Feature(val feature: String): Incompatibility()
+ }
+
+ val identifier: String
+ get() = "$versionCode.$hash"
+
+ fun getDownloadUrl(repository: Repository): String {
+ return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString()
+ }
+
+ val cacheFileName: String
+ get() = "${hash.replace('/', '-')}.apk"
+
+ fun serialize(generator: JsonGenerator) {
+ generator.writeNumberField("serialVersion", 1)
+ generator.writeBooleanField("selected", selected)
+ generator.writeStringField("version", version)
+ generator.writeNumberField("versionCode", versionCode)
+ generator.writeNumberField("added", added)
+ generator.writeNumberField("size", size)
+ generator.writeNumberField("minSdkVersion", minSdkVersion)
+ generator.writeNumberField("targetSdkVersion", targetSdkVersion)
+ generator.writeNumberField("maxSdkVersion", maxSdkVersion)
+ generator.writeStringField("source", source)
+ generator.writeStringField("release", release)
+ generator.writeStringField("hash", hash)
+ generator.writeStringField("hashType", hashType)
+ generator.writeStringField("signature", signature)
+ generator.writeStringField("obbMain", obbMain)
+ generator.writeStringField("obbMainHash", obbMainHash)
+ generator.writeStringField("obbMainHashType", obbMainHashType)
+ generator.writeStringField("obbPatch", obbPatch)
+ generator.writeStringField("obbPatchHash", obbPatchHash)
+ generator.writeStringField("obbPatchHashType", obbPatchHashType)
+ generator.writeArray("permissions") { permissions.forEach { writeString(it) } }
+ generator.writeArray("features") { features.forEach { writeString(it) } }
+ generator.writeArray("platforms") { platforms.forEach { writeString(it) } }
+ generator.writeArray("incompatibilities") {
+ incompatibilities.forEach {
+ writeDictionary {
+ when (it) {
+ is Incompatibility.MinSdk -> {
+ writeStringField("type", "minSdk")
+ }
+ is Incompatibility.MaxSdk -> {
+ writeStringField("type", "maxSdk")
+ }
+ is Incompatibility.Platform -> {
+ writeStringField("type", "platform")
+ }
+ is Incompatibility.Feature -> {
+ writeStringField("type", "feature")
+ writeStringField("feature", it.feature)
+ }
+ }::class
+ }
+ }
+ }
+ }
+
+ companion object {
+ fun deserialize(parser: JsonParser): Release {
+ var selected = false
+ var version = ""
+ var versionCode = 0L
+ var added = 0L
+ var size = 0L
+ var minSdkVersion = 0
+ var targetSdkVersion = 0
+ var maxSdkVersion = 0
+ var source = ""
+ var release = ""
+ var hash = ""
+ var hashType = ""
+ var signature = ""
+ var obbMain = ""
+ var obbMainHash = ""
+ var obbMainHashType = ""
+ var obbPatch = ""
+ var obbPatchHash = ""
+ var obbPatchHashType = ""
+ var permissions = emptyList()
+ var features = emptyList()
+ var platforms = emptyList()
+ var incompatibilities = emptyList()
+ parser.forEachKey {
+ when {
+ it.boolean("selected") -> selected = valueAsBoolean
+ it.string("version") -> version = valueAsString
+ it.number("versionCode") -> versionCode = valueAsLong
+ it.number("added") -> added = valueAsLong
+ it.number("size") -> size = valueAsLong
+ it.number("minSdkVersion") -> minSdkVersion = valueAsInt
+ it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
+ it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
+ it.string("source") -> source = valueAsString
+ it.string("release") -> release = valueAsString
+ it.string("hash") -> hash = valueAsString
+ it.string("hashType") -> hashType = valueAsString
+ it.string("signature") -> signature = valueAsString
+ it.string("obbMain") -> obbMain = valueAsString
+ it.string("obbMainHash") -> obbMainHash = valueAsString
+ it.string("obbMainHashType") -> obbMainHashType = valueAsString
+ it.string("obbPatch") -> obbPatch = valueAsString
+ it.string("obbPatchHash") -> obbPatchHash = valueAsString
+ it.string("obbPatchHashType") -> obbPatchHashType = valueAsString
+ it.array("permissions") -> permissions = collectNotNullStrings()
+ it.array("features") -> features = collectNotNullStrings()
+ it.array("platforms") -> platforms = collectNotNullStrings()
+ it.array("incompatibilities") -> incompatibilities = collectNotNull(JsonToken.START_OBJECT) {
+ var type = ""
+ var feature = ""
+ forEachKey {
+ when {
+ it.string("type") -> type = valueAsString
+ it.string("feature") -> feature = valueAsString
+ else -> skipChildren()
+ }
+ }
+ when (type) {
+ "minSdk" -> Incompatibility.MinSdk
+ "maxSdk" -> Incompatibility.MaxSdk
+ "platform" -> Incompatibility.Platform
+ "feature" -> Incompatibility.Feature(feature)
+ else -> null
+ }
+ }
+ else -> skipChildren()
+ }
+ }
+ return Release(selected, version, versionCode, added, size,
+ minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature,
+ obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType,
+ permissions, features, platforms, incompatibilities)
+ }
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt
new file mode 100644
index 0000000..6debcf0
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt
@@ -0,0 +1,118 @@
+package nya.kitsunyan.foxydroid.entity
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import nya.kitsunyan.foxydroid.utility.extension.json.*
+import java.net.URL
+
+data class Repository(val id: Long, val address: String, val mirrors: List,
+ val name: String, val description: String, val version: Int, val enabled: Boolean,
+ val fingerprint: String, val lastModified: String, val entityTag: String,
+ val updated: Long, val timestamp: Long, val authentication: String) {
+ fun edit(address: String, fingerprint: String, authentication: String): Repository {
+ val addressChanged = this.address != address
+ val fingerprintChanged = this.fingerprint != fingerprint
+ val changed = addressChanged || fingerprintChanged
+ return copy(address = address, fingerprint = fingerprint, lastModified = if (changed) "" else lastModified,
+ entityTag = if (changed) "" else entityTag, authentication = authentication)
+ }
+
+ fun update(mirrors: List, name: String, description: String, version: Int,
+ lastModified: String, entityTag: String, timestamp: Long): Repository {
+ return copy(mirrors = mirrors, name = name, description = description,
+ version = if (version >= 0) version else this.version, lastModified = lastModified,
+ entityTag = entityTag, updated = System.currentTimeMillis(), timestamp = timestamp)
+ }
+
+ fun enable(enabled: Boolean): Repository {
+ return copy(enabled = enabled, lastModified = "", entityTag = "")
+ }
+
+ fun serialize(generator: JsonGenerator) {
+ generator.writeNumberField("serialVersion", 1)
+ generator.writeStringField("address", address)
+ generator.writeArray("mirrors") { mirrors.forEach { writeString(it) } }
+ generator.writeStringField("name", name)
+ generator.writeStringField("description", description)
+ generator.writeNumberField("version", version)
+ generator.writeBooleanField("enabled", enabled)
+ generator.writeStringField("fingerprint", fingerprint)
+ generator.writeStringField("lastModified", lastModified)
+ generator.writeStringField("entityTag", entityTag)
+ generator.writeNumberField("updated", updated)
+ generator.writeNumberField("timestamp", timestamp)
+ generator.writeStringField("authentication", authentication)
+ }
+
+ companion object {
+ fun deserialize(id: Long, parser: JsonParser): Repository {
+ var address = ""
+ var mirrors = emptyList()
+ var name = ""
+ var description = ""
+ var version = 0
+ var enabled = false
+ var fingerprint = ""
+ var lastModified = ""
+ var entityTag = ""
+ var updated = 0L
+ var timestamp = 0L
+ var authentication = ""
+ parser.forEachKey {
+ when {
+ it.string("address") -> address = valueAsString
+ it.array("mirrors") -> mirrors = collectNotNullStrings()
+ it.string("name") -> name = valueAsString
+ it.string("description") -> description = valueAsString
+ it.number("version") -> version = valueAsInt
+ it.boolean("enabled") -> enabled = valueAsBoolean
+ it.string("fingerprint") -> fingerprint = valueAsString
+ it.string("lastModified") -> lastModified = valueAsString
+ it.string("entityTag") -> entityTag = valueAsString
+ it.number("updated") -> updated = valueAsLong
+ it.number("timestamp") -> timestamp = valueAsLong
+ it.string("authentication") -> authentication = valueAsString
+ else -> skipChildren()
+ }
+ }
+ return Repository(id, address, mirrors, name, description, version, enabled, fingerprint,
+ lastModified, entityTag, updated, timestamp, authentication)
+ }
+
+ fun newRepository(address: String, fingerprint: String, authentication: String): Repository {
+ val name = try {
+ URL(address).let { "${it.host}${it.path}" }
+ } catch (e: Exception) {
+ address
+ }
+ return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
+ }
+
+ private fun defaultRepository(address: String, name: String, description: String,
+ version: Int, enabled: Boolean, fingerprint: String, authentication: String): Repository {
+ return Repository(-1, address, emptyList(), name, description, version, enabled,
+ fingerprint, "", "", 0L, 0L, authentication)
+ }
+
+ val defaultRepositories = listOf(run {
+ defaultRepository("https://f-droid.org/repo", "F-Droid", "The official F-Droid Free Software repository. " +
+ "Everything in this repository is always built from the source code.",
+ 21, true, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
+ }, run {
+ defaultRepository("https://f-droid.org/archive", "F-Droid Archive", "The archive of the official F-Droid Free " +
+ "Software repository. Apps here are old and can contain known vulnerabilities and security issues!",
+ 21, false, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
+ }, run {
+ defaultRepository("https://guardianproject.info/fdroid/repo", "Guardian Project Official Releases", "The " +
+ "official repository of The Guardian Project apps for use with the F-Droid client. Applications in this " +
+ "repository are official binaries built by the original application developers and signed by the same key as " +
+ "the APKs that are released in the Google Play Store.",
+ 21, false, "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135", "")
+ }, run {
+ defaultRepository("https://guardianproject.info/fdroid/archive", "Guardian Project Archive", "The official " +
+ "repository of The Guardian Project apps for use with the F-Droid client. This contains older versions of " +
+ "applications from the main repository.", 21, false,
+ "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135", "")
+ })
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/graphics/DrawableWrapper.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/graphics/DrawableWrapper.kt
new file mode 100644
index 0000000..ab2e1e5
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/graphics/DrawableWrapper.kt
@@ -0,0 +1,56 @@
+package nya.kitsunyan.foxydroid.graphics
+
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+
+open class DrawableWrapper(val drawable: Drawable): Drawable() {
+ init {
+ drawable.callback = object: Callback {
+ override fun invalidateDrawable(who: Drawable) {
+ callback?.invalidateDrawable(who)
+ }
+
+ override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
+ callback?.scheduleDrawable(who, what, `when`)
+ }
+
+ override fun unscheduleDrawable(who: Drawable, what: Runnable) {
+ callback?.unscheduleDrawable(who, what)
+ }
+ }
+ }
+
+ override fun onBoundsChange(bounds: Rect) {
+ drawable.bounds = bounds
+ }
+
+ override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth
+ override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight
+ override fun getMinimumWidth(): Int = drawable.minimumWidth
+ override fun getMinimumHeight(): Int = drawable.minimumHeight
+
+ override fun draw(canvas: Canvas) {
+ drawable.draw(canvas)
+ }
+
+ override fun getAlpha(): Int {
+ return drawable.alpha
+ }
+
+ override fun setAlpha(alpha: Int) {
+ drawable.alpha = alpha
+ }
+
+ override fun getColorFilter(): ColorFilter? {
+ return drawable.colorFilter
+ }
+
+ override fun setColorFilter(colorFilter: ColorFilter?) {
+ drawable.colorFilter = colorFilter
+ }
+
+ @Suppress("DEPRECATION")
+ override fun getOpacity(): Int = drawable.opacity
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/graphics/PaddingDrawable.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/graphics/PaddingDrawable.kt
new file mode 100644
index 0000000..5f6db03
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/graphics/PaddingDrawable.kt
@@ -0,0 +1,19 @@
+package nya.kitsunyan.foxydroid.graphics
+
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import kotlin.math.*
+
+class PaddingDrawable(drawable: Drawable, private val factor: Float): DrawableWrapper(drawable) {
+ override fun getIntrinsicWidth(): Int = (factor * super.getIntrinsicWidth()).roundToInt()
+ override fun getIntrinsicHeight(): Int = (factor * super.getIntrinsicHeight()).roundToInt()
+
+ override fun onBoundsChange(bounds: Rect) {
+ val width = (bounds.width() / factor).roundToInt()
+ val height = (bounds.height() / factor).roundToInt()
+ val left = (bounds.width() - width) / 2
+ val top = (bounds.height() - height) / 2
+ drawable.setBounds(bounds.left + left, bounds.top + top,
+ bounds.left + left + width, bounds.top + top + height)
+ }
+}
diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexHandler.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexHandler.kt
new file mode 100644
index 0000000..13aa518
--- /dev/null
+++ b/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexHandler.kt
@@ -0,0 +1,259 @@
+package nya.kitsunyan.foxydroid.index
+
+import nya.kitsunyan.foxydroid.entity.Product
+import nya.kitsunyan.foxydroid.entity.Release
+import nya.kitsunyan.foxydroid.utility.extension.android.*
+import org.xml.sax.Attributes
+import org.xml.sax.helpers.DefaultHandler
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+class IndexHandler(private val repositoryId: Long, private val callback: Callback): DefaultHandler() {
+ companion object {
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ .apply { timeZone = TimeZone.getTimeZone("UTC") }
+
+ private fun String.parseDate(): Long {
+ return try {
+ dateFormat.parse(this)?.time ?: 0L
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+ }
+
+ interface Callback {
+ fun onRepository(mirrors: List, name: String, description: String,
+ certificate: String, version: Int, timestamp: Long)
+ fun onProduct(product: Product)
+ }
+
+ internal object DonateComparator: Comparator {
+ private val classes = listOf(Product.Donate.Regular::class, Product.Donate.Bitcoin::class,
+ Product.Donate.Litecoin::class, Product.Donate.Flattr::class, Product.Donate.Liberapay::class)
+
+ override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
+ val index1 = classes.indexOf(donate1::class)
+ val index2 = classes.indexOf(donate2::class)
+ return when {
+ index1 >= 0 && index2 == -1 -> -1
+ index2 >= 0 && index1 == -1 -> 1
+ else -> index1.compareTo(index2)
+ }
+ }
+ }
+
+ private class RepositoryBuilder {
+ var address = ""
+ val mirrors = mutableListOf()
+ var name = ""
+ var description = ""
+ var certificate = ""
+ var version = -1
+ var timestamp = 0L
+ }
+
+ private class ProductBuilder(val repositoryId: Long, val packageName: String) {
+ var name = ""
+ var summary = ""
+ var description = ""
+ var icon = ""
+ var authorName = ""
+ var authorEmail = ""
+ var source = ""
+ var changelog = ""
+ var web = ""
+ var tracker = ""
+ var added = 0L
+ var updated = 0L
+ var suggestedVersionCode = 0L
+ val categories = linkedSetOf()
+ val antiFeatures = linkedSetOf()
+ val licenses = mutableListOf()
+ val donates = mutableListOf()
+ val releases = mutableListOf()
+
+ fun build(): Product {
+ return Product(repositoryId, packageName, name, summary, description, "", icon,
+ Product.Author(authorName, authorEmail, ""), source, changelog, web, tracker, added, updated,
+ suggestedVersionCode, categories.toList(), antiFeatures.toList(),
+ licenses, donates.sortedWith(DonateComparator), emptyList(), releases)
+ }
+ }
+
+ private class ReleaseBuilder {
+ var version = ""
+ var versionCode = 0L
+ var added = 0L
+ var size = 0L
+ var minSdkVersion = 0
+ var targetSdkVersion = 0
+ var maxSdkVersion = 0
+ var source = ""
+ var release = ""
+ var hash = ""
+ var hashType = ""
+ var signature = ""
+ var obbMain = ""
+ var obbMainHash = ""
+ var obbPatch = ""
+ var obbPatchHash = ""
+ val permissions = linkedSetOf()
+ val features = linkedSetOf()
+ val platforms = linkedSetOf()
+
+ fun build(): Release {
+ val hashType = if (hash.isNotEmpty() && hashType.isEmpty()) "sha256" else hashType
+ val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
+ val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
+ return Release(false, version, versionCode, added, size,
+ minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature,
+ obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType,
+ permissions.toList(), features.toList(), platforms.toList(), emptyList())
+ }
+ }
+
+ private val contentBuilder = StringBuilder()
+
+ private var repositoryBuilder: RepositoryBuilder? = RepositoryBuilder()
+ private var productBuilder: ProductBuilder? = null
+ private var releaseBuilder: ReleaseBuilder? = null
+
+ private fun Attributes.get(localName: String): String = getValue("", localName).orEmpty()
+ private fun String.cleanWhiteSpace(): String = replace("\\s".toRegex(), " ")
+
+ override fun startElement(uri: String, localName: String, qName: String, attributes: Attributes) {
+ super.startElement(uri, localName, qName, attributes)
+
+ val repositoryBuilder = repositoryBuilder
+ val productBuilder = productBuilder
+ val releaseBuilder = releaseBuilder
+ contentBuilder.setLength(0)
+
+ when {
+ localName == "repo" -> {
+ if (repositoryBuilder != null) {
+ repositoryBuilder.address = attributes.get("url").cleanWhiteSpace()
+ repositoryBuilder.name = attributes.get("name").cleanWhiteSpace()
+ repositoryBuilder.description = attributes.get("description").cleanWhiteSpace()
+ repositoryBuilder.certificate = attributes.get("pubkey")
+ repositoryBuilder.version = attributes.get("version").toIntOrNull() ?: 0
+ repositoryBuilder.timestamp = (attributes.get("timestamp").toLongOrNull() ?: 0L) * 1000L
+ }
+ }
+ localName == "application" && productBuilder == null -> {
+ this.productBuilder = ProductBuilder(repositoryId, attributes.get("id"))
+ }
+ localName == "package" && productBuilder != null && releaseBuilder == null -> {
+ this.releaseBuilder = ReleaseBuilder()
+ }
+ localName == "hash" && releaseBuilder != null -> {
+ releaseBuilder.hashType = attributes.get("type")
+ }
+ (localName == "uses-permission" || localName.startsWith("uses-permission-")) && releaseBuilder != null -> {
+ val minSdkVersion = if (localName != "uses-permission") {
+ "uses-permission-sdk-(\\d+)".toRegex().matchEntire(localName)
+ ?.destructured?.let { (version) -> version.toIntOrNull() }
+ } else {
+ null
+ } ?: 0
+ val maxSdkVersion = attributes.get("maxSdkVersion").toIntOrNull() ?: Int.MAX_VALUE
+ if (Android.sdk in minSdkVersion .. maxSdkVersion) {
+ releaseBuilder.permissions.add(attributes.get("name"))
+ } else {
+ releaseBuilder.permissions.remove(attributes.get("name"))
+ }
+ }
+ }
+ }
+
+ override fun endElement(uri: String, localName: String, qName: String) {
+ super.endElement(uri, localName, qName)
+
+ val repositoryBuilder = repositoryBuilder
+ val productBuilder = productBuilder
+ val releaseBuilder = releaseBuilder
+ val content = contentBuilder.toString()
+
+ when {
+ localName == "repo" -> {
+ if (repositoryBuilder != null) {
+ val mirrors = (listOf(repositoryBuilder.address) + repositoryBuilder.mirrors)
+ .filter { it.isNotEmpty() }.distinct()
+ callback.onRepository(mirrors, repositoryBuilder.name, repositoryBuilder.description,
+ repositoryBuilder.certificate, repositoryBuilder.version, repositoryBuilder.timestamp)
+ this.repositoryBuilder = null
+ }
+ }
+ localName == "application" && productBuilder != null -> {
+ val product = productBuilder.build()
+ this.productBuilder = null
+ callback.onProduct(product)
+ }
+ localName == "package" && productBuilder != null && releaseBuilder != null -> {
+ productBuilder.releases.add(releaseBuilder.build())
+ this.releaseBuilder = null
+ }
+ repositoryBuilder != null -> {
+ when (localName) {
+ "description" -> repositoryBuilder.description = content.cleanWhiteSpace()
+ "mirror" -> repositoryBuilder.mirrors += content
+ }
+ }
+ productBuilder != null && releaseBuilder != null -> {
+ when (localName) {
+ "version" -> releaseBuilder.version = content
+ "versioncode" -> releaseBuilder.versionCode = content.toLongOrNull() ?: 0L
+ "added" -> releaseBuilder.added = content.parseDate()
+ "size" -> releaseBuilder.size = content.toLongOrNull() ?: 0
+ "sdkver" -> releaseBuilder.minSdkVersion = content.toIntOrNull() ?: 0
+ "targetSdkVersion" -> releaseBuilder.targetSdkVersion = content.toIntOrNull() ?: 0
+ "maxsdkver" -> releaseBuilder.maxSdkVersion = content.toIntOrNull() ?: 0
+ "srcname" -> releaseBuilder.source = content
+ "apkname" -> releaseBuilder.release = content
+ "hash" -> releaseBuilder.hash = content
+ "sig" -> releaseBuilder.signature = content
+ "obbMainFile" -> releaseBuilder.obbMain = content
+ "obbMainFileSha256" -> releaseBuilder.obbMainHash = content
+ "obbPatchFile" -> releaseBuilder.obbPatch = content
+ "obbPatchFileSha256" -> releaseBuilder.obbPatchHash = content
+ "permissions" -> releaseBuilder.permissions += content.split(',')
+ "features" -> releaseBuilder.features += content.split(',')
+ "nativecode" -> releaseBuilder.platforms += content.split(',')
+ }
+ }
+ productBuilder != null -> {
+ when (localName) {
+ "name" -> productBuilder.name = content
+ "summary" -> productBuilder.summary = content
+ "description" -> productBuilder.description = "