mirror of
https://github.com/Michatec/michas-droid.git
synced 2026-05-30 18:02:43 +02:00
Initial commit
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
!/.gitignore
|
||||||
|
!/build.gradle
|
||||||
|
!/COPYING
|
||||||
|
!/extra
|
||||||
|
!/gradle
|
||||||
|
!/gradle.properties
|
||||||
|
!/gradlew
|
||||||
|
!/gradlew.bat
|
||||||
|
!/metadata
|
||||||
|
!/proguard.pro
|
||||||
|
!/README.md
|
||||||
|
!/src
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -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.
|
||||||
+117
@@ -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'
|
||||||
|
}
|
||||||
Executable
+27
@@ -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'
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="enable-background:new"
|
||||||
|
id="svg3"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 108 108"
|
||||||
|
height="108"
|
||||||
|
width="108">
|
||||||
|
<metadata
|
||||||
|
id="metadata55">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<defs
|
||||||
|
id="defs17">
|
||||||
|
<clipPath
|
||||||
|
id="paper-clip"
|
||||||
|
clipPathUnits="userSpaceOnUse">
|
||||||
|
<path
|
||||||
|
id="paper-clip-path"
|
||||||
|
d="m 67.759585,28.469613 -18.355472,18.146485 -21.453125,-4.732422 9.087891,24.064453 25.894536,12.974609 12.37695,-23.86914 -1.93359,-0.66211 z" />
|
||||||
|
</clipPath>
|
||||||
|
<filter
|
||||||
|
id="paper-inner-shadow">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood6"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.2" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite8"
|
||||||
|
result="composite1"
|
||||||
|
operator="in"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur10"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="2"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset12"
|
||||||
|
result="offset"
|
||||||
|
dy="0"
|
||||||
|
dx="0" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite14"
|
||||||
|
result="composite2"
|
||||||
|
operator="over"
|
||||||
|
in2="offset"
|
||||||
|
in="SourceGraphic" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id="paper-edge-1"
|
||||||
|
style="color-interpolation-filters:sRGB">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood958"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.2" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite960"
|
||||||
|
result="composite1"
|
||||||
|
operator="out"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur962"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="0"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset964"
|
||||||
|
result="offset"
|
||||||
|
dy="0.2"
|
||||||
|
dx="0.2" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite966"
|
||||||
|
result="composite2"
|
||||||
|
operator="atop"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="offset" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id="paper-edge-2"
|
||||||
|
style="color-interpolation-filters:sRGB">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood1388"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(255,255,255)"
|
||||||
|
flood-opacity="0.2" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1390"
|
||||||
|
result="composite1"
|
||||||
|
operator="out"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur1392"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="0"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset1394"
|
||||||
|
result="offset"
|
||||||
|
dy="-0.1"
|
||||||
|
dx="0.3" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1396"
|
||||||
|
result="composite2"
|
||||||
|
operator="atop"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="offset" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id="paper-shadow"
|
||||||
|
style="color-interpolation-filters:sRGB">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood1506"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.4" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1508"
|
||||||
|
result="composite1"
|
||||||
|
operator="in"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur1510"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="2"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset1512"
|
||||||
|
result="offset"
|
||||||
|
dy="0"
|
||||||
|
dx="0" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1514"
|
||||||
|
result="composite2"
|
||||||
|
operator="over"
|
||||||
|
in2="offset"
|
||||||
|
in="SourceGraphic" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id="circle-shadow"
|
||||||
|
style="color-interpolation-filters:sRGB">
|
||||||
|
<feFlood
|
||||||
|
id="feFlood1626"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(255,255,255)"
|
||||||
|
flood-opacity="0.2" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1628"
|
||||||
|
result="composite1"
|
||||||
|
operator="out"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="flood" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur1630"
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="0"
|
||||||
|
in="composite1" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset1632"
|
||||||
|
result="offset"
|
||||||
|
dy="0.5"
|
||||||
|
dx="0" />
|
||||||
|
<feComposite
|
||||||
|
id="feComposite1634"
|
||||||
|
result="fbSourceGraphic"
|
||||||
|
operator="atop"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
in="offset" />
|
||||||
|
<feFlood
|
||||||
|
in="fbSourceGraphic"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.2"
|
||||||
|
id="feFlood1640" />
|
||||||
|
<feComposite
|
||||||
|
result="composite1"
|
||||||
|
operator="out"
|
||||||
|
in="flood"
|
||||||
|
id="feComposite1642"
|
||||||
|
in2="fbSourceGraphic" />
|
||||||
|
<feGaussianBlur
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="0"
|
||||||
|
in="composite1"
|
||||||
|
id="feGaussianBlur1644" />
|
||||||
|
<feOffset
|
||||||
|
result="offset"
|
||||||
|
dy="-0.5"
|
||||||
|
dx="0"
|
||||||
|
id="feOffset1646" />
|
||||||
|
<feComposite
|
||||||
|
result="fbSourceGraphic"
|
||||||
|
operator="atop"
|
||||||
|
in="offset"
|
||||||
|
id="feComposite1648"
|
||||||
|
in2="fbSourceGraphic" />
|
||||||
|
<feFlood
|
||||||
|
in="fbSourceGraphic"
|
||||||
|
result="flood"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
flood-opacity="0.2"
|
||||||
|
id="feFlood1664" />
|
||||||
|
<feComposite
|
||||||
|
result="composite1"
|
||||||
|
operator="in"
|
||||||
|
in="flood"
|
||||||
|
id="feComposite1666"
|
||||||
|
in2="fbSourceGraphic" />
|
||||||
|
<feGaussianBlur
|
||||||
|
result="blur"
|
||||||
|
stdDeviation="1"
|
||||||
|
in="composite1"
|
||||||
|
id="feGaussianBlur1668" />
|
||||||
|
<feOffset
|
||||||
|
result="offset"
|
||||||
|
dy="1"
|
||||||
|
dx="0"
|
||||||
|
id="feOffset1670" />
|
||||||
|
<feComposite
|
||||||
|
result="composite2"
|
||||||
|
operator="over"
|
||||||
|
in="fbSourceGraphic"
|
||||||
|
id="feComposite1672"
|
||||||
|
in2="offset" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle
|
||||||
|
r="36"
|
||||||
|
cy="54"
|
||||||
|
cx="54"
|
||||||
|
id="circle"
|
||||||
|
style="fill:#262c38;filter:url(#circle-shadow)" />
|
||||||
|
<g
|
||||||
|
style="filter:url(#paper-shadow)"
|
||||||
|
id="paper-group">
|
||||||
|
<path
|
||||||
|
d="m 67.75836,28.470764 -23.382292,23.115602 -0.0887,1.254642 29.189963,2.017946 z"
|
||||||
|
style="fill:#1976d2"
|
||||||
|
id="paper-4" />
|
||||||
|
<path
|
||||||
|
style="fill:#47a2fc;filter:url(#paper-inner-shadow)"
|
||||||
|
d="m 27.949219,41.884766 9.08789,24.064453 25.894532,12.974609 12.376953,-23.86914 -22.298828,-7.638672 v -0.002 z"
|
||||||
|
clip-path="url(#paper-clip)"
|
||||||
|
id="paper-3" />
|
||||||
|
<path
|
||||||
|
d="m 53.009473,47.414648 -15.970174,18.53113 25.894116,12.97696 z"
|
||||||
|
style="fill:#1976d2;filter:url(#paper-edge-1)"
|
||||||
|
id="paper-2" />
|
||||||
|
<path
|
||||||
|
style="fill:#47a2fc;filter:url(#paper-edge-2)"
|
||||||
|
d="m 53.009766,47.414016 9.923828,31.503906 12.375,-23.865234 z"
|
||||||
|
id="paper-1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
Vendored
BIN
Binary file not shown.
+5
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
Vendored
+84
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Unofficial F-Droid client that resembles classic F-Droid client.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Yet another F-Droid client
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-dontobfuscate
|
||||||
|
|
||||||
|
# Disable ServiceLoader reproducibility-breaking optimizations
|
||||||
|
-keep class kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
-keep class kotlinx.coroutines.internal.MainDispatcherFactory
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="nya.kitsunyan.foxydroid">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".MainApplication"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:theme="@style/Theme.Main.Light"
|
||||||
|
tools:ignore="GoogleAppIndexingWarning">
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".MainApplication$BootReceiver">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="fdroid.app" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="market" android:host="details" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.SyncService" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.SyncService$Job"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.DownloadService" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.DownloadService$Receiver" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".content.Cache$Provider"
|
||||||
|
android:authorities="${applicationId}.provider.cache"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Binder>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Int>) {
|
||||||
|
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<File, String> {
|
||||||
|
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<String>?,
|
||||||
|
selection: String?, selectionArgs: Array<out String>?, 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<out String>?): Int = unsupported
|
||||||
|
override fun update(uri: Uri, contentValues: ContentValues?,
|
||||||
|
selection: String?, selectionArgs: Array<out String>?): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Key<*>>()
|
||||||
|
|
||||||
|
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<Key<*>>
|
||||||
|
get() = subject
|
||||||
|
|
||||||
|
sealed class Value<T> {
|
||||||
|
abstract val value: T
|
||||||
|
|
||||||
|
internal abstract fun get(preferences: SharedPreferences, key: String, defaultValue: Value<T>): T
|
||||||
|
internal abstract fun set(preferences: SharedPreferences, key: String, value: T)
|
||||||
|
|
||||||
|
class BooleanValue(override val value: Boolean): Value<Boolean>() {
|
||||||
|
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<Boolean>): 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<Int>() {
|
||||||
|
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<Int>): 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<String>() {
|
||||||
|
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<String>): 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<T: Enumeration<T>>(override val value: T): Value<T>() {
|
||||||
|
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<T>): 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<T> {
|
||||||
|
val values: List<T>
|
||||||
|
val valueString: String
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Key<T>(val name: String, val default: Value<T>) {
|
||||||
|
object IncompatibleVersions: Key<Boolean>("incompatible_versions", Value.BooleanValue(false))
|
||||||
|
object ProxyHost: Key<String>("proxy_host", Value.StringValue("localhost"))
|
||||||
|
object ProxyPort: Key<Int>("proxy_port", Value.IntValue(9050))
|
||||||
|
object ProxyType: Key<Preferences.ProxyType>("proxy_type", Value.EnumerationValue(Preferences.ProxyType.Direct))
|
||||||
|
object Theme: Key<Preferences.Theme>("theme", Value.EnumerationValue(Preferences.Theme.Light))
|
||||||
|
object AutoSync: Key<Preferences.AutoSync>("auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi))
|
||||||
|
object UpdateNotify: Key<Boolean>("update_notify", Value.BooleanValue(true))
|
||||||
|
object UpdateUnstable: Key<Boolean>("update_unstable", Value.BooleanValue(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AutoSync(override val valueString: String): Enumeration<AutoSync> {
|
||||||
|
override val values: List<AutoSync>
|
||||||
|
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<ProxyType> {
|
||||||
|
override val values: List<ProxyType>
|
||||||
|
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<Theme> {
|
||||||
|
override val values: List<Theme>
|
||||||
|
get() = listOf(Light, Dark)
|
||||||
|
|
||||||
|
object Light: Theme("light", R.style.Theme_Main_Light)
|
||||||
|
object Dark: Theme("dark", R.style.Theme_Main_Dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun <T> get(key: Key<T>): T {
|
||||||
|
return key.default.get(preferences, key.name, key.default)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun <T> set(key: Key<T>, value: T) {
|
||||||
|
key.default.set(preferences, key.name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Pair<String, Long?>>()
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Cursor> {
|
||||||
|
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<Int, ActiveRequest>()
|
||||||
|
|
||||||
|
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<Cursor> {
|
||||||
|
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<Cursor>, 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<Cursor>) = onLoadFinished(loader, null)
|
||||||
|
}
|
||||||
@@ -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<String, String>?
|
||||||
|
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<Subject, MutableSet<() -> 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<Unit> {
|
||||||
|
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<String>? = null,
|
||||||
|
selection: Pair<String, Array<String>>? = 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 <T> 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<Repository> {
|
||||||
|
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<Pair<Long, Boolean>> {
|
||||||
|
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<Pair<Long, Boolean>>) {
|
||||||
|
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<Product> {
|
||||||
|
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<String> {
|
||||||
|
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<InstalledItem>) {
|
||||||
|
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<String, Long>, 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<String, Long>) = put(lock, true)
|
||||||
|
|
||||||
|
fun putAll(locks: List<Pair<String, Long>>) {
|
||||||
|
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<Product>) {
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
|
||||||
|
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<String>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Cursor>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.entity
|
||||||
|
|
||||||
|
class InstalledItem(val packageName: String, val version: String, val versionCode: Long, val signature: String)
|
||||||
@@ -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<String>, val antiFeatures: List<String>, val licenses: List<String>,
|
||||||
|
val donates: List<Donate>, val screenshots: List<Screenshot>, val releases: List<Release>) {
|
||||||
|
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 <T> findSuggested(products: List<T>, 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<String>()
|
||||||
|
var antiFeatures = emptyList<String>()
|
||||||
|
var licenses = emptyList<String>()
|
||||||
|
var donates = emptyList<Donate>()
|
||||||
|
var screenshots = emptyList<Screenshot>()
|
||||||
|
var releases = emptyList<Release>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>, val features: List<String>, val platforms: List<String>,
|
||||||
|
val incompatibilities: List<Incompatibility>) {
|
||||||
|
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<String>()
|
||||||
|
var features = emptyList<String>()
|
||||||
|
var platforms = emptyList<String>()
|
||||||
|
var incompatibilities = emptyList<Incompatibility>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<String>, 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<String>()
|
||||||
|
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", "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>, name: String, description: String,
|
||||||
|
certificate: String, version: Int, timestamp: Long)
|
||||||
|
fun onProduct(product: Product)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object DonateComparator: Comparator<Product.Donate> {
|
||||||
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
val antiFeatures = linkedSetOf<String>()
|
||||||
|
val licenses = mutableListOf<String>()
|
||||||
|
val donates = mutableListOf<Product.Donate>()
|
||||||
|
val releases = mutableListOf<Release>()
|
||||||
|
|
||||||
|
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<String>()
|
||||||
|
val features = linkedSetOf<String>()
|
||||||
|
val platforms = linkedSetOf<String>()
|
||||||
|
|
||||||
|
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 = "<p>$content</p>"
|
||||||
|
"desc" -> productBuilder.description = content.replace("\n", "<br/>")
|
||||||
|
"icon" -> productBuilder.icon = content
|
||||||
|
"author" -> productBuilder.authorName = content
|
||||||
|
"email" -> productBuilder.authorEmail = content
|
||||||
|
"source" -> productBuilder.source = content
|
||||||
|
"changelog" -> productBuilder.changelog = content
|
||||||
|
"web" -> productBuilder.web = content
|
||||||
|
"tracker" -> productBuilder.tracker = content
|
||||||
|
"added" -> productBuilder.added = content.parseDate()
|
||||||
|
"lastupdated" -> productBuilder.updated = content.parseDate()
|
||||||
|
"marketvercode" -> productBuilder.suggestedVersionCode = content.toLongOrNull() ?: 0L
|
||||||
|
"categories" -> productBuilder.categories += content.split(',')
|
||||||
|
"antifeatures" -> productBuilder.antiFeatures += content.split(',')
|
||||||
|
"license" -> productBuilder.licenses += content.split(',')
|
||||||
|
"donate" -> productBuilder.donates += Product.Donate.Regular(content)
|
||||||
|
"bitcoin" -> productBuilder.donates += Product.Donate.Bitcoin(content)
|
||||||
|
"litecoin" -> productBuilder.donates += Product.Donate.Litecoin(content)
|
||||||
|
"flattr" -> productBuilder.donates += Product.Donate.Flattr(content)
|
||||||
|
"liberapay" -> productBuilder.donates += Product.Donate.Liberapay(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun characters(ch: CharArray, start: Int, length: Int) {
|
||||||
|
super.characters(ch, start, length)
|
||||||
|
contentBuilder.append(ch, start, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.index
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import com.fasterxml.jackson.core.JsonToken
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Product
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Release
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.json.*
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class IndexMerger(file: File): Closeable {
|
||||||
|
private val db = SQLiteDatabase.openOrCreateDatabase(file, null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
db.execWithResult("PRAGMA synchronous = OFF")
|
||||||
|
db.execWithResult("PRAGMA journal_mode = OFF")
|
||||||
|
db.execSQL("CREATE TABLE product (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)")
|
||||||
|
db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)")
|
||||||
|
db.beginTransaction()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addProducts(products: List<Product>) {
|
||||||
|
for (product in products) {
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
Json.factory.createGenerator(outputStream).use { it.writeDictionary(product::serialize) }
|
||||||
|
db.insert("product", null, ContentValues().apply {
|
||||||
|
put("package_name", product.packageName)
|
||||||
|
put("data", outputStream.toByteArray())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReleases(pairs: List<Pair<String, List<Release>>>) {
|
||||||
|
for (pair in pairs) {
|
||||||
|
val (packageName, releases) = pair
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
Json.factory.createGenerator(outputStream).use {
|
||||||
|
it.writeStartArray()
|
||||||
|
for (release in releases) {
|
||||||
|
it.writeDictionary(release::serialize)
|
||||||
|
}
|
||||||
|
it.writeEndArray()
|
||||||
|
}
|
||||||
|
db.insert("releases", null, ContentValues().apply {
|
||||||
|
put("package_name", packageName)
|
||||||
|
put("data", outputStream.toByteArray())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeTransaction() {
|
||||||
|
if (db.inTransaction()) {
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
db.endTransaction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
|
||||||
|
closeTransaction()
|
||||||
|
db.rawQuery("""SELECT product.data AS p, releases.data AS d FROM product
|
||||||
|
LEFT JOIN releases ON product.package_name = releases.package_name""", null)
|
||||||
|
?.use { it.asSequence().map {
|
||||||
|
val product = Json.factory.createParser(it.getBlob(0)).use {
|
||||||
|
it.nextToken()
|
||||||
|
Product.deserialize(repositoryId, it)
|
||||||
|
}
|
||||||
|
val releases = it.getBlob(1)?.let { Json.factory.createParser(it).use {
|
||||||
|
it.nextToken()
|
||||||
|
it.collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
|
||||||
|
} }.orEmpty()
|
||||||
|
product.copy(releases = releases)
|
||||||
|
}.windowed(windowSize, windowSize, true).forEach { products -> callback(products, it.count) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
db.use { closeTransaction() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.index
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
|
import com.fasterxml.jackson.core.JsonToken
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Product
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Release
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.json.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
object IndexV1Parser {
|
||||||
|
interface Callback {
|
||||||
|
fun onRepository(mirrors: List<String>, name: String, description: String, version: Int, timestamp: Long)
|
||||||
|
fun onProduct(product: Product)
|
||||||
|
fun onReleases(packageName: String, releases: List<Release>)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Screenshots(val phone: List<String>, val smallTablet: List<String>, val largeTablet: List<String>)
|
||||||
|
private class Localized(val name: String, val summary: String, val description: String,
|
||||||
|
val whatsNew: String, val screenshots: Screenshots?)
|
||||||
|
|
||||||
|
private fun <T> Map<String, Localized>.getAndCall(key: String, callback: (String, Localized) -> T?): T? {
|
||||||
|
return this[key]?.let { callback(key, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
|
||||||
|
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall("en", callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<String, Localized>.findString(fallback: String, callback: (Localized) -> String): String {
|
||||||
|
return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
|
||||||
|
val jsonParser = Json.factory.createParser(inputStream)
|
||||||
|
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
|
||||||
|
jsonParser.illegal()
|
||||||
|
} else {
|
||||||
|
jsonParser.forEachKey {
|
||||||
|
when {
|
||||||
|
it.dictionary("repo") -> {
|
||||||
|
var address = ""
|
||||||
|
var mirrors = emptyList<String>()
|
||||||
|
var name = ""
|
||||||
|
var description = ""
|
||||||
|
var version = 0
|
||||||
|
var timestamp = 0L
|
||||||
|
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.number("timestamp") -> timestamp = valueAsLong
|
||||||
|
else -> skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val realMirrors = ((if (address.isNotEmpty()) listOf(address) else emptyList()) + mirrors).distinct()
|
||||||
|
callback.onRepository(realMirrors, name, description, version, timestamp)
|
||||||
|
}
|
||||||
|
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
|
||||||
|
val product = parseProduct(repositoryId)
|
||||||
|
callback.onProduct(product)
|
||||||
|
}
|
||||||
|
it.dictionary("packages") -> forEachKey {
|
||||||
|
if (it.token == JsonToken.START_ARRAY) {
|
||||||
|
val packageName = it.key
|
||||||
|
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
|
||||||
|
callback.onReleases(packageName, releases)
|
||||||
|
} else {
|
||||||
|
skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonParser.parseProduct(repositoryId: Long): Product {
|
||||||
|
var packageName = ""
|
||||||
|
var nameFallback = ""
|
||||||
|
var summaryFallback = ""
|
||||||
|
var descriptionFallback = ""
|
||||||
|
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<String>()
|
||||||
|
var antiFeatures = emptyList<String>()
|
||||||
|
val licenses = mutableListOf<String>()
|
||||||
|
val donates = mutableListOf<Product.Donate>()
|
||||||
|
val localizedMap = mutableMapOf<String, Localized>()
|
||||||
|
forEachKey {
|
||||||
|
when {
|
||||||
|
it.string("packageName") -> packageName = valueAsString
|
||||||
|
it.string("name") -> nameFallback = valueAsString
|
||||||
|
it.string("summary") -> summaryFallback = valueAsString
|
||||||
|
it.string("description") -> descriptionFallback = valueAsString
|
||||||
|
it.string("icon") -> icon = valueAsString
|
||||||
|
it.string("authorName") -> authorName = valueAsString
|
||||||
|
it.string("authorEmail") -> authorEmail = valueAsString
|
||||||
|
it.string("authorWebSite") -> authorWeb = valueAsString
|
||||||
|
it.string("sourceCode") -> source = valueAsString
|
||||||
|
it.string("changelog") -> changelog = valueAsString
|
||||||
|
it.string("webSite") -> web = valueAsString
|
||||||
|
it.string("issueTracker") -> tracker = valueAsString
|
||||||
|
it.number("added") -> added = valueAsLong
|
||||||
|
it.number("lastUpdated") -> updated = valueAsLong
|
||||||
|
it.string("suggestedVersionCode") -> suggestedVersionCode = valueAsString.toLongOrNull() ?: 0L
|
||||||
|
it.array("categories") -> categories = collectNotNullStrings()
|
||||||
|
it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
|
||||||
|
it.string("license") -> licenses += valueAsString.split(',')
|
||||||
|
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
|
||||||
|
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
|
||||||
|
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
|
||||||
|
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
|
||||||
|
it.dictionary("localized") -> forEachKey {
|
||||||
|
if (it.token == JsonToken.START_OBJECT) {
|
||||||
|
val locale = it.key
|
||||||
|
var name = ""
|
||||||
|
var summary = ""
|
||||||
|
var description = ""
|
||||||
|
var whatsNew = ""
|
||||||
|
var phone = emptyList<String>()
|
||||||
|
var smallTablet = emptyList<String>()
|
||||||
|
var largeTablet = emptyList<String>()
|
||||||
|
forEachKey {
|
||||||
|
when {
|
||||||
|
it.string("name") -> name = valueAsString
|
||||||
|
it.string("summary") -> summary = valueAsString
|
||||||
|
it.string("description") -> description = valueAsString
|
||||||
|
it.string("whatsNew") -> whatsNew = valueAsString
|
||||||
|
it.array("phoneScreenshots") -> phone = collectNotNullStrings()
|
||||||
|
it.array("sevenInchScreenshots") -> smallTablet = collectNotNullStrings()
|
||||||
|
it.array("tenInchScreenshots") -> largeTablet = collectNotNullStrings()
|
||||||
|
else -> skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val screenshots = if (sequenceOf(phone, smallTablet, largeTablet).any { it.isNotEmpty() })
|
||||||
|
Screenshots(phone, smallTablet, largeTablet) else null
|
||||||
|
localizedMap[locale] = Localized(name, summary, description, whatsNew, screenshots)
|
||||||
|
} else {
|
||||||
|
skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val name = localizedMap.findString(nameFallback) { it.name }
|
||||||
|
val summary = localizedMap.findString(summaryFallback) { it.summary }
|
||||||
|
val description = localizedMap.findString(descriptionFallback) { it.description }.replace("\n", "<br/>")
|
||||||
|
val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "<br/>")
|
||||||
|
val screenshotPairs = localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
|
||||||
|
val screenshots = screenshotPairs
|
||||||
|
?.let { (key, screenshots) -> screenshots.phone.asSequence()
|
||||||
|
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
|
||||||
|
screenshots.smallTablet.asSequence()
|
||||||
|
.map { Product.Screenshot(key, Product.Screenshot.Type.SMALL_TABLET, it) } +
|
||||||
|
screenshots.largeTablet.asSequence()
|
||||||
|
.map { Product.Screenshot(key, Product.Screenshot.Type.LARGE_TABLET, it) } }
|
||||||
|
.orEmpty().toList()
|
||||||
|
return Product(repositoryId, packageName, name, summary, description, whatsNew, icon,
|
||||||
|
Product.Author(authorName, authorEmail, authorWeb), source, changelog, web, tracker, added, updated,
|
||||||
|
suggestedVersionCode, categories, antiFeatures, licenses,
|
||||||
|
donates.sortedWith(IndexHandler.DonateComparator), screenshots, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonParser.parseRelease(): Release {
|
||||||
|
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 hashTypeCandidate = ""
|
||||||
|
var signature = ""
|
||||||
|
var obbMain = ""
|
||||||
|
var obbMainHash = ""
|
||||||
|
var obbPatch = ""
|
||||||
|
var obbPatchHash = ""
|
||||||
|
val permissions = linkedSetOf<String>()
|
||||||
|
var features = emptyList<String>()
|
||||||
|
var platforms = emptyList<String>()
|
||||||
|
forEachKey {
|
||||||
|
when {
|
||||||
|
it.string("versionName") -> 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("srcname") -> source = valueAsString
|
||||||
|
it.string("apkName") -> release = valueAsString
|
||||||
|
it.string("hash") -> hash = valueAsString
|
||||||
|
it.string("hashType") -> hashTypeCandidate = valueAsString
|
||||||
|
it.string("sig") -> signature = valueAsString
|
||||||
|
it.string("obbMainFile") -> obbMain = valueAsString
|
||||||
|
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
|
||||||
|
it.string("obbPatchFile") -> obbPatch = valueAsString
|
||||||
|
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
|
||||||
|
it.array("uses-permission") -> collectPermissions(permissions, 0)
|
||||||
|
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
|
||||||
|
it.array("features") -> features = collectNotNullStrings()
|
||||||
|
it.array("nativecode") -> platforms = collectNotNullStrings()
|
||||||
|
else -> skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hashType = if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate
|
||||||
|
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, platforms, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonParser.collectPermissions(permissions: LinkedHashSet<String>, minSdk: Int) {
|
||||||
|
forEach(JsonToken.START_ARRAY) {
|
||||||
|
val firstToken = nextToken()
|
||||||
|
val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else ""
|
||||||
|
if (firstToken != JsonToken.END_ARRAY) {
|
||||||
|
val secondToken = nextToken()
|
||||||
|
val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0
|
||||||
|
if (permission.isNotEmpty() && Android.sdk >= minSdk && (maxSdk <= 0 || Android.sdk <= maxSdk)) {
|
||||||
|
permissions.add(permission)
|
||||||
|
}
|
||||||
|
if (secondToken != JsonToken.END_ARRAY) {
|
||||||
|
while (true) {
|
||||||
|
val token = nextToken()
|
||||||
|
if (token == JsonToken.END_ARRAY) {
|
||||||
|
break
|
||||||
|
} else if (token.isStructStart) {
|
||||||
|
skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.index
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.content.Cache
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Product
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Release
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.network.Downloader
|
||||||
|
import nya.kitsunyan.foxydroid.utility.ProgressInputStream
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import org.xml.sax.InputSource
|
||||||
|
import java.io.File
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.jar.JarEntry
|
||||||
|
import java.util.jar.JarFile
|
||||||
|
import javax.xml.parsers.SAXParserFactory
|
||||||
|
|
||||||
|
object RepositoryUpdater {
|
||||||
|
enum class Stage {
|
||||||
|
DOWNLOAD, PROCESS, MERGE, COMMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class IndexType(val jarName: String, val contentName: String, val certificateFromIndex: Boolean) {
|
||||||
|
INDEX("index.jar", "index.xml", true),
|
||||||
|
INDEX_V1("index-v1.jar", "index-v1.json", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ErrorType {
|
||||||
|
NETWORK, HTTP, VALIDATION, PARSING
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateException: Exception {
|
||||||
|
val errorType: ErrorType
|
||||||
|
|
||||||
|
constructor(errorType: ErrorType, message: String): super(message) {
|
||||||
|
this.errorType = errorType
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(errorType: ErrorType, message: String, cause: Exception): super(message, cause) {
|
||||||
|
this.errorType = errorType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private val updaterLock = Any()
|
||||||
|
private val cleanupLock = Any()
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
this.context = context
|
||||||
|
|
||||||
|
var lastDisabled = setOf<Long>()
|
||||||
|
Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Repositories))
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAllDisabledDeleted(it) } }
|
||||||
|
.forEach {
|
||||||
|
val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet()
|
||||||
|
val disabled = newDisabled - lastDisabled
|
||||||
|
lastDisabled = newDisabled
|
||||||
|
val deleted = it.asSequence().filter { it.second }.map { it.first }.toSet()
|
||||||
|
if (disabled.isNotEmpty() || deleted.isNotEmpty()) {
|
||||||
|
val pairs = (disabled.asSequence().map { Pair(it, false) } +
|
||||||
|
deleted.asSequence().map { Pair(it, true) }).toSet()
|
||||||
|
synchronized(cleanupLock) { Database.RepositoryAdapter.cleanup(pairs) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun await() {
|
||||||
|
synchronized(updaterLock) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(repository: Repository, unstable: Boolean,
|
||||||
|
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> {
|
||||||
|
return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(repository: Repository, indexTypes: List<IndexType>, unstable: Boolean,
|
||||||
|
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> {
|
||||||
|
val indexType = indexTypes[0]
|
||||||
|
return downloadIndex(repository, indexType, callback)
|
||||||
|
.flatMap { (result, file) ->
|
||||||
|
when {
|
||||||
|
result.isNotChanged -> {
|
||||||
|
file.delete()
|
||||||
|
Single.just(false)
|
||||||
|
}
|
||||||
|
!result.success -> {
|
||||||
|
file.delete()
|
||||||
|
if (result.code == 404 && indexTypes.isNotEmpty()) {
|
||||||
|
update(repository, indexTypes.subList(1, indexTypes.size), unstable, callback)
|
||||||
|
} else {
|
||||||
|
Single.error(UpdateException(ErrorType.HTTP, "Invalid response: HTTP ${result.code}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
RxUtils.managedSingle { processFile(repository, indexType, unstable,
|
||||||
|
file, result.lastModified, result.entityTag, callback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadIndex(repository: Repository, indexType: IndexType,
|
||||||
|
callback: (Stage, Long, Long?) -> Unit): Single<Pair<Downloader.Result, File>> {
|
||||||
|
return Single.just(Unit)
|
||||||
|
.map { Cache.getTemporaryFile(context) }
|
||||||
|
.flatMap { file -> Downloader
|
||||||
|
.download(Uri.parse(repository.address).buildUpon()
|
||||||
|
.appendPath(indexType.jarName).build().toString(), file, repository.lastModified, repository.entityTag,
|
||||||
|
repository.authentication) { read, total -> callback(Stage.DOWNLOAD, read, total) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.map { Pair(it, file) }
|
||||||
|
.onErrorResumeNext {
|
||||||
|
file.delete()
|
||||||
|
when (it) {
|
||||||
|
is InterruptedException, is RuntimeException, is Error -> Single.error(it)
|
||||||
|
is Exception -> Single.error(UpdateException(ErrorType.NETWORK, "Network error", it))
|
||||||
|
else -> Single.error(it)
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processFile(repository: Repository, indexType: IndexType, unstable: Boolean,
|
||||||
|
file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit): Boolean {
|
||||||
|
var rollback = true
|
||||||
|
return synchronized(updaterLock) {
|
||||||
|
try {
|
||||||
|
val jarFile = JarFile(file, true)
|
||||||
|
val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry
|
||||||
|
val total = indexEntry.size
|
||||||
|
Database.UpdaterAdapter.createTemporaryTable()
|
||||||
|
val features = context.packageManager.systemAvailableFeatures
|
||||||
|
.asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen")
|
||||||
|
|
||||||
|
val (changedRepository, certificateFromIndex) = when (indexType) {
|
||||||
|
IndexType.INDEX -> {
|
||||||
|
val factory = SAXParserFactory.newInstance()
|
||||||
|
factory.isNamespaceAware = true
|
||||||
|
val parser = factory.newSAXParser()
|
||||||
|
val reader = parser.xmlReader
|
||||||
|
var changedRepository: Repository? = null
|
||||||
|
var certificateFromIndex: String? = null
|
||||||
|
val products = mutableListOf<Product>()
|
||||||
|
|
||||||
|
reader.contentHandler = IndexHandler(repository.id, object: IndexHandler.Callback {
|
||||||
|
override fun onRepository(mirrors: List<String>, name: String, description: String,
|
||||||
|
certificate: String, version: Int, timestamp: Long) {
|
||||||
|
changedRepository = repository.update(mirrors, name, description, version,
|
||||||
|
lastModified, entityTag, timestamp)
|
||||||
|
certificateFromIndex = certificate.toLowerCase(Locale.US)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProduct(product: Product) {
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
products += transformProduct(product, features, unstable)
|
||||||
|
if (products.size >= 50) {
|
||||||
|
Database.UpdaterAdapter.putTemporary(products)
|
||||||
|
products.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }
|
||||||
|
.use { reader.parse(InputSource(it)) }
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
if (products.isNotEmpty()) {
|
||||||
|
Database.UpdaterAdapter.putTemporary(products)
|
||||||
|
products.clear()
|
||||||
|
}
|
||||||
|
Pair(changedRepository, certificateFromIndex)
|
||||||
|
}
|
||||||
|
IndexType.INDEX_V1 -> {
|
||||||
|
var changedRepository: Repository? = null
|
||||||
|
|
||||||
|
val mergerFile = Cache.getTemporaryFile(context)
|
||||||
|
try {
|
||||||
|
val unmergedProducts = mutableListOf<Product>()
|
||||||
|
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
|
||||||
|
IndexMerger(mergerFile).use { indexMerger ->
|
||||||
|
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }.use {
|
||||||
|
IndexV1Parser.parse(repository.id, it, object: IndexV1Parser.Callback {
|
||||||
|
override fun onRepository(mirrors: List<String>, name: String, description: String,
|
||||||
|
version: Int, timestamp: Long) {
|
||||||
|
changedRepository = repository.update(mirrors, name, description, version,
|
||||||
|
lastModified, entityTag, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProduct(product: Product) {
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
unmergedProducts += product
|
||||||
|
if (unmergedProducts.size >= 50) {
|
||||||
|
indexMerger.addProducts(unmergedProducts)
|
||||||
|
unmergedProducts.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReleases(packageName: String, releases: List<Release>) {
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
unmergedReleases += Pair(packageName, releases)
|
||||||
|
if (unmergedReleases.size >= 50) {
|
||||||
|
indexMerger.addReleases(unmergedReleases)
|
||||||
|
unmergedReleases.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
if (unmergedProducts.isNotEmpty()) {
|
||||||
|
indexMerger.addProducts(unmergedProducts)
|
||||||
|
unmergedProducts.clear()
|
||||||
|
}
|
||||||
|
if (unmergedReleases.isNotEmpty()) {
|
||||||
|
indexMerger.addReleases(unmergedReleases)
|
||||||
|
unmergedReleases.clear()
|
||||||
|
}
|
||||||
|
var progress = 0
|
||||||
|
indexMerger.forEach(repository.id, 50) { products, totalCount ->
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
progress += products.size
|
||||||
|
callback(Stage.MERGE, progress.toLong(), totalCount.toLong())
|
||||||
|
Database.UpdaterAdapter.putTemporary(products
|
||||||
|
.map { transformProduct(it, features, unstable) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
mergerFile.delete()
|
||||||
|
}
|
||||||
|
Pair(changedRepository, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val workRepository = changedRepository ?: repository
|
||||||
|
if (workRepository.timestamp < repository.timestamp) {
|
||||||
|
throw UpdateException(ErrorType.VALIDATION, "New index is older than current index: " +
|
||||||
|
"${workRepository.timestamp} < ${repository.timestamp}")
|
||||||
|
} else {
|
||||||
|
val fingerprint = run {
|
||||||
|
val certificateFromJar = run {
|
||||||
|
val codeSigners = indexEntry.codeSigners
|
||||||
|
if (codeSigners == null || codeSigners.size != 1) {
|
||||||
|
throw UpdateException(ErrorType.VALIDATION, "index.jar must be signed by a single code signer")
|
||||||
|
} else {
|
||||||
|
val certificates = codeSigners[0].signerCertPath?.certificates.orEmpty()
|
||||||
|
if (certificates.size != 1) {
|
||||||
|
throw UpdateException(ErrorType.VALIDATION, "index.jar code signer should have only one certificate")
|
||||||
|
} else {
|
||||||
|
certificates[0] as X509Certificate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fingerprintFromJar = Utils.calculateFingerprint(certificateFromJar)
|
||||||
|
if (indexType.certificateFromIndex) {
|
||||||
|
val fingerprintFromIndex = certificateFromIndex?.unhex()?.let(Utils::calculateFingerprint)
|
||||||
|
if (fingerprintFromIndex == null || fingerprintFromJar != fingerprintFromIndex) {
|
||||||
|
throw UpdateException(ErrorType.VALIDATION, "index.xml contains invalid public key")
|
||||||
|
}
|
||||||
|
fingerprintFromIndex
|
||||||
|
} else {
|
||||||
|
fingerprintFromJar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val commitRepository = if (workRepository.fingerprint != fingerprint) {
|
||||||
|
if (workRepository.fingerprint.isEmpty()) {
|
||||||
|
workRepository.copy(fingerprint = fingerprint)
|
||||||
|
} else {
|
||||||
|
throw UpdateException(ErrorType.VALIDATION, "Certificate fingerprints do not match")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workRepository
|
||||||
|
}
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
callback(Stage.COMMIT, 0, null)
|
||||||
|
synchronized(cleanupLock) { Database.UpdaterAdapter.finishTemporary(commitRepository, true) }
|
||||||
|
rollback = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw when (e) {
|
||||||
|
is UpdateException, is InterruptedException -> e
|
||||||
|
else -> UpdateException(ErrorType.PARSING, "Error parsing index", e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
if (rollback) {
|
||||||
|
Database.UpdaterAdapter.finishTemporary(repository, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transformProduct(product: Product, features: Set<String>, unstable: Boolean): Product {
|
||||||
|
val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map {
|
||||||
|
val incompatibilities = mutableListOf<Release.Incompatibility>()
|
||||||
|
if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) {
|
||||||
|
incompatibilities += Release.Incompatibility.MinSdk
|
||||||
|
}
|
||||||
|
if (it.maxSdkVersion > 0 && Android.sdk > it.maxSdkVersion) {
|
||||||
|
incompatibilities += Release.Incompatibility.MaxSdk
|
||||||
|
}
|
||||||
|
if (it.platforms.isNotEmpty() && it.platforms.intersect(Android.platforms).isEmpty()) {
|
||||||
|
incompatibilities += Release.Incompatibility.Platform
|
||||||
|
}
|
||||||
|
incompatibilities += (it.features - features).map { Release.Incompatibility.Feature(it) }
|
||||||
|
Pair(it, incompatibilities as List<Release.Incompatibility>)
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
|
val predicate: (Release) -> Boolean = { unstable || product.suggestedVersionCode <= 0 ||
|
||||||
|
it.versionCode <= product.suggestedVersionCode }
|
||||||
|
val compatibleReleaseIndex = releasePairs.indexOfFirst { it.second.isEmpty() && predicate(it.first) }
|
||||||
|
val releaseIndex = if (compatibleReleaseIndex >= 0) compatibleReleaseIndex else
|
||||||
|
releasePairs.indexOfFirst { predicate(it.first) }
|
||||||
|
|
||||||
|
val releases = releasePairs.mapIndexed { index, (release, incompatibilities) -> release
|
||||||
|
.copy(incompatibilities = incompatibilities, selected = index == releaseIndex) }
|
||||||
|
return product.copy(releases = releases)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.View
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Product
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import okhttp3.Cache
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
object CoilDownloader {
|
||||||
|
private const val HOST_ICON = "icon"
|
||||||
|
private const val HOST_SCREENSHOT = "screenshot"
|
||||||
|
private const val QUERY_ADDRESS = "address"
|
||||||
|
private const val QUERY_AUTHENTICATION = "authentication"
|
||||||
|
private const val QUERY_ICON = "icon"
|
||||||
|
private const val QUERY_PACKAGE_NAME = "packageName"
|
||||||
|
private const val QUERY_LOCALE = "locale"
|
||||||
|
private const val QUERY_DEVICE = "device"
|
||||||
|
private const val QUERY_SCREENSHOT = "screenshot"
|
||||||
|
private const val QUERY_DPI = "dpi"
|
||||||
|
|
||||||
|
private val supportedDpis = listOf(120, 160, 240, 320, 480, 640)
|
||||||
|
|
||||||
|
class Factory(cacheDir: File): Call.Factory {
|
||||||
|
private val cache = Cache(cacheDir, 50_000_000L)
|
||||||
|
|
||||||
|
override fun newCall(request: okhttp3.Request): Call {
|
||||||
|
return when (request.url.host) {
|
||||||
|
HOST_ICON -> {
|
||||||
|
val address = request.url.queryParameter(QUERY_ADDRESS)
|
||||||
|
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
|
||||||
|
val icon = request.url.queryParameter(QUERY_ICON)
|
||||||
|
val dpi = request.url.queryParameter(QUERY_DPI)?.nullIfEmpty()
|
||||||
|
if (address.isNullOrEmpty() || icon.isNullOrEmpty()) {
|
||||||
|
Downloader.createCall(request.newBuilder(), "", null)
|
||||||
|
} else {
|
||||||
|
Downloader.createCall(request.newBuilder().url(address.toHttpUrl()
|
||||||
|
.newBuilder().addPathSegment(if (dpi != null) "icons-$dpi" else "icons")
|
||||||
|
.addPathSegment(icon).build()), authentication.orEmpty(), cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HOST_SCREENSHOT -> {
|
||||||
|
val address = request.url.queryParameter(QUERY_ADDRESS)
|
||||||
|
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
|
||||||
|
val packageName = request.url.queryParameter(QUERY_PACKAGE_NAME)
|
||||||
|
val locale = request.url.queryParameter(QUERY_LOCALE)
|
||||||
|
val device = request.url.queryParameter(QUERY_DEVICE)
|
||||||
|
val screenshot = request.url.queryParameter(QUERY_SCREENSHOT)
|
||||||
|
if (screenshot.isNullOrEmpty() || address.isNullOrEmpty()) {
|
||||||
|
Downloader.createCall(request.newBuilder(), "", null)
|
||||||
|
} else {
|
||||||
|
Downloader.createCall(request.newBuilder().url(address.toHttpUrl()
|
||||||
|
.newBuilder().addPathSegment(packageName.orEmpty()).addPathSegment(locale.orEmpty())
|
||||||
|
.addPathSegment(device.orEmpty()).addPathSegment(screenshot.orEmpty()).build()),
|
||||||
|
authentication.orEmpty(), cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Downloader.createCall(request.newBuilder(), "", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createScreenshotUri(repository: Repository, packageName: String, screenshot: Product.Screenshot): Uri {
|
||||||
|
return Uri.Builder().scheme("https").authority(HOST_SCREENSHOT)
|
||||||
|
.appendQueryParameter(QUERY_ADDRESS, repository.address)
|
||||||
|
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
|
||||||
|
.appendQueryParameter(QUERY_PACKAGE_NAME, packageName)
|
||||||
|
.appendQueryParameter(QUERY_LOCALE, screenshot.locale)
|
||||||
|
.appendQueryParameter(QUERY_DEVICE, when (screenshot.type) {
|
||||||
|
Product.Screenshot.Type.PHONE -> "phoneScreenshots"
|
||||||
|
Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots"
|
||||||
|
Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots"
|
||||||
|
})
|
||||||
|
.appendQueryParameter(QUERY_SCREENSHOT, screenshot.path)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIconUri(view: View, icon: String, repository: Repository): Uri {
|
||||||
|
val size = (view.layoutParams.let { min(it.width, it.height) } /
|
||||||
|
view.resources.displayMetrics.density).roundToInt()
|
||||||
|
return createIconUri(view.context, icon, size, repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createIconUri(context: Context, icon: String, targetSizeDp: Int, repository: Repository): Uri {
|
||||||
|
return Uri.Builder().scheme("https").authority(HOST_ICON)
|
||||||
|
.appendQueryParameter(QUERY_ADDRESS, repository.address)
|
||||||
|
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
|
||||||
|
.appendQueryParameter(QUERY_ICON, icon)
|
||||||
|
.apply {
|
||||||
|
if (repository.version >= 11) {
|
||||||
|
val displayDpi = context.resources.displayMetrics.densityDpi
|
||||||
|
val requiredDpi = displayDpi * targetSizeDp / 48
|
||||||
|
val iconDpi = supportedDpis.find { it >= requiredDpi } ?: supportedDpis.last()
|
||||||
|
appendQueryParameter(QUERY_DPI, iconDpi.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.network
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.utility.ProgressInputStream
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import okhttp3.Cache
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Proxy
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object Downloader {
|
||||||
|
private data class ClientConfiguration(val cache: Cache?, val onion: Boolean)
|
||||||
|
|
||||||
|
private val clients = mutableMapOf<ClientConfiguration, OkHttpClient>()
|
||||||
|
private val onionProxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050))
|
||||||
|
|
||||||
|
var proxy: Proxy? = null
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
synchronized(clients) {
|
||||||
|
field = value
|
||||||
|
clients.keys.removeAll { !it.onion }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createClient(proxy: Proxy?, cache: Cache?): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30L, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15L, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15L, TimeUnit.SECONDS)
|
||||||
|
.proxy(proxy).cache(cache).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Result(val code: Int, val lastModified: String, val entityTag: String) {
|
||||||
|
val success: Boolean
|
||||||
|
get() = code == 200 || code == 206
|
||||||
|
|
||||||
|
val isNotChanged: Boolean
|
||||||
|
get() = code == 304
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCall(request: Request.Builder, authentication: String, cache: Cache?): Call {
|
||||||
|
val oldRequest = request.build()
|
||||||
|
val newRequest = if (authentication.isNotEmpty()) {
|
||||||
|
request.addHeader("Authorization", authentication).build()
|
||||||
|
} else {
|
||||||
|
request.build()
|
||||||
|
}
|
||||||
|
val onion = oldRequest.url.host.endsWith(".onion")
|
||||||
|
val client = synchronized(clients) {
|
||||||
|
val proxy = if (onion) onionProxy else proxy
|
||||||
|
val clientConfiguration = ClientConfiguration(cache, onion)
|
||||||
|
clients[clientConfiguration] ?: run {
|
||||||
|
val client = createClient(proxy, cache)
|
||||||
|
clients[clientConfiguration] = client
|
||||||
|
client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return client.newCall(newRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(url: String, target: File, lastModified: String, entityTag: String, authentication: String,
|
||||||
|
callback: ((read: Long, total: Long?) -> Unit)?): Single<Result> {
|
||||||
|
val start = if (target.exists()) target.length().let { if (it > 0L) it else null } else null
|
||||||
|
val request = Request.Builder().url(url)
|
||||||
|
.apply {
|
||||||
|
if (entityTag.isNotEmpty()) {
|
||||||
|
addHeader("If-None-Match", entityTag)
|
||||||
|
} else if (lastModified.isNotEmpty()) {
|
||||||
|
addHeader("If-Modified-Since", lastModified)
|
||||||
|
}
|
||||||
|
if (start != null) {
|
||||||
|
addHeader("Range", "bytes=$start-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RxUtils
|
||||||
|
.callSingle { createCall(request, authentication, null) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.flatMap { result -> RxUtils
|
||||||
|
.managedSingle { result.use {
|
||||||
|
if (result.code == 304) {
|
||||||
|
Result(it.code, lastModified, entityTag)
|
||||||
|
} else {
|
||||||
|
val body = it.body!!
|
||||||
|
val append = start != null && it.header("Content-Range") != null
|
||||||
|
val progressStart = if (append && start != null) start else 0L
|
||||||
|
val progressTotal = body.contentLength().let { if (it >= 0L) it else null }
|
||||||
|
?.let { progressStart + it }
|
||||||
|
val inputStream = ProgressInputStream(body.byteStream()) {
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw InterruptedException()
|
||||||
|
}
|
||||||
|
callback?.invoke(progressStart + it, progressTotal)
|
||||||
|
}
|
||||||
|
inputStream.use { input ->
|
||||||
|
val outputStream = if (append) FileOutputStream(target, true) else FileOutputStream(target)
|
||||||
|
outputStream.use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
output.fd.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Result(it.code, it.header("Last-Modified").orEmpty(), it.header("ETag").orEmpty())
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
|
||||||
|
val Fragment.screenActivity: ScreenActivity
|
||||||
|
get() = requireActivity() as ScreenActivity
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.PorterDuffColorFilter
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.Selection
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.util.Base64
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.network.Downloader
|
||||||
|
import nya.kitsunyan.foxydroid.service.Connection
|
||||||
|
import nya.kitsunyan.foxydroid.service.SyncService
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class EditRepositoryFragment(): Fragment() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||||
|
|
||||||
|
private val checkPaths = listOf("", "fdroid/repo", "repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(repositoryId: Long?): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
repositoryId?.let { putLong(EXTRA_REPOSITORY_ID, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Layout(view: View) {
|
||||||
|
val address = view.findViewById<EditText>(R.id.address)!!
|
||||||
|
val addressMirror = view.findViewById<View>(R.id.address_mirror)!!
|
||||||
|
val addressError = view.findViewById<TextView>(R.id.address_error)!!
|
||||||
|
val fingerprint = view.findViewById<EditText>(R.id.fingerprint)!!
|
||||||
|
val fingerprintError = view.findViewById<TextView>(R.id.fingerprint_error)!!
|
||||||
|
val username = view.findViewById<EditText>(R.id.username)!!
|
||||||
|
val usernameError = view.findViewById<TextView>(R.id.username_error)!!
|
||||||
|
val password = view.findViewById<EditText>(R.id.password)!!
|
||||||
|
val passwordError = view.findViewById<TextView>(R.id.password_error)!!
|
||||||
|
val overlay = view.findViewById<View>(R.id.overlay)!!
|
||||||
|
val skip = view.findViewById<View>(R.id.skip)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private val repositoryId: Long?
|
||||||
|
get() = requireArguments().let { if (it.containsKey(EXTRA_REPOSITORY_ID))
|
||||||
|
it.getLong(EXTRA_REPOSITORY_ID) else null }
|
||||||
|
|
||||||
|
private lateinit var errorColorFilter: PorterDuffColorFilter
|
||||||
|
|
||||||
|
private var saveMenuItem: MenuItem? = null
|
||||||
|
private var layout: Layout? = null
|
||||||
|
|
||||||
|
private val syncConnection = Connection<SyncService.Binder>(SyncService::class.java)
|
||||||
|
private var repositoriesDisposable: Disposable? = null
|
||||||
|
private var checkDisposable: Disposable? = null
|
||||||
|
|
||||||
|
private var takenAddresses = emptySet<String>()
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
return inflater.inflate(R.layout.fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
syncConnection.bind(requireContext())
|
||||||
|
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
screenActivity.onFragmentViewCreated(toolbar)
|
||||||
|
if (repositoryId != null) {
|
||||||
|
toolbar.setTitle(R.string.edit_repository)
|
||||||
|
} else {
|
||||||
|
toolbar.setTitle(R.string.add_repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.menu.apply {
|
||||||
|
saveMenuItem = add(R.string.save)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_save))
|
||||||
|
.setEnabled(false)
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
onSaveRepositoryClick(true)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = view.findViewById<FrameLayout>(R.id.fragment_content)
|
||||||
|
errorColorFilter = PorterDuffColorFilter(content.context
|
||||||
|
.getColorFromAttr(R.attr.colorError).defaultColor, PorterDuff.Mode.SRC_IN)
|
||||||
|
|
||||||
|
content.addView(content.inflate(R.layout.edit_repository))
|
||||||
|
val layout = Layout(content)
|
||||||
|
this.layout = layout
|
||||||
|
|
||||||
|
layout.fingerprint.hint = generateSequence { "FF" }.take(32).joinToString(separator = " ")
|
||||||
|
layout.fingerprint.addTextChangedListener(object: TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
private val validChar: (Char) -> Boolean = { it in '0' .. '9' || it in 'a' .. 'f' || it in 'A' .. 'F' }
|
||||||
|
|
||||||
|
private fun logicalPosition(s: String, position: Int): Int {
|
||||||
|
return if (position > 0) s.asSequence().take(position).count(validChar) else position
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun realPosition(s: String, position: Int): Int {
|
||||||
|
return if (position > 0) {
|
||||||
|
var left = position
|
||||||
|
val index = s.indexOfFirst {
|
||||||
|
validChar(it) && run {
|
||||||
|
left -= 1
|
||||||
|
left <= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index >= 0) min(index + 1, s.length) else s.length
|
||||||
|
} else {
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable) {
|
||||||
|
val inputString = s.toString()
|
||||||
|
val outputString = inputString.toUpperCase(Locale.US)
|
||||||
|
.filter(validChar).windowed(2, 2, true).take(32).joinToString(separator = " ")
|
||||||
|
if (inputString != outputString) {
|
||||||
|
val inputStart = logicalPosition(inputString, Selection.getSelectionStart(s))
|
||||||
|
val inputEnd = logicalPosition(inputString, Selection.getSelectionEnd(s))
|
||||||
|
s.replace(0, s.length, outputString)
|
||||||
|
Selection.setSelection(s, realPosition(outputString, inputStart), realPosition(outputString, inputEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
|
||||||
|
if (repository == null) {
|
||||||
|
val clipboardManager = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val text = clipboardManager.primaryClip
|
||||||
|
?.let { if (it.itemCount > 0) it else null }
|
||||||
|
?.getItemAt(0)?.text?.toString().orEmpty()
|
||||||
|
val (addressText, fingerprintText) = try {
|
||||||
|
val uri = Uri.parse(URL(text).toString())
|
||||||
|
val fingerprintText = uri.getQueryParameter("fingerprint")?.nullIfEmpty()
|
||||||
|
?: uri.getQueryParameter("FINGERPRINT")?.nullIfEmpty()
|
||||||
|
Pair(uri.buildUpon().path(uri.path?.pathCropped)
|
||||||
|
.query(null).fragment(null).build().toString(), fingerprintText)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair(null, null)
|
||||||
|
}
|
||||||
|
layout.address.setText(addressText?.nullIfEmpty() ?: layout.address.hint)
|
||||||
|
layout.fingerprint.setText(fingerprintText)
|
||||||
|
} else {
|
||||||
|
layout.address.setText(repository.address)
|
||||||
|
val mirrors = repository.mirrors.map { it.withoutKnownPath }
|
||||||
|
if (mirrors.isNotEmpty()) {
|
||||||
|
layout.addressMirror.visibility = View.VISIBLE
|
||||||
|
layout.address.apply { setPaddingRelative(paddingStart, paddingTop,
|
||||||
|
paddingEnd + layout.addressMirror.layoutParams.width, paddingBottom) }
|
||||||
|
layout.addressMirror.setOnClickListener { SelectMirrorDialog(mirrors)
|
||||||
|
.show(childFragmentManager, SelectMirrorDialog::class.java.name) }
|
||||||
|
}
|
||||||
|
layout.fingerprint.setText(repository.fingerprint)
|
||||||
|
val (usernameText, passwordText) = repository.authentication.nullIfEmpty()
|
||||||
|
?.let { if (it.startsWith("Basic ")) it.substring(6) else null }
|
||||||
|
?.let {
|
||||||
|
try {
|
||||||
|
Base64.decode(it, Base64.NO_WRAP).toString(Charset.defaultCharset())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?.let {
|
||||||
|
val index = it.indexOf(':')
|
||||||
|
if (index >= 0) Pair(it.substring(0, index), it.substring(index + 1)) else null
|
||||||
|
}
|
||||||
|
?: Pair(null, null)
|
||||||
|
layout.username.setText(usernameText)
|
||||||
|
layout.password.setText(passwordText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.address.addTextChangedListener(SimpleTextWatcher { invalidateAddress() })
|
||||||
|
layout.fingerprint.addTextChangedListener(SimpleTextWatcher { invalidateFingerprint() })
|
||||||
|
layout.username.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() })
|
||||||
|
layout.password.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() })
|
||||||
|
|
||||||
|
(layout.overlay.parent as ViewGroup).layoutTransition?.setDuration(200L)
|
||||||
|
layout.overlay.background!!.apply {
|
||||||
|
mutate()
|
||||||
|
alpha = 0xcc
|
||||||
|
}
|
||||||
|
layout.skip.setOnClickListener {
|
||||||
|
if (checkDisposable != null) {
|
||||||
|
checkDisposable?.dispose()
|
||||||
|
checkDisposable = null
|
||||||
|
onSaveRepositoryClick(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoriesDisposable = Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Repositories))
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe {
|
||||||
|
takenAddresses = it.asSequence().filter { it.id != repositoryId }
|
||||||
|
.flatMap { (it.mirrors + it.address).asSequence() }
|
||||||
|
.map { it.withoutKnownPath }.toSet()
|
||||||
|
invalidateAddress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
saveMenuItem = null
|
||||||
|
layout = null
|
||||||
|
|
||||||
|
syncConnection.unbind(requireContext())
|
||||||
|
repositoriesDisposable?.dispose()
|
||||||
|
repositoriesDisposable = null
|
||||||
|
checkDisposable?.dispose()
|
||||||
|
checkDisposable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
super.onActivityCreated(savedInstanceState)
|
||||||
|
|
||||||
|
invalidateAddress()
|
||||||
|
invalidateFingerprint()
|
||||||
|
invalidateUsernamePassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addressError = false
|
||||||
|
private var fingerprintError = false
|
||||||
|
private var usernamePasswordError = false
|
||||||
|
|
||||||
|
private fun invalidateAddress() {
|
||||||
|
invalidateAddress(layout!!.address.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateAddress(addressText: String) {
|
||||||
|
val layout = layout!!
|
||||||
|
val normalizedAddress = normalizeAddress(addressText)
|
||||||
|
val addressErrorResId = if (normalizedAddress != null) {
|
||||||
|
if (normalizedAddress.withoutKnownPath in takenAddresses) {
|
||||||
|
R.string.already_exists
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
R.string.invalid_address
|
||||||
|
}
|
||||||
|
layout.address.setError(addressErrorResId != null)
|
||||||
|
layout.addressError.visibility = if (addressErrorResId != null) View.VISIBLE else View.GONE
|
||||||
|
if (addressErrorResId != null) {
|
||||||
|
layout.addressError.setText(addressErrorResId)
|
||||||
|
}
|
||||||
|
addressError = addressErrorResId != null
|
||||||
|
invalidateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateFingerprint() {
|
||||||
|
val layout = layout!!
|
||||||
|
val fingerprint = layout.fingerprint.text.toString().replace(" ", "")
|
||||||
|
val fingerprintInvalid = fingerprint.isNotEmpty() && fingerprint.length != 64
|
||||||
|
layout.fingerprintError.visibility = if (fingerprintInvalid) View.VISIBLE else View.GONE
|
||||||
|
if (fingerprintInvalid) {
|
||||||
|
layout.fingerprintError.setText(R.string.invalid_fingerprint_format)
|
||||||
|
}
|
||||||
|
layout.fingerprint.setError(fingerprintInvalid)
|
||||||
|
fingerprintError = fingerprintInvalid
|
||||||
|
invalidateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateUsernamePassword() {
|
||||||
|
val layout = layout!!
|
||||||
|
val username = layout.username.text.toString()
|
||||||
|
val password = layout.password.text.toString()
|
||||||
|
val usernameInvalid = username.contains(':')
|
||||||
|
val usernameEmpty = username.isEmpty() && password.isNotEmpty()
|
||||||
|
val passwordEmpty = username.isNotEmpty() && password.isEmpty()
|
||||||
|
layout.usernameError.visibility = if (usernameInvalid || usernameEmpty) View.VISIBLE else View.GONE
|
||||||
|
layout.passwordError.visibility = if (passwordEmpty) View.VISIBLE else View.GONE
|
||||||
|
if (usernameInvalid) {
|
||||||
|
layout.usernameError.setText(R.string.invalid_username_format)
|
||||||
|
} else if (usernameEmpty) {
|
||||||
|
layout.usernameError.setText(R.string.username_is_not_specified)
|
||||||
|
}
|
||||||
|
layout.username.setError(usernameEmpty)
|
||||||
|
if (passwordEmpty) {
|
||||||
|
layout.passwordError.setText(R.string.password_is_not_specified)
|
||||||
|
}
|
||||||
|
layout.password.setError(passwordEmpty)
|
||||||
|
usernamePasswordError = usernameInvalid || usernameEmpty || passwordEmpty
|
||||||
|
invalidateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateState() {
|
||||||
|
val layout = layout!!
|
||||||
|
saveMenuItem!!.isEnabled = !addressError && !fingerprintError &&
|
||||||
|
!usernamePasswordError && checkDisposable == null
|
||||||
|
layout.apply { sequenceOf(address, addressMirror, fingerprint, username, password)
|
||||||
|
.forEach { it.isEnabled = checkDisposable == null } }
|
||||||
|
layout.overlay.visibility = if (checkDisposable != null) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private val String.pathCropped: String
|
||||||
|
get() {
|
||||||
|
val index = indexOfLast { it != '/' }
|
||||||
|
return if (index >= 0 && index < length - 1) substring(0, index + 1) else this
|
||||||
|
}
|
||||||
|
|
||||||
|
private val String.withoutKnownPath: String
|
||||||
|
get() {
|
||||||
|
val cropped = pathCropped
|
||||||
|
val endsWith = checkPaths.asSequence().filter { it.isNotEmpty() }
|
||||||
|
.sortedByDescending { it.length }.find { cropped.endsWith("/$it") }
|
||||||
|
return if (endsWith != null) cropped.substring(0, cropped.length - endsWith.length - 1) else cropped
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeAddress(address: String): String? {
|
||||||
|
val uri = try {
|
||||||
|
val uri = URI(address)
|
||||||
|
if (uri.isAbsolute) uri.normalize() else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val path = uri?.path?.pathCropped
|
||||||
|
return if (uri != null && path != null) {
|
||||||
|
try {
|
||||||
|
URI(uri.scheme, uri.userInfo, uri.host, uri.port, path, uri.query, uri.fragment).toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMirror(address: String) {
|
||||||
|
layout?.address?.setText(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EditText.setError(error: Boolean) {
|
||||||
|
val drawable = background.mutate()
|
||||||
|
drawable.colorFilter = if (error) errorColorFilter else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSaveRepositoryClick(check: Boolean) {
|
||||||
|
if (checkDisposable == null) {
|
||||||
|
val layout = layout!!
|
||||||
|
val address = normalizeAddress(layout.address.text.toString())!!
|
||||||
|
val fingerprint = layout.fingerprint.text.toString().replace(" ", "")
|
||||||
|
val username = layout.username.text.toString().nullIfEmpty()
|
||||||
|
val password = layout.password.text.toString().nullIfEmpty()
|
||||||
|
val paths = sequenceOf("", "fdroid/repo", "repo")
|
||||||
|
val authentication = username?.let { u -> password
|
||||||
|
?.let { p -> Base64.encodeToString("$u:$p".toByteArray(Charset.defaultCharset()), Base64.NO_WRAP) } }
|
||||||
|
?.let { "Basic $it" }.orEmpty()
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
checkDisposable = paths
|
||||||
|
.fold(Single.just("")) { oldAddressSingle, checkPath -> oldAddressSingle
|
||||||
|
.flatMap { oldAddress ->
|
||||||
|
if (oldAddress.isEmpty()) {
|
||||||
|
val builder = Uri.parse(address).buildUpon()
|
||||||
|
.let { if (checkPath.isEmpty()) it else it.appendEncodedPath(checkPath) }
|
||||||
|
val newAddress = builder.build()
|
||||||
|
val indexAddress = builder.appendPath("index.jar").build()
|
||||||
|
RxUtils
|
||||||
|
.callSingle { Downloader
|
||||||
|
.createCall(Request.Builder().method("HEAD", null)
|
||||||
|
.url(indexAddress.toString().toHttpUrl()), authentication, null) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.map { if (it.code == 200) newAddress.toString() else "" }
|
||||||
|
} else {
|
||||||
|
Single.just(oldAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { result, throwable ->
|
||||||
|
checkDisposable = null
|
||||||
|
throwable?.printStackTrace()
|
||||||
|
val resultAddress = result?.let { if (it.isEmpty()) null else it } ?: address
|
||||||
|
val allow = resultAddress == address || run {
|
||||||
|
layout.address.setText(resultAddress)
|
||||||
|
invalidateAddress(resultAddress)
|
||||||
|
!addressError
|
||||||
|
}
|
||||||
|
if (allow) {
|
||||||
|
onSaveRepositoryProceedInvalidate(resultAddress, fingerprint, authentication)
|
||||||
|
} else {
|
||||||
|
invalidateState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalidateState()
|
||||||
|
} else {
|
||||||
|
onSaveRepositoryProceedInvalidate(address, fingerprint, authentication)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSaveRepositoryProceedInvalidate(address: String, fingerprint: String, authentication: String) {
|
||||||
|
val binder = syncConnection.binder
|
||||||
|
if (binder != null) {
|
||||||
|
val repositoryId = repositoryId
|
||||||
|
if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) {
|
||||||
|
MessageDialog(MessageDialog.Message.CantEditSyncing).show(childFragmentManager)
|
||||||
|
invalidateState()
|
||||||
|
} else {
|
||||||
|
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
|
||||||
|
?.edit(address, fingerprint, authentication)
|
||||||
|
?: Repository.newRepository(address, fingerprint, authentication)
|
||||||
|
val changedRepository = Database.RepositoryAdapter.put(repository)
|
||||||
|
if (repositoryId == null && changedRepository.enabled) {
|
||||||
|
binder.sync(changedRepository)
|
||||||
|
}
|
||||||
|
requireActivity().onBackPressed()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
invalidateState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SimpleTextWatcher(private val callback: (Editable) -> Unit): TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun afterTextChanged(s: Editable) = callback(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectMirrorDialog(): DialogFragment() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_MIRRORS = "mirrors"
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(mirrors: List<String>): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putStringArrayList(EXTRA_MIRRORS, ArrayList(mirrors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||||
|
val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!!
|
||||||
|
return AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle(R.string.select_the_mirror)
|
||||||
|
.setItems(mirrors.toTypedArray()) { _, position -> (parentFragment as EditRepositoryFragment)
|
||||||
|
.setMirror(mirrors[position]) }
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcel
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Release
|
||||||
|
import nya.kitsunyan.foxydroid.utility.KParcelable
|
||||||
|
import nya.kitsunyan.foxydroid.utility.PackageItemResolver
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
|
||||||
|
class MessageDialog(): DialogFragment() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_MESSAGE = "message"
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Message: KParcelable {
|
||||||
|
object DeleteRepositoryConfirm: Message() {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { DeleteRepositoryConfirm }
|
||||||
|
}
|
||||||
|
|
||||||
|
object CantEditSyncing: Message() {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { CantEditSyncing }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Link(val uri: Uri): Message() {
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
|
dest.writeString(uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||||
|
val uri = Uri.parse(it.readString()!!)
|
||||||
|
Link(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Permissions(val group: String?, val permissions: List<String>): Message() {
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
|
dest.writeString(group)
|
||||||
|
dest.writeStringList(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||||
|
val group = it.readString()
|
||||||
|
val permissions = it.createStringArrayList()!!
|
||||||
|
Permissions(group, permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReleaseIncompatible(val incompatibilities: List<Release.Incompatibility>,
|
||||||
|
val platforms: List<String>, val minSdkVersion: Int, val maxSdkVersion: Int): Message() {
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
|
dest.writeInt(incompatibilities.size)
|
||||||
|
for (incompatibility in incompatibilities) {
|
||||||
|
when (incompatibility) {
|
||||||
|
is Release.Incompatibility.MinSdk -> {
|
||||||
|
dest.writeInt(0)
|
||||||
|
}
|
||||||
|
is Release.Incompatibility.MaxSdk -> {
|
||||||
|
dest.writeInt(1)
|
||||||
|
}
|
||||||
|
is Release.Incompatibility.Platform -> {
|
||||||
|
dest.writeInt(2)
|
||||||
|
}
|
||||||
|
is Release.Incompatibility.Feature -> {
|
||||||
|
dest.writeInt(3)
|
||||||
|
dest.writeString(incompatibility.feature)
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}
|
||||||
|
dest.writeStringList(platforms)
|
||||||
|
dest.writeInt(minSdkVersion)
|
||||||
|
dest.writeInt(maxSdkVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||||
|
val count = it.readInt()
|
||||||
|
val incompatibilities = generateSequence {
|
||||||
|
when (it.readInt()) {
|
||||||
|
0 -> Release.Incompatibility.MinSdk
|
||||||
|
1 -> Release.Incompatibility.MaxSdk
|
||||||
|
2 -> Release.Incompatibility.Platform
|
||||||
|
3 -> Release.Incompatibility.Feature(it.readString()!!)
|
||||||
|
else -> throw RuntimeException()
|
||||||
|
}
|
||||||
|
}.take(count).toList()
|
||||||
|
val platforms = it.createStringArrayList()!!
|
||||||
|
val minSdkVersion = it.readInt()
|
||||||
|
val maxSdkVersion = it.readInt()
|
||||||
|
ReleaseIncompatible(incompatibilities, platforms, minSdkVersion, maxSdkVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object ReleaseOlder: Message() {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseOlder }
|
||||||
|
}
|
||||||
|
|
||||||
|
object ReleaseSignatureMismatch: Message() {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseSignatureMismatch }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(message: Message): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putParcelable(EXTRA_MESSAGE, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(fragmentManager: FragmentManager) {
|
||||||
|
show(fragmentManager, this::class.java.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||||
|
val dialog = AlertDialog.Builder(requireContext())
|
||||||
|
when (val message = requireArguments().getParcelable<Message>(EXTRA_MESSAGE)!!) {
|
||||||
|
is Message.DeleteRepositoryConfirm -> {
|
||||||
|
dialog.setTitle(R.string.confirm_action)
|
||||||
|
dialog.setMessage(R.string.delete_repository_confirm)
|
||||||
|
dialog.setPositiveButton(R.string.delete) { _, _ -> (parentFragment as RepositoryFragment).onDeleteConfirm() }
|
||||||
|
dialog.setNegativeButton(R.string.cancel, null)
|
||||||
|
}
|
||||||
|
is Message.CantEditSyncing -> {
|
||||||
|
dialog.setTitle(R.string.action_failed)
|
||||||
|
dialog.setMessage(R.string.cant_edit_sync_description)
|
||||||
|
dialog.setPositiveButton(R.string.ok, null)
|
||||||
|
}
|
||||||
|
is Message.Link -> {
|
||||||
|
dialog.setTitle(R.string.confirm_action)
|
||||||
|
dialog.setMessage(getString(R.string.open_link_confirm_format, message.uri.toString()))
|
||||||
|
dialog.setPositiveButton(R.string.ok) { _, _ ->
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, message.uri))
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.setNegativeButton(R.string.cancel, null)
|
||||||
|
}
|
||||||
|
is Message.Permissions -> {
|
||||||
|
val packageManager = requireContext().packageManager
|
||||||
|
val builder = StringBuilder()
|
||||||
|
val localCache = PackageItemResolver.LocalCache()
|
||||||
|
val title = if (message.group != null) {
|
||||||
|
val name = try {
|
||||||
|
val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0)
|
||||||
|
PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo)
|
||||||
|
?.nullIfEmpty()?.let { if (it == message.group) null else it }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
name ?: getString(R.string.unknown)
|
||||||
|
} else {
|
||||||
|
getString(R.string.other)
|
||||||
|
}
|
||||||
|
for (permission in message.permissions) {
|
||||||
|
val description = try {
|
||||||
|
val permissionInfo = packageManager.getPermissionInfo(permission, 0)
|
||||||
|
PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo)
|
||||||
|
?.nullIfEmpty()?.let { if (it == permission) null else it }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
description?.let { builder.append(it).append("\n\n") }
|
||||||
|
}
|
||||||
|
if (builder.isNotEmpty()) {
|
||||||
|
builder.delete(builder.length - 2, builder.length)
|
||||||
|
} else {
|
||||||
|
builder.append(getString(R.string.no_description_available_description))
|
||||||
|
}
|
||||||
|
dialog.setTitle(title)
|
||||||
|
dialog.setMessage(builder)
|
||||||
|
dialog.setPositiveButton(R.string.ok, null)
|
||||||
|
}
|
||||||
|
is Message.ReleaseIncompatible -> {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
val minSdkVersion = if (Release.Incompatibility.MinSdk in message.incompatibilities)
|
||||||
|
message.minSdkVersion else null
|
||||||
|
val maxSdkVersion = if (Release.Incompatibility.MaxSdk in message.incompatibilities)
|
||||||
|
message.maxSdkVersion else null
|
||||||
|
if (minSdkVersion != null || maxSdkVersion != null) {
|
||||||
|
val versionMessage = minSdkVersion?.let { getString(R.string.incompatible_sdk_min_description_format, it) }
|
||||||
|
?: maxSdkVersion?.let { getString(R.string.incompatible_sdk_max_description_format, it) }
|
||||||
|
builder.append(getString(R.string.incompatible_sdk_description_format,
|
||||||
|
Android.name, Android.sdk, versionMessage.orEmpty())).append("\n\n")
|
||||||
|
}
|
||||||
|
if (Release.Incompatibility.Platform in message.incompatibilities) {
|
||||||
|
builder.append(getString(R.string.incompatible_platforms_description_format,
|
||||||
|
Android.primaryPlatform ?: getString(R.string.unknown),
|
||||||
|
message.platforms.joinToString(separator = ", "))).append("\n\n")
|
||||||
|
}
|
||||||
|
val features = message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
|
||||||
|
if (features.isNotEmpty()) {
|
||||||
|
builder.append(getString(R.string.incompatible_features_description))
|
||||||
|
for (feature in features) {
|
||||||
|
builder.append("\n\u2022 ").append(feature.feature)
|
||||||
|
}
|
||||||
|
builder.append("\n\n")
|
||||||
|
}
|
||||||
|
if (builder.isNotEmpty()) {
|
||||||
|
builder.delete(builder.length - 2, builder.length)
|
||||||
|
}
|
||||||
|
dialog.setTitle(R.string.incompatible_version)
|
||||||
|
dialog.setMessage(builder)
|
||||||
|
dialog.setPositiveButton(R.string.ok, null)
|
||||||
|
}
|
||||||
|
is Message.ReleaseOlder -> {
|
||||||
|
dialog.setTitle(R.string.incompatible_version)
|
||||||
|
dialog.setMessage(R.string.incompatible_older_description)
|
||||||
|
dialog.setPositiveButton(R.string.ok, null)
|
||||||
|
}
|
||||||
|
is Message.ReleaseSignatureMismatch -> {
|
||||||
|
dialog.setTitle(R.string.incompatible_version)
|
||||||
|
dialog.setMessage(R.string.incompatible_signature_description)
|
||||||
|
dialog.setPositiveButton(R.string.ok, null)
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
return dialog.create()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.InputFilter
|
||||||
|
import android.text.InputType
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceCategory
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.preference.PreferenceGroup
|
||||||
|
import androidx.preference.SwitchPreference
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.content.Preferences
|
||||||
|
|
||||||
|
class PreferencesFragment: PreferenceFragmentCompat() {
|
||||||
|
private var disposable: Disposable? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
val view = inflater.inflate(R.layout.fragment, container, false)
|
||||||
|
val content = view.findViewById<FrameLayout>(R.id.fragment_content)
|
||||||
|
val child = super.onCreateView(LayoutInflater.from(content.context), content, savedInstanceState)
|
||||||
|
content.addView(child, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
|
||||||
|
preferenceScreen.addCategory(getString(R.string.updates)).apply {
|
||||||
|
addEnumeration(Preferences.Key.AutoSync, getString(R.string.sync_repositories_automatically)) {
|
||||||
|
when (it) {
|
||||||
|
Preferences.AutoSync.Never -> getString(R.string.never)
|
||||||
|
Preferences.AutoSync.Wifi -> getString(R.string.over_wifi)
|
||||||
|
Preferences.AutoSync.Always -> getString(R.string.always)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addSwitch(Preferences.Key.UpdateNotify, getString(R.string.update_notifications),
|
||||||
|
getString(R.string.update_notifications_summary))
|
||||||
|
addSwitch(Preferences.Key.UpdateUnstable, getString(R.string.unstable_updates),
|
||||||
|
getString(R.string.unstable_updates_summary))
|
||||||
|
}
|
||||||
|
preferenceScreen.addCategory(getString(R.string.proxy)).apply {
|
||||||
|
addEnumeration(Preferences.Key.ProxyType, getString(R.string.proxy_type)) {
|
||||||
|
when (it) {
|
||||||
|
is Preferences.ProxyType.Direct -> getString(R.string.no_proxy)
|
||||||
|
is Preferences.ProxyType.Http -> getString(R.string.http_proxy)
|
||||||
|
is Preferences.ProxyType.Socks -> getString(R.string.socks_proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEditString(Preferences.Key.ProxyHost, getString(R.string.proxy_host))
|
||||||
|
addEditInt(Preferences.Key.ProxyPort, getString(R.string.proxy_port), 1 .. 65535)
|
||||||
|
}
|
||||||
|
preferenceScreen.addCategory(getString(R.string.other)).apply {
|
||||||
|
addEnumeration(Preferences.Key.Theme, getString(R.string.theme)) {
|
||||||
|
when (it) {
|
||||||
|
is Preferences.Theme.Light -> getString(R.string.light)
|
||||||
|
is Preferences.Theme.Dark -> getString(R.string.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addSwitch(Preferences.Key.IncompatibleVersions, getString(R.string.incompatible_versions),
|
||||||
|
getString(R.string.incompatible_versions_summary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
screenActivity.onFragmentViewCreated(toolbar)
|
||||||
|
toolbar.setTitle(R.string.preferences)
|
||||||
|
|
||||||
|
disposable = Preferences.observable.subscribe(this::updatePreference)
|
||||||
|
updatePreference(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
disposable?.dispose()
|
||||||
|
disposable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePreference(key: Preferences.Key<*>?) {
|
||||||
|
if (key == null || key == Preferences.Key.ProxyType) {
|
||||||
|
val enabled = when (Preferences[Preferences.Key.ProxyType]) {
|
||||||
|
is Preferences.ProxyType.Direct -> false
|
||||||
|
is Preferences.ProxyType.Http, is Preferences.ProxyType.Socks -> true
|
||||||
|
}
|
||||||
|
findPreference<Preference>(Preferences.Key.ProxyHost.name)!!.isEnabled = enabled
|
||||||
|
findPreference<Preference>(Preferences.Key.ProxyPort.name)!!.isEnabled = enabled
|
||||||
|
}
|
||||||
|
if (key == Preferences.Key.Theme) {
|
||||||
|
requireActivity().recreate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PreferenceGroup.addCategory(title: String): PreferenceCategory {
|
||||||
|
val preference = PreferenceCategory(context)
|
||||||
|
preference.isIconSpaceReserved = false
|
||||||
|
preference.title = title
|
||||||
|
addPreference(preference)
|
||||||
|
return preference
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PreferenceGroup.addSwitch(key: Preferences.Key<Boolean>, title: String, summary: String?) {
|
||||||
|
val preference = SwitchPreference(context)
|
||||||
|
preference.isIconSpaceReserved = false
|
||||||
|
preference.title = title
|
||||||
|
preference.summary = summary
|
||||||
|
preference.key = key.name
|
||||||
|
preference.setDefaultValue(key.default.value)
|
||||||
|
addPreference(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PreferenceGroup.addEditString(key: Preferences.Key<String>, title: String) {
|
||||||
|
val preference = EditTextPreference(context)
|
||||||
|
preference.isIconSpaceReserved = false
|
||||||
|
preference.title = title
|
||||||
|
preference.dialogTitle = title
|
||||||
|
preference.summaryProvider = Preference.SummaryProvider<EditTextPreference> { it.text }
|
||||||
|
preference.key = key.name
|
||||||
|
preference.setDefaultValue(key.default.value)
|
||||||
|
addPreference(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PreferenceGroup.addEditInt(key: Preferences.Key<Int>, title: String, range: IntRange?) {
|
||||||
|
val preference = object: EditTextPreference(context) {
|
||||||
|
override fun persistString(value: String?): Boolean {
|
||||||
|
val intValue = value.orEmpty().toIntOrNull() ?: key.default.value
|
||||||
|
val result = persistInt(intValue)
|
||||||
|
if (intValue.toString() != value) {
|
||||||
|
text = intValue.toString()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSetInitialValue(defaultValue: Any?) {
|
||||||
|
text = getPersistedInt((defaultValue as? Int) ?: key.default.value).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preference.isIconSpaceReserved = false
|
||||||
|
preference.title = title
|
||||||
|
preference.dialogTitle = title
|
||||||
|
preference.summaryProvider = Preference.SummaryProvider<EditTextPreference> { it.text }
|
||||||
|
preference.key = key.name
|
||||||
|
preference.setDefaultValue(key.default.value)
|
||||||
|
preference.setOnBindEditTextListener {
|
||||||
|
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||||
|
if (range != null) {
|
||||||
|
it.filters = arrayOf(InputFilter { source, start, end, dest, dstart, dend ->
|
||||||
|
val value = (dest.substring(0, dstart) + source.substring(start, end) +
|
||||||
|
dest.substring(dend, dest.length)).toIntOrNull()
|
||||||
|
if (value != null && value in range) null else ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addPreference(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T: Preferences.Enumeration<T>> PreferenceGroup
|
||||||
|
.addEnumeration(key: Preferences.Key<T>, title: String, valueString: (T) -> String) {
|
||||||
|
val preference = ListPreference(context)
|
||||||
|
preference.isIconSpaceReserved = false
|
||||||
|
preference.title = title
|
||||||
|
preference.dialogTitle = title
|
||||||
|
preference.summaryProvider = Preference.SummaryProvider<ListPreference> { p ->
|
||||||
|
val index = p.entryValues.indexOfFirst { it == p.value }
|
||||||
|
if (index >= 0) p.entries[index] else valueString(key.default.value)
|
||||||
|
}
|
||||||
|
preference.key = key.name
|
||||||
|
preference.setDefaultValue(key.default.value.valueString)
|
||||||
|
preference.entryValues = key.default.value.values.map { it.valueString }.toTypedArray()
|
||||||
|
preference.entries = key.default.value.values.map(valueString).toTypedArray()
|
||||||
|
addPreference(preference)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.content.ProductPreferences
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.InstalledItem
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Product
|
||||||
|
import nya.kitsunyan.foxydroid.entity.ProductPreference
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Release
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.service.Connection
|
||||||
|
import nya.kitsunyan.foxydroid.service.DownloadService
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.widget.DividerItemDecoration
|
||||||
|
|
||||||
|
class ProductFragment(): Fragment(), ProductAdapter.Callbacks {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_PACKAGE_NAME = "packageName"
|
||||||
|
|
||||||
|
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||||
|
private const val STATE_ADAPTER = "adapter"
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(packageName: String): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(EXTRA_PACKAGE_NAME, packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Nullable<T>(val value: T?)
|
||||||
|
private enum class Action(val id: Int, val adapterAction: ProductAdapter.Action, val iconResId: Int) {
|
||||||
|
LAUNCH(1, ProductAdapter.Action.LAUNCH, R.drawable.ic_launch),
|
||||||
|
DETAILS(2, ProductAdapter.Action.DETAILS, R.drawable.ic_tune),
|
||||||
|
UNINSTALL(3, ProductAdapter.Action.UNINSTALL, R.drawable.ic_delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageName: String
|
||||||
|
get() = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
|
||||||
|
|
||||||
|
private var layoutManagerState: LinearLayoutManager.SavedState? = null
|
||||||
|
|
||||||
|
private var products = emptyList<Pair<Product, Repository>>()
|
||||||
|
private var installedItem: InstalledItem? = null
|
||||||
|
private var installedMainActivity: String? = null
|
||||||
|
private var installedIsSystem = false
|
||||||
|
private var downloading = false
|
||||||
|
|
||||||
|
private var toolbar: Toolbar? = null
|
||||||
|
private var recyclerView: RecyclerView? = null
|
||||||
|
|
||||||
|
private var productDisposable: Disposable? = null
|
||||||
|
private var downloadDisposable: Disposable? = null
|
||||||
|
private val downloadConnection = Connection<DownloadService.Binder>(DownloadService::class.java, onBind = {
|
||||||
|
updateDownloadState(it.binder.getState(packageName))
|
||||||
|
downloadDisposable = it.binder.events(packageName).subscribe { updateDownloadState(it) }
|
||||||
|
}, onUnbind = {
|
||||||
|
downloadDisposable?.dispose()
|
||||||
|
downloadDisposable = null
|
||||||
|
})
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
screenActivity.onFragmentViewCreated(toolbar)
|
||||||
|
toolbar.setTitle(R.string.application)
|
||||||
|
this.toolbar = toolbar
|
||||||
|
|
||||||
|
toolbar.menu.apply {
|
||||||
|
for (action in Action.values()) {
|
||||||
|
add(0, action.id, 0, action.adapterAction.titleResId)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, action.iconResId))
|
||||||
|
.setVisible(false)
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
onActionClick(action.adapterAction)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = view.findViewById<FrameLayout>(R.id.fragment_content)
|
||||||
|
content.addView(RecyclerView(content.context).apply {
|
||||||
|
id = android.R.id.list
|
||||||
|
val columns = (resources.configuration.screenWidthDp / 120).coerceIn(3, 5)
|
||||||
|
val layoutManager = GridLayoutManager(context, columns)
|
||||||
|
this.layoutManager = layoutManager
|
||||||
|
isMotionEventSplittingEnabled = false
|
||||||
|
isVerticalScrollBarEnabled = false
|
||||||
|
val adapter = ProductAdapter(this@ProductFragment, columns)
|
||||||
|
this.adapter = adapter
|
||||||
|
layoutManager.spanSizeLookup = object: GridLayoutManager.SpanSizeLookup() {
|
||||||
|
override fun getSpanSize(position: Int): Int {
|
||||||
|
return if (adapter.requiresGrid(position)) 1 else layoutManager.spanCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addItemDecoration(adapter.gridItemDecoration)
|
||||||
|
addItemDecoration(DividerItemDecoration(context, adapter::configureDivider))
|
||||||
|
savedInstanceState?.getParcelable<ProductAdapter.SavedState>(STATE_ADAPTER)?.let(adapter::restoreState)
|
||||||
|
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||||
|
recyclerView = this
|
||||||
|
}, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||||
|
|
||||||
|
var first = true
|
||||||
|
productDisposable = Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Products))
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
|
||||||
|
.flatMapSingle { products -> RxUtils
|
||||||
|
.querySingle { Database.RepositoryAdapter.getAll(it) }
|
||||||
|
.map { it.asSequence().map { Pair(it.id, it) }.toMap()
|
||||||
|
.let { products.mapNotNull { product -> it[product.repositoryId]?.let { Pair(product, it) } } } } }
|
||||||
|
.flatMapSingle { products -> RxUtils
|
||||||
|
.querySingle { Nullable(Database.InstalledAdapter.get(packageName, it)) }
|
||||||
|
.map { Pair(products, it) } }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe {
|
||||||
|
val (products, installedItem) = it
|
||||||
|
val firstChanged = first
|
||||||
|
first = false
|
||||||
|
val productChanged = this.products != products
|
||||||
|
val installedItemChanged = this.installedItem != installedItem.value
|
||||||
|
if (firstChanged || productChanged || installedItemChanged) {
|
||||||
|
layoutManagerState?.let { recyclerView?.layoutManager!!.onRestoreInstanceState(it) }
|
||||||
|
layoutManagerState = null
|
||||||
|
this.products = products
|
||||||
|
this.installedItem = installedItem.value
|
||||||
|
val recyclerView = recyclerView!!
|
||||||
|
val adapter = recyclerView.adapter as ProductAdapter
|
||||||
|
if (firstChanged || productChanged) {
|
||||||
|
adapter.setProducts(recyclerView.context, products, packageName)
|
||||||
|
}
|
||||||
|
if (installedItemChanged) {
|
||||||
|
adapter.installedItem = installedItem.value
|
||||||
|
installedMainActivity = requireContext().packageManager
|
||||||
|
.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)
|
||||||
|
.find { it.activityInfo?.packageName == packageName }?.activityInfo?.name
|
||||||
|
installedIsSystem = try {
|
||||||
|
((requireContext().packageManager.getApplicationInfo(packageName, 0).flags)
|
||||||
|
and ApplicationInfo.FLAG_SYSTEM) != 0
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateButtons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadConnection.bind(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
toolbar = null
|
||||||
|
recyclerView = null
|
||||||
|
|
||||||
|
productDisposable?.dispose()
|
||||||
|
productDisposable = null
|
||||||
|
downloadDisposable?.dispose()
|
||||||
|
downloadDisposable = null
|
||||||
|
downloadConnection.unbind(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
val layoutManagerState = layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState()
|
||||||
|
layoutManagerState?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||||
|
val adapterState = (recyclerView?.adapter as? ProductAdapter)?.saveState()
|
||||||
|
adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateButtons() {
|
||||||
|
updateButtons(ProductPreferences[packageName])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateButtons(preference: ProductPreference) {
|
||||||
|
val product = Product.findSuggested(products) { it.first }?.first
|
||||||
|
val installedItem = installedItem
|
||||||
|
val compatible = product != null && product.selectedRelease.let { it != null && it.incompatibilities.isEmpty() }
|
||||||
|
val canInstall = product != null && installedItem == null && compatible
|
||||||
|
val canUpdate = product != null && compatible && product.canUpdate(installedItem) &&
|
||||||
|
!preference.shouldIgnoreUpdate(product.versionCode)
|
||||||
|
val canUninstall = product != null && installedItem != null && !installedIsSystem
|
||||||
|
val canLaunch = product != null && installedItem != null && installedMainActivity != null
|
||||||
|
val actions = mutableSetOf<Action>()
|
||||||
|
if (canLaunch) {
|
||||||
|
actions += Action.LAUNCH
|
||||||
|
}
|
||||||
|
if (installedItem != null) {
|
||||||
|
actions += Action.DETAILS
|
||||||
|
}
|
||||||
|
if (canUninstall) {
|
||||||
|
actions += Action.UNINSTALL
|
||||||
|
}
|
||||||
|
|
||||||
|
val recyclerView = recyclerView
|
||||||
|
if (recyclerView != null) {
|
||||||
|
val adapterAction = when {
|
||||||
|
downloading -> ProductAdapter.Action.CANCEL
|
||||||
|
canUpdate -> ProductAdapter.Action.UPDATE
|
||||||
|
canLaunch -> ProductAdapter.Action.LAUNCH
|
||||||
|
canUninstall -> ProductAdapter.Action.UNINSTALL
|
||||||
|
canInstall -> ProductAdapter.Action.INSTALL
|
||||||
|
installedItem != null -> ProductAdapter.Action.DETAILS
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
(recyclerView.adapter as ProductAdapter).setAction(recyclerView, adapterAction)
|
||||||
|
Action.values().find { it.adapterAction == adapterAction }?.let { actions -= it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val toolbar = toolbar
|
||||||
|
if (toolbar != null) {
|
||||||
|
toolbar.menu.findItem(Action.UNINSTALL.id).isEnabled = !downloading
|
||||||
|
for (action in Action.values()) {
|
||||||
|
toolbar.menu.findItem(action.id).isVisible = action in actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDownloadState(state: DownloadService.State?) {
|
||||||
|
val status = when (state) {
|
||||||
|
is DownloadService.State.Pending -> ProductAdapter.Status.Pending
|
||||||
|
is DownloadService.State.Connecting -> ProductAdapter.Status.Connecting
|
||||||
|
is DownloadService.State.Downloading -> ProductAdapter.Status.Downloading(state.read, state.total)
|
||||||
|
is DownloadService.State.Success, is DownloadService.State.Error, is DownloadService.State.Cancel, null -> null
|
||||||
|
}
|
||||||
|
val downloading = status != null
|
||||||
|
if (this.downloading != downloading) {
|
||||||
|
this.downloading = downloading
|
||||||
|
updateButtons()
|
||||||
|
}
|
||||||
|
val recyclerView = recyclerView
|
||||||
|
if (recyclerView != null) {
|
||||||
|
(recyclerView.adapter as ProductAdapter).setStatus(recyclerView, status)
|
||||||
|
}
|
||||||
|
if (state is DownloadService.State.Success) {
|
||||||
|
state.consume()
|
||||||
|
screenActivity.startPackageInstaller(state.release.cacheFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionClick(action: ProductAdapter.Action) {
|
||||||
|
when (action) {
|
||||||
|
ProductAdapter.Action.INSTALL,
|
||||||
|
ProductAdapter.Action.UPDATE -> {
|
||||||
|
val productRepository = Product.findSuggested(products) { it.first }
|
||||||
|
val release = productRepository?.first?.selectedRelease
|
||||||
|
val binder = downloadConnection.binder
|
||||||
|
if (release != null && binder != null) {
|
||||||
|
binder.enqueue(packageName, productRepository.first.name, productRepository.second, release)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
ProductAdapter.Action.LAUNCH -> {
|
||||||
|
val installedMainActivity = installedMainActivity
|
||||||
|
if (installedMainActivity != null) {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Intent.ACTION_MAIN)
|
||||||
|
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
.setComponent(ComponentName(packageName, installedMainActivity))
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
ProductAdapter.Action.DETAILS -> {
|
||||||
|
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
.setData(Uri.parse("package:$packageName")))
|
||||||
|
}
|
||||||
|
ProductAdapter.Action.UNINSTALL -> {
|
||||||
|
// TODO Handle deprecation
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
startActivity(Intent(Intent.ACTION_UNINSTALL_PACKAGE)
|
||||||
|
.setData(Uri.parse("package:$packageName")))
|
||||||
|
}
|
||||||
|
ProductAdapter.Action.CANCEL -> {
|
||||||
|
val binder = downloadConnection.binder
|
||||||
|
if (downloading && binder != null) {
|
||||||
|
binder.cancel(packageName)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceChanged(preference: ProductPreference) {
|
||||||
|
updateButtons(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPermissionsClick(group: String?, permissions: List<String>) {
|
||||||
|
MessageDialog(MessageDialog.Message.Permissions(group, permissions)).show(childFragmentManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScreenshotClick(screenshot: Product.Screenshot) {
|
||||||
|
val pair = products.asSequence()
|
||||||
|
.map { Pair(it.second, it.first.screenshots.find { it === screenshot }?.identifier) }
|
||||||
|
.filter { it.second != null }.firstOrNull()
|
||||||
|
if (pair != null) {
|
||||||
|
val (repository, identifier) = pair
|
||||||
|
if (identifier != null) {
|
||||||
|
ScreenshotsFragment(packageName, repository.id, identifier).show(childFragmentManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReleaseClick(release: Release) {
|
||||||
|
val installedItem = installedItem
|
||||||
|
when {
|
||||||
|
release.incompatibilities.isNotEmpty() -> {
|
||||||
|
MessageDialog(MessageDialog.Message.ReleaseIncompatible(release.incompatibilities,
|
||||||
|
release.platforms, release.minSdkVersion, release.maxSdkVersion)).show(childFragmentManager)
|
||||||
|
}
|
||||||
|
installedItem != null && installedItem.versionCode > release.versionCode -> {
|
||||||
|
MessageDialog(MessageDialog.Message.ReleaseOlder).show(childFragmentManager)
|
||||||
|
}
|
||||||
|
installedItem != null && installedItem.signature != release.signature -> {
|
||||||
|
MessageDialog(MessageDialog.Message.ReleaseSignatureMismatch).show(childFragmentManager)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val productRepository = products.asSequence().filter { it.first.releases.any { it === release } }.firstOrNull()
|
||||||
|
if (productRepository != null) {
|
||||||
|
downloadConnection.binder?.enqueue(packageName, productRepository.first.name,
|
||||||
|
productRepository.second, release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {
|
||||||
|
return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) {
|
||||||
|
MessageDialog(MessageDialog.Message.Link(uri)).show(childFragmentManager)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.widget.AppCompatTextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import coil.api.clear
|
||||||
|
import coil.api.load
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.ProductItem
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.network.CoilDownloader
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import nya.kitsunyan.foxydroid.widget.CursorRecyclerAdapter
|
||||||
|
|
||||||
|
class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
|
||||||
|
CursorRecyclerAdapter<ProductsAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||||
|
enum class ViewType { PRODUCT, LOADING, EMPTY }
|
||||||
|
|
||||||
|
private class ProductViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
|
||||||
|
val name = itemView.findViewById<TextView>(R.id.name)!!
|
||||||
|
val status = itemView.findViewById<TextView>(R.id.status)!!
|
||||||
|
val summary = itemView.findViewById<TextView>(R.id.summary)!!
|
||||||
|
val icon = itemView.findViewById<ImageView>(R.id.icon)!!
|
||||||
|
|
||||||
|
val progressIcon: Drawable
|
||||||
|
val defaultIcon: Drawable
|
||||||
|
|
||||||
|
init {
|
||||||
|
val (progressIcon, defaultIcon) = Utils.getDefaultApplicationIcons(icon.context)
|
||||||
|
this.progressIcon = progressIcon
|
||||||
|
this.defaultIcon = defaultIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoadingViewHolder(context: Context): RecyclerView.ViewHolder(FrameLayout(context)) {
|
||||||
|
init {
|
||||||
|
itemView as FrameLayout
|
||||||
|
val progressBar = ProgressBar(itemView.context)
|
||||||
|
itemView.addView(progressBar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER })
|
||||||
|
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||||
|
RecyclerView.LayoutParams.MATCH_PARENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EmptyViewHolder(context: Context): RecyclerView.ViewHolder(AppCompatTextView(context)) {
|
||||||
|
val text: TextView
|
||||||
|
get() = itemView as TextView
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView as TextView
|
||||||
|
itemView.gravity = Gravity.CENTER
|
||||||
|
itemView.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) }
|
||||||
|
itemView.typeface = TypefaceExtra.light
|
||||||
|
itemView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
itemView.setTextSizeScaled(20)
|
||||||
|
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||||
|
RecyclerView.LayoutParams.MATCH_PARENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var repositories: Map<Long, Repository> = emptyMap()
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyText: String = ""
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
if (isEmpty) {
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val viewTypeClass: Class<ViewType>
|
||||||
|
get() = ViewType::class.java
|
||||||
|
|
||||||
|
private val isEmpty: Boolean
|
||||||
|
get() = super.getItemCount() == 0
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = if (isEmpty) 1 else super.getItemCount()
|
||||||
|
override fun getItemId(position: Int): Long = if (isEmpty) -1 else super.getItemId(position)
|
||||||
|
|
||||||
|
override fun getItemEnumViewType(position: Int): ViewType {
|
||||||
|
return when {
|
||||||
|
!isEmpty -> ViewType.PRODUCT
|
||||||
|
cursor == null -> ViewType.LOADING
|
||||||
|
else -> ViewType.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getProductItem(position: Int): ProductItem {
|
||||||
|
return Database.ProductAdapter.transformItem(moveTo(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
|
||||||
|
return when (viewType) {
|
||||||
|
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply {
|
||||||
|
itemView.setOnClickListener { onClick(getProductItem(adapterPosition)) }
|
||||||
|
}
|
||||||
|
ViewType.LOADING -> LoadingViewHolder(parent.context)
|
||||||
|
ViewType.EMPTY -> EmptyViewHolder(parent.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (getItemEnumViewType(position)) {
|
||||||
|
ViewType.PRODUCT -> {
|
||||||
|
holder as ProductViewHolder
|
||||||
|
val productItem = getProductItem(position)
|
||||||
|
holder.name.text = productItem.name
|
||||||
|
holder.summary.text = if (productItem.name == productItem.summary) "" else productItem.summary
|
||||||
|
holder.summary.visibility = if (holder.summary.text.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
val repository: Repository? = repositories[productItem.repositoryId]
|
||||||
|
if (productItem.icon.isNotEmpty() && repository != null) {
|
||||||
|
holder.icon.load(CoilDownloader.createIconUri(holder.icon, productItem.icon, repository)) {
|
||||||
|
placeholder(holder.progressIcon)
|
||||||
|
error(holder.defaultIcon)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
holder.icon.clear()
|
||||||
|
holder.icon.setImageDrawable(holder.defaultIcon)
|
||||||
|
}
|
||||||
|
holder.status.apply {
|
||||||
|
if (productItem.canUpdate) {
|
||||||
|
text = productItem.version
|
||||||
|
if (background == null) {
|
||||||
|
resources.sizeScaled(4).let { setPadding(it, 0, it, 0) }
|
||||||
|
setTextColor(holder.status.context.getColorFromAttr(android.R.attr.colorBackground))
|
||||||
|
background = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, null).apply {
|
||||||
|
color = holder.status.context.getColorFromAttr(R.attr.colorAccent)
|
||||||
|
cornerRadius = holder.status.resources.sizeScaled(2).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = productItem.installedVersion.nullIfEmpty() ?: productItem.version
|
||||||
|
if (background != null) {
|
||||||
|
setPadding(0, 0, 0, 0)
|
||||||
|
setTextColor(holder.status.context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
background = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty()
|
||||||
|
sequenceOf(holder.name, holder.status, holder.summary).forEach { it.isEnabled = enabled }
|
||||||
|
}
|
||||||
|
ViewType.LOADING -> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
ViewType.EMPTY -> {
|
||||||
|
holder as EmptyViewHolder
|
||||||
|
holder.text.text = emptyText
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.CursorOwner
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.widget.DividerItemDecoration
|
||||||
|
import nya.kitsunyan.foxydroid.widget.RecyclerFastScroller
|
||||||
|
|
||||||
|
class ProductsFragment(): Fragment(), CursorOwner.Callback {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_SOURCE = "source"
|
||||||
|
|
||||||
|
private const val STATE_CURRENT_SEARCH_QUERY = "currentSearchQuery"
|
||||||
|
private const val STATE_CURRENT_CATEGORY = "currentCategory"
|
||||||
|
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Source(val titleResId: Int, val categories: Boolean) {
|
||||||
|
AVAILABLE(R.string.available, true),
|
||||||
|
INSTALLED(R.string.installed, false),
|
||||||
|
UPDATES(R.string.updates, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: Source): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(EXTRA_SOURCE, source.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val source: Source
|
||||||
|
get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf)
|
||||||
|
|
||||||
|
private var searchQuery = ""
|
||||||
|
private var category = ""
|
||||||
|
|
||||||
|
private var currentSearchQuery = ""
|
||||||
|
private var currentCategory = ""
|
||||||
|
private var layoutManagerState: Parcelable? = null
|
||||||
|
|
||||||
|
private var recyclerView: RecyclerView? = null
|
||||||
|
|
||||||
|
private var repositoriesDisposable: Disposable? = null
|
||||||
|
|
||||||
|
private val request: CursorOwner.Request
|
||||||
|
get() {
|
||||||
|
val searchQuery = searchQuery
|
||||||
|
val category = if (source.categories) category else ""
|
||||||
|
return when (source) {
|
||||||
|
Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(searchQuery, category)
|
||||||
|
Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(searchQuery, category)
|
||||||
|
Source.UPDATES -> CursorOwner.Request.ProductsUpdates(searchQuery, category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
return RecyclerView(container!!.context).apply {
|
||||||
|
id = android.R.id.list
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
isMotionEventSplittingEnabled = false
|
||||||
|
isVerticalScrollBarEnabled = false
|
||||||
|
setHasFixedSize(true)
|
||||||
|
adapter = ProductsAdapter { screenActivity.navigateProduct(it.packageName) }
|
||||||
|
addItemDecoration(DividerItemDecoration(context,
|
||||||
|
DividerItemDecoration.fixed(context.resources.sizeScaled(72), 0)))
|
||||||
|
RecyclerFastScroller(this)
|
||||||
|
recyclerView = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
currentSearchQuery = savedInstanceState?.getString(STATE_CURRENT_SEARCH_QUERY).orEmpty()
|
||||||
|
currentCategory = savedInstanceState?.getString(STATE_CURRENT_CATEGORY).orEmpty()
|
||||||
|
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||||
|
|
||||||
|
screenActivity.cursorOwner.attach(this, request)
|
||||||
|
repositoriesDisposable = Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Repositories))
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
|
||||||
|
.map { it.asSequence().map { Pair(it.id, it) }.toMap() }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { (recyclerView?.adapter as? ProductsAdapter)?.repositories = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
recyclerView = null
|
||||||
|
|
||||||
|
screenActivity.cursorOwner.detach(this)
|
||||||
|
repositoriesDisposable?.dispose()
|
||||||
|
repositoriesDisposable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
outState.putString(STATE_CURRENT_SEARCH_QUERY, currentSearchQuery)
|
||||||
|
outState.putString(STATE_CURRENT_CATEGORY, currentCategory)
|
||||||
|
(layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState())
|
||||||
|
?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||||
|
(recyclerView?.adapter as? ProductsAdapter)?.apply {
|
||||||
|
this.cursor = cursor
|
||||||
|
emptyText = when {
|
||||||
|
cursor == null -> ""
|
||||||
|
searchQuery.isNotEmpty() -> getString(R.string.empty_search_summary)
|
||||||
|
else -> when (source) {
|
||||||
|
Source.AVAILABLE -> getString(R.string.available_empty_summary)
|
||||||
|
Source.INSTALLED -> getString(R.string.installed_empty_summary)
|
||||||
|
Source.UPDATES -> getString(R.string.updates_empty_summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutManagerState?.let {
|
||||||
|
layoutManagerState = null
|
||||||
|
recyclerView?.layoutManager?.onRestoreInstanceState(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSearchQuery != searchQuery || currentCategory != category) {
|
||||||
|
currentSearchQuery = searchQuery
|
||||||
|
currentCategory = category
|
||||||
|
recyclerView?.scrollToPosition(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun setSearchQuery(searchQuery: String) {
|
||||||
|
if (this.searchQuery != searchQuery) {
|
||||||
|
this.searchQuery = searchQuery
|
||||||
|
if (view != null) {
|
||||||
|
screenActivity.cursorOwner.attach(this, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun setCategory(category: String) {
|
||||||
|
if (this.category != category) {
|
||||||
|
this.category = category
|
||||||
|
if (view != null) {
|
||||||
|
screenActivity.cursorOwner.attach(this, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.widget.SwitchCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.widget.CursorRecyclerAdapter
|
||||||
|
|
||||||
|
class RepositoriesAdapter(private val onClick: (Repository) -> Unit,
|
||||||
|
private val onSwitch: (repository: Repository, isEnabled: Boolean) -> Boolean):
|
||||||
|
CursorRecyclerAdapter<RepositoriesAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||||
|
enum class ViewType { REPOSITORY }
|
||||||
|
|
||||||
|
private class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
|
||||||
|
val name = itemView.findViewById<TextView>(R.id.name)!!
|
||||||
|
val enabled = itemView.findViewById<SwitchCompat>(R.id.enabled)!!
|
||||||
|
|
||||||
|
var listenSwitch = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override val viewTypeClass: Class<ViewType>
|
||||||
|
get() = ViewType::class.java
|
||||||
|
|
||||||
|
override fun getItemEnumViewType(position: Int): ViewType {
|
||||||
|
return ViewType.REPOSITORY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRepository(position: Int): Repository {
|
||||||
|
return Database.RepositoryAdapter.transform(moveTo(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
|
||||||
|
return ViewHolder(parent.inflate(R.layout.repository_item)).apply {
|
||||||
|
itemView.setOnClickListener { onClick(getRepository(adapterPosition)) }
|
||||||
|
enabled.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (listenSwitch) {
|
||||||
|
if (!onSwitch(getRepository(adapterPosition), isChecked)) {
|
||||||
|
listenSwitch = false
|
||||||
|
enabled.isChecked = !isChecked
|
||||||
|
listenSwitch = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
holder as ViewHolder
|
||||||
|
val repository = getRepository(position)
|
||||||
|
val lastListenSwitch = holder.listenSwitch
|
||||||
|
holder.listenSwitch = false
|
||||||
|
holder.enabled.isChecked = repository.enabled
|
||||||
|
holder.listenSwitch = lastListenSwitch
|
||||||
|
holder.name.text = repository.name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.CursorOwner
|
||||||
|
import nya.kitsunyan.foxydroid.service.Connection
|
||||||
|
import nya.kitsunyan.foxydroid.service.SyncService
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
|
||||||
|
class RepositoriesFragment: Fragment(), CursorOwner.Callback {
|
||||||
|
private var recyclerView: RecyclerView? = null
|
||||||
|
|
||||||
|
private val syncConnection = Connection<SyncService.Binder>(SyncService::class.java)
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
return inflater.inflate(R.layout.fragment, container, false).apply {
|
||||||
|
val content = findViewById<FrameLayout>(R.id.fragment_content)
|
||||||
|
content.addView(RecyclerView(content.context).apply {
|
||||||
|
id = android.R.id.list
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
isMotionEventSplittingEnabled = false
|
||||||
|
setHasFixedSize(true)
|
||||||
|
adapter = RepositoriesAdapter({ screenActivity.navigateRepository(it.id) },
|
||||||
|
{ repository, isEnabled -> repository.enabled != isEnabled &&
|
||||||
|
syncConnection.binder?.setEnabled(repository, isEnabled) == true })
|
||||||
|
recyclerView = this
|
||||||
|
}, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
syncConnection.bind(requireContext())
|
||||||
|
screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories)
|
||||||
|
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
screenActivity.onFragmentViewCreated(toolbar)
|
||||||
|
toolbar.setTitle(R.string.repositories)
|
||||||
|
|
||||||
|
toolbar.menu.apply {
|
||||||
|
add(R.string.add_repository)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_add))
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
view.post { screenActivity.navigateAddRepository() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
recyclerView = null
|
||||||
|
|
||||||
|
syncConnection.unbind(requireContext())
|
||||||
|
screenActivity.cursorOwner.detach(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||||
|
(recyclerView?.adapter as? RepositoriesAdapter)?.cursor = cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import android.text.style.TypefaceSpan
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.service.Connection
|
||||||
|
import nya.kitsunyan.foxydroid.service.SyncService
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class RepositoryFragment(): Fragment() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(repositoryId: Long): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putLong(EXTRA_REPOSITORY_ID, repositoryId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val repositoryId: Long
|
||||||
|
get() = requireArguments().getLong(EXTRA_REPOSITORY_ID)
|
||||||
|
|
||||||
|
private var layout: LinearLayout? = null
|
||||||
|
|
||||||
|
private val syncConnection = Connection<SyncService.Binder>(SyncService::class.java)
|
||||||
|
private var repositoryDisposable: Disposable? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
return inflater.inflate(R.layout.fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
syncConnection.bind(requireContext())
|
||||||
|
repositoryDisposable = Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Repository(repositoryId)))
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { updateRepositoryView() }
|
||||||
|
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
screenActivity.onFragmentViewCreated(toolbar)
|
||||||
|
toolbar.setTitle(R.string.repository)
|
||||||
|
|
||||||
|
toolbar.menu.apply {
|
||||||
|
add(R.string.edit_repository)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_edit))
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
view.post { screenActivity.navigateEditRepository(repositoryId) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
add(R.string.delete)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_delete))
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
MessageDialog(MessageDialog.Message.DeleteRepositoryConfirm).show(childFragmentManager)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = view.findViewById<FrameLayout>(R.id.fragment_content)
|
||||||
|
val scroll = ScrollView(content.context)
|
||||||
|
scroll.id = android.R.id.list
|
||||||
|
scroll.isFillViewport = true
|
||||||
|
content.addView(scroll, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||||
|
val layout = LinearLayout(scroll.context)
|
||||||
|
layout.orientation = LinearLayout.VERTICAL
|
||||||
|
resources.sizeScaled(8).let { layout.setPadding(0, it, 0, it) }
|
||||||
|
this.layout = layout
|
||||||
|
scroll.addView(layout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
layout = null
|
||||||
|
syncConnection.unbind(requireContext())
|
||||||
|
repositoryDisposable?.dispose()
|
||||||
|
repositoryDisposable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRepositoryView() {
|
||||||
|
val repository = Database.RepositoryAdapter.get(repositoryId)
|
||||||
|
val layout = layout!!
|
||||||
|
layout.removeAllViews()
|
||||||
|
if (repository == null) {
|
||||||
|
layout.addTitleText(R.string.address, getString(R.string.unknown))
|
||||||
|
} else {
|
||||||
|
layout.addTitleText(R.string.address, repository.address)
|
||||||
|
if (repository.updated > 0L) {
|
||||||
|
layout.addTitleText(R.string.name, repository.name)
|
||||||
|
layout.addTitleText(R.string.description, repository.description.replace('\n', ' '))
|
||||||
|
layout.addTitleText(R.string.last_update, run {
|
||||||
|
val lastUpdated = repository.updated
|
||||||
|
if (lastUpdated > 0L) {
|
||||||
|
val date = Date(repository.updated)
|
||||||
|
val format = if (DateUtils.isToday(date.time)) DateUtils.FORMAT_SHOW_TIME else
|
||||||
|
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
|
||||||
|
DateUtils.formatDateTime(layout.context, date.time, format)
|
||||||
|
} else {
|
||||||
|
getString(R.string.unknown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (repository.enabled && repository.entityTag.isNotEmpty()) {
|
||||||
|
layout.addTitleText(R.string.number_of_applications,
|
||||||
|
Database.ProductAdapter.getCount(repository.id).toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
layout.addTitleText(R.string.description, getString(R.string.not_updated_description))
|
||||||
|
}
|
||||||
|
if (repository.fingerprint.isEmpty()) {
|
||||||
|
if (repository.updated > 0L) {
|
||||||
|
val builder = SpannableStringBuilder(getString(R.string.unsigned_description))
|
||||||
|
builder.setSpan(ForegroundColorSpan(layout.context.getColorFromAttr(R.attr.colorError).defaultColor),
|
||||||
|
0, builder.length, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
layout.addTitleText(R.string.fingerprint, builder)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val fingerprint = SpannableStringBuilder(repository.fingerprint.windowed(2, 2, false)
|
||||||
|
.take(32).joinToString(separator = " ") { it.toUpperCase(Locale.US) })
|
||||||
|
fingerprint.setSpan(TypefaceSpan("monospace"), 0, fingerprint.length,
|
||||||
|
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
layout.addTitleText(R.string.fingerprint, fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LinearLayout.addTitleText(titleResId: Int, text: CharSequence) {
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
val layout = inflate(R.layout.title_text_item)
|
||||||
|
val titleView = layout.findViewById<TextView>(R.id.title)
|
||||||
|
titleView.setText(titleResId)
|
||||||
|
val textView = layout.findViewById<TextView>(R.id.text)
|
||||||
|
textView.text = text
|
||||||
|
addView(layout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDeleteConfirm() {
|
||||||
|
if (syncConnection.binder?.deleteRepository(repositoryId) == true) {
|
||||||
|
requireActivity().onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.content.Cache
|
||||||
|
import nya.kitsunyan.foxydroid.content.Preferences
|
||||||
|
import nya.kitsunyan.foxydroid.database.CursorOwner
|
||||||
|
import nya.kitsunyan.foxydroid.utility.KParcelable
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
abstract class ScreenActivity: AppCompatActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val STATE_FRAGMENT_STACK = "fragmentStack"
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SpecialIntent {
|
||||||
|
object Updates: SpecialIntent()
|
||||||
|
class Install(val packageName: String?, val cacheFileName: String?): SpecialIntent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FragmentStackItem(val className: String, val arguments: Bundle?,
|
||||||
|
val savedState: Fragment.SavedState?): KParcelable {
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
|
dest.writeString(className)
|
||||||
|
dest.writeByte(if (arguments != null) 1 else 0)
|
||||||
|
arguments?.writeToParcel(dest, flags)
|
||||||
|
dest.writeByte(if (savedState != null) 1 else 0)
|
||||||
|
savedState?.writeToParcel(dest, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||||
|
val className = it.readString()!!
|
||||||
|
val arguments = if (it.readByte().toInt() == 0) null else Bundle.CREATOR.createFromParcel(it)
|
||||||
|
arguments?.classLoader = ScreenActivity::class.java.classLoader
|
||||||
|
val savedState = if (it.readByte().toInt() == 0) null else Fragment.SavedState.CREATOR.createFromParcel(it)
|
||||||
|
FragmentStackItem(className, arguments, savedState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lateinit var cursorOwner: CursorOwner
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val fragmentStack = mutableListOf<FragmentStackItem>()
|
||||||
|
private var toolbar: WeakReference<Toolbar>? = null
|
||||||
|
|
||||||
|
private val currentFragment: Fragment?
|
||||||
|
get() {
|
||||||
|
supportFragmentManager.executePendingTransactions()
|
||||||
|
return supportFragmentManager.findFragmentById(R.id.main_content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(base: Context) {
|
||||||
|
super.attachBaseContext(Utils.configureLocale(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
setTheme(Preferences[Preferences.Key.Theme].resId)
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
addContentView(FrameLayout(this).apply { id = R.id.main_content },
|
||||||
|
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||||
|
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
cursorOwner = CursorOwner()
|
||||||
|
supportFragmentManager.beginTransaction()
|
||||||
|
.add(cursorOwner, CursorOwner::class.java.name)
|
||||||
|
.commit()
|
||||||
|
} else {
|
||||||
|
cursorOwner = supportFragmentManager
|
||||||
|
.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
|
||||||
|
?.let { fragmentStack += it }
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
replaceFragment(TabsFragment(), null)
|
||||||
|
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
val menuItem = toolbar?.get()?.menu?.findItem(R.id.toolbar_search)
|
||||||
|
if (menuItem != null && menuItem.isActionViewExpanded) {
|
||||||
|
menuItem.collapseActionView()
|
||||||
|
} else {
|
||||||
|
hideKeyboard()
|
||||||
|
if (!popFragment()) {
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
|
||||||
|
if (open != null) {
|
||||||
|
currentFragment?.view?.translationZ = (if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
|
||||||
|
}
|
||||||
|
supportFragmentManager
|
||||||
|
.beginTransaction()
|
||||||
|
.apply {
|
||||||
|
if (open != null) {
|
||||||
|
setCustomAnimations(if (open) R.animator.slide_in else 0,
|
||||||
|
if (open) R.animator.slide_in_keep else R.animator.slide_out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.replace(R.id.main_content, fragment)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushFragment(fragment: Fragment) {
|
||||||
|
currentFragment?.let { fragmentStack.add(FragmentStackItem(it::class.java.name, it.arguments,
|
||||||
|
supportFragmentManager.saveFragmentInstanceState(it))) }
|
||||||
|
replaceFragment(fragment, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun popFragment(): Boolean {
|
||||||
|
return fragmentStack.isNotEmpty() && run {
|
||||||
|
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
|
||||||
|
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
|
||||||
|
stackItem.arguments?.let(fragment::setArguments)
|
||||||
|
stackItem.savedState?.let(fragment::setInitialSavedState)
|
||||||
|
replaceFragment(fragment, false)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideKeyboard() {
|
||||||
|
(getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager)
|
||||||
|
?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachFragment(fragment: Fragment) {
|
||||||
|
super.onAttachFragment(fragment)
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onFragmentViewCreated(toolbar: Toolbar?) {
|
||||||
|
this.toolbar = toolbar?.let(::WeakReference)
|
||||||
|
if (fragmentStack.isNotEmpty() && toolbar != null) {
|
||||||
|
toolbar.navigationIcon = toolbar.context.getDrawableFromAttr(R.attr.homeAsUpIndicator)
|
||||||
|
toolbar.setNavigationOnClickListener { onBackPressed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected val Intent.packageName: String?
|
||||||
|
get() {
|
||||||
|
val uri = data
|
||||||
|
return when {
|
||||||
|
uri?.scheme == "package" || uri?.scheme == "fdroid.app" -> uri.schemeSpecificPart?.nullIfEmpty()
|
||||||
|
uri?.scheme == "market" && uri.host == "details" -> uri.getQueryParameter("id")?.nullIfEmpty()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun handleSpecialIntent(specialIntent: SpecialIntent) {
|
||||||
|
when (specialIntent) {
|
||||||
|
is SpecialIntent.Updates -> {
|
||||||
|
if (currentFragment !is TabsFragment) {
|
||||||
|
fragmentStack.clear()
|
||||||
|
replaceFragment(TabsFragment(), true)
|
||||||
|
}
|
||||||
|
val tabsFragment = currentFragment as TabsFragment
|
||||||
|
tabsFragment.selectUpdates()
|
||||||
|
}
|
||||||
|
is SpecialIntent.Install -> {
|
||||||
|
val packageName = specialIntent.packageName
|
||||||
|
if (!packageName.isNullOrEmpty()) {
|
||||||
|
val fragment = currentFragment
|
||||||
|
if (fragment !is ProductFragment || fragment.packageName != packageName) {
|
||||||
|
pushFragment(ProductFragment(packageName))
|
||||||
|
}
|
||||||
|
specialIntent.cacheFileName?.let(::startPackageInstaller)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun handleIntent(intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
Intent.ACTION_VIEW -> {
|
||||||
|
val packageName = intent.packageName
|
||||||
|
if (!packageName.isNullOrEmpty()) {
|
||||||
|
val fragment = currentFragment
|
||||||
|
if (fragment !is ProductFragment || fragment.packageName != packageName) {
|
||||||
|
pushFragment(ProductFragment(packageName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun startPackageInstaller(cacheFileName: String) {
|
||||||
|
val (uri, flags) = if (Android.sdk(24)) {
|
||||||
|
Pair(Cache.getReleaseUri(this, cacheFileName), Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
} else {
|
||||||
|
Pair(Uri.fromFile(Cache.getReleaseFile(this, cacheFileName)), 0)
|
||||||
|
}
|
||||||
|
// TODO Handle deprecation
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
startActivity(Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||||
|
.setDataAndType(uri, "application/vnd.android.package-archive").setFlags(flags))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun navigateProduct(packageName: String) = pushFragment(ProductFragment(packageName))
|
||||||
|
internal fun navigateRepositories() = pushFragment(RepositoriesFragment())
|
||||||
|
internal fun navigatePreferences() = pushFragment(PreferencesFragment())
|
||||||
|
internal fun navigateAddRepository() = pushFragment(EditRepositoryFragment(null))
|
||||||
|
internal fun navigateRepository(repositoryId: Long) = pushFragment(RepositoryFragment(repositoryId))
|
||||||
|
internal fun navigateEditRepository(repositoryId: Long) = pushFragment(EditRepositoryFragment(repositoryId))
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.widget.MarginPageTransformer
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import coil.api.*
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Product
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.graphics.PaddingDrawable
|
||||||
|
import nya.kitsunyan.foxydroid.network.CoilDownloader
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.widget.StableRecyclerAdapter
|
||||||
|
|
||||||
|
class ScreenshotsFragment(): DialogFragment() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_PACKAGE_NAME = "packageName"
|
||||||
|
private const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||||
|
private const val EXTRA_IDENTIFIER = "identifier"
|
||||||
|
|
||||||
|
private const val STATE_IDENTIFIER = "identifier"
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(packageName: String, repositoryId: Long, identifier: String): this() {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(EXTRA_PACKAGE_NAME, packageName)
|
||||||
|
putLong(EXTRA_REPOSITORY_ID, repositoryId)
|
||||||
|
putString(EXTRA_IDENTIFIER, identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(fragmentManager: FragmentManager) {
|
||||||
|
show(fragmentManager, this::class.java.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var viewPager: ViewPager2? = null
|
||||||
|
|
||||||
|
private var productDisposable: Disposable? = null
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val packageName = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
|
||||||
|
val repositoryId = requireArguments().getLong(EXTRA_REPOSITORY_ID)
|
||||||
|
val dialog = Dialog(requireContext(), R.style.Theme_Main_Dark)
|
||||||
|
|
||||||
|
val window = dialog.window!!
|
||||||
|
val decorView = window.decorView
|
||||||
|
val background = dialog.context.getColorFromAttr(android.R.attr.colorBackground).defaultColor
|
||||||
|
decorView.setBackgroundColor(background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.9f) })
|
||||||
|
decorView.setPadding(0, 0, 0, 0)
|
||||||
|
background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let {
|
||||||
|
window.statusBarColor = it
|
||||||
|
window.navigationBarColor = it
|
||||||
|
}
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
title = ScreenshotsFragment::class.java.name
|
||||||
|
format = PixelFormat.TRANSLUCENT
|
||||||
|
windowAnimations = run {
|
||||||
|
val typedArray = dialog.context.obtainStyledAttributes(null,
|
||||||
|
intArrayOf(android.R.attr.windowAnimationStyle), R.attr.dialogTheme, 0)
|
||||||
|
try {
|
||||||
|
typedArray.getResourceId(0, 0)
|
||||||
|
} finally {
|
||||||
|
typedArray.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||||
|
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE
|
||||||
|
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
val applyHide = Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags }
|
||||||
|
decorView.postDelayed(applyHide, 2000L)
|
||||||
|
decorView.setOnClickListener {
|
||||||
|
decorView.removeCallbacks(applyHide)
|
||||||
|
if ((decorView.systemUiVisibility and hideFlags) == hideFlags) {
|
||||||
|
decorView.systemUiVisibility = decorView.systemUiVisibility and hideFlags.inv()
|
||||||
|
} else {
|
||||||
|
decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewPager = ViewPager2(dialog.context)
|
||||||
|
viewPager.adapter = Adapter(packageName) { decorView.performClick() }
|
||||||
|
viewPager.setPageTransformer(MarginPageTransformer(resources.sizeScaled(16)))
|
||||||
|
dialog.addContentView(viewPager, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT))
|
||||||
|
this.viewPager = viewPager
|
||||||
|
|
||||||
|
var restored = false
|
||||||
|
productDisposable = Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Products))
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
|
||||||
|
.map { Pair(it.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe {
|
||||||
|
val (product, repository) = it
|
||||||
|
val screenshots = product?.screenshots.orEmpty()
|
||||||
|
(viewPager.adapter as Adapter).update(repository, screenshots)
|
||||||
|
if (!restored) {
|
||||||
|
restored = true
|
||||||
|
val identifier = savedInstanceState?.getString(STATE_IDENTIFIER)
|
||||||
|
?: requireArguments().getString(STATE_IDENTIFIER)
|
||||||
|
if (identifier != null) {
|
||||||
|
val index = screenshots.indexOfFirst { it.identifier == identifier }
|
||||||
|
if (index >= 0) {
|
||||||
|
viewPager.setCurrentItem(index, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
viewPager = null
|
||||||
|
|
||||||
|
productDisposable?.dispose()
|
||||||
|
productDisposable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
val viewPager = viewPager
|
||||||
|
if (viewPager != null) {
|
||||||
|
val identifier = (viewPager.adapter as Adapter).getCurrentIdentifier(viewPager)
|
||||||
|
identifier?.let { outState.putString(STATE_IDENTIFIER, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Adapter(private val packageName: String, private val onClick: () -> Unit):
|
||||||
|
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
|
||||||
|
enum class ViewType { SCREENSHOT }
|
||||||
|
|
||||||
|
private class ViewHolder(context: Context): RecyclerView.ViewHolder(AppCompatImageView(context)) {
|
||||||
|
val image: ImageView
|
||||||
|
get() = itemView as ImageView
|
||||||
|
|
||||||
|
val placeholder: Drawable
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||||
|
RecyclerView.LayoutParams.MATCH_PARENT)
|
||||||
|
|
||||||
|
val placeholder = ContextCompat.getDrawable(itemView.context, R.drawable.ic_photo_camera)!!.mutate()
|
||||||
|
placeholder.setTint(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
|
||||||
|
.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.25f) })
|
||||||
|
this.placeholder = PaddingDrawable(placeholder, 4f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var repository: Repository? = null
|
||||||
|
private var screenshots = emptyList<Product.Screenshot>()
|
||||||
|
|
||||||
|
fun update(repository: Repository?, screenshots: List<Product.Screenshot>) {
|
||||||
|
this.repository = repository
|
||||||
|
this.screenshots = screenshots
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentIdentifier(viewPager: ViewPager2): String? {
|
||||||
|
val position = viewPager.currentItem
|
||||||
|
return screenshots.getOrNull(position)?.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
override val viewTypeClass: Class<ViewType>
|
||||||
|
get() = ViewType::class.java
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = screenshots.size
|
||||||
|
override fun getItemDescriptor(position: Int): String = screenshots[position].identifier
|
||||||
|
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SCREENSHOT
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
|
||||||
|
return ViewHolder(parent.context).apply {
|
||||||
|
itemView.setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
holder as ViewHolder
|
||||||
|
val screenshot = screenshots[position]
|
||||||
|
holder.image.load(CoilDownloader.createScreenshotUri(repository!!, packageName, screenshot)) {
|
||||||
|
placeholder(holder.placeholder)
|
||||||
|
error(holder.placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.screen
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.widget.AppCompatTextView
|
||||||
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentStatePagerAdapter
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager.widget.ViewPager
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.service.Connection
|
||||||
|
import nya.kitsunyan.foxydroid.service.SyncService
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class TabsFragment: Fragment() {
|
||||||
|
companion object {
|
||||||
|
private const val STATE_SEARCH_QUERY = "searchQuery"
|
||||||
|
private const val STATE_SHOW_CATEGORIES = "showCategories"
|
||||||
|
private const val STATE_CATEGORIES = "categories"
|
||||||
|
private const val STATE_CATEGORY = "category"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tabLayout: TabLayout? = null
|
||||||
|
private var categoryName: TextView? = null
|
||||||
|
private var categoryIcon: ImageView? = null
|
||||||
|
private var categoriesList: RecyclerView? = null
|
||||||
|
private var viewPager: ViewPager? = null
|
||||||
|
|
||||||
|
private var showCategories = false
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
tabLayout?.let { (0 until it.tabCount)
|
||||||
|
.forEach { index -> it.getTabAt(index)!!.view.isEnabled = !value } }
|
||||||
|
categoryIcon?.scaleY = if (value) -1f else 1f
|
||||||
|
if ((categoriesList?.parent as? View)?.height ?: 0 > 0) {
|
||||||
|
animateCategoriesList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchQuery = ""
|
||||||
|
private var categories = emptyList<String>()
|
||||||
|
private var category = ""
|
||||||
|
|
||||||
|
private val syncConnection = Connection<SyncService.Binder>(SyncService::class.java, onBind = {
|
||||||
|
viewPager?.let {
|
||||||
|
val source = ProductsFragment.Source.values()[it.currentItem]
|
||||||
|
updateUpdateNotificationBlocker(source)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
private var categoriesDisposable: Disposable? = null
|
||||||
|
private var categoriesAnimator: ValueAnimator? = null
|
||||||
|
|
||||||
|
private var needSelectUpdates = false
|
||||||
|
|
||||||
|
private val productFragments: Sequence<ProductsFragment>
|
||||||
|
get() = if (host == null) emptySequence() else
|
||||||
|
childFragmentManager.fragments.asSequence().mapNotNull { it as? ProductsFragment }
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
return inflater.inflate(R.layout.fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
syncConnection.bind(requireContext())
|
||||||
|
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
screenActivity.onFragmentViewCreated(toolbar)
|
||||||
|
toolbar.setTitle(R.string.app_name)
|
||||||
|
|
||||||
|
val searchView = SearchView(toolbar.context)
|
||||||
|
searchView.maxWidth = Int.MAX_VALUE
|
||||||
|
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
|
||||||
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
|
searchView.clearFocus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQueryTextChange(newText: String?): Boolean {
|
||||||
|
if (isResumed) {
|
||||||
|
searchQuery = newText.orEmpty()
|
||||||
|
productFragments.forEach { it.setSearchQuery(newText.orEmpty()) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
toolbar.menu.apply {
|
||||||
|
add(0, R.id.toolbar_search, 0, R.string.search)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_search))
|
||||||
|
.setActionView(searchView)
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
|
||||||
|
|
||||||
|
add(R.string.sync_repositories)
|
||||||
|
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_sync))
|
||||||
|
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
add(R.string.repositories)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
view.post { screenActivity.navigateRepositories() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
add(R.string.preferences)
|
||||||
|
.setOnMenuItemClickListener {
|
||||||
|
view.post { screenActivity.navigatePreferences() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty()
|
||||||
|
productFragments.forEach { it.setSearchQuery(searchQuery) }
|
||||||
|
|
||||||
|
val toolbarExtra = view.findViewById<FrameLayout>(R.id.toolbar_extra)
|
||||||
|
toolbarExtra.addView(toolbarExtra.inflate(R.layout.tabs_toolbar))
|
||||||
|
|
||||||
|
val tabLayout = view.findViewById<TabLayout>(R.id.tabs)
|
||||||
|
this.tabLayout = tabLayout
|
||||||
|
ProductsFragment.Source.values().forEach { tabLayout.addTab(tabLayout.newTab().setText(it.titleResId)) }
|
||||||
|
tabLayout.addOnTabSelectedListener(object: TabLayout.OnTabSelectedListener {
|
||||||
|
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||||
|
viewPager!!.currentItem = tab.position
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTabUnselected(tab: TabLayout.Tab) = Unit
|
||||||
|
override fun onTabReselected(tab: TabLayout.Tab) = Unit
|
||||||
|
})
|
||||||
|
|
||||||
|
val categoryLayout = view.findViewById<ViewGroup>(R.id.category_layout)
|
||||||
|
val categoryChange = view.findViewById<View>(R.id.category_change)
|
||||||
|
val categoryName = view.findViewById<TextView>(R.id.category_name)
|
||||||
|
val categoryIcon = view.findViewById<ImageView>(R.id.category_icon)
|
||||||
|
this.categoryName = categoryName
|
||||||
|
this.categoryIcon = categoryIcon
|
||||||
|
showCategories = savedInstanceState?.getByte(STATE_SHOW_CATEGORIES)?.toInt() ?: 0 != 0
|
||||||
|
categories = savedInstanceState?.getStringArrayList(STATE_CATEGORIES).orEmpty()
|
||||||
|
category = savedInstanceState?.getString(STATE_CATEGORY).orEmpty()
|
||||||
|
categoryChange.setOnClickListener { showCategories = categories.isNotEmpty() && !showCategories }
|
||||||
|
|
||||||
|
val content = view.findViewById<FrameLayout>(R.id.fragment_content)
|
||||||
|
|
||||||
|
viewPager = ViewPager(content.context).apply {
|
||||||
|
id = R.id.fragment_pager
|
||||||
|
adapter = object: FragmentStatePagerAdapter(childFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||||
|
override fun getItem(position: Int): Fragment = ProductsFragment(ProductsFragment.Source.values()[position])
|
||||||
|
override fun getCount(): Int = ProductsFragment.Source.values().size
|
||||||
|
}
|
||||||
|
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||||
|
addOnPageChangeListener(object: ViewPager.OnPageChangeListener {
|
||||||
|
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
|
||||||
|
val fromCategories = ProductsFragment.Source.values()[position].categories
|
||||||
|
val toCategories = if (positionOffset <= 0f) fromCategories else
|
||||||
|
ProductsFragment.Source.values()[position + 1].categories
|
||||||
|
val offset = if (fromCategories != toCategories) {
|
||||||
|
if (fromCategories) 1f - positionOffset else positionOffset
|
||||||
|
} else {
|
||||||
|
if (fromCategories) 1f else 0f
|
||||||
|
}
|
||||||
|
if (categoryLayout.childCount != 1) {
|
||||||
|
throw RuntimeException()
|
||||||
|
}
|
||||||
|
val child = categoryLayout.getChildAt(0)
|
||||||
|
val height = child.layoutParams.height
|
||||||
|
if (height <= 0) {
|
||||||
|
throw RuntimeException()
|
||||||
|
}
|
||||||
|
val currentHeight = (offset * height).roundToInt()
|
||||||
|
if (categoryLayout.layoutParams.height != currentHeight) {
|
||||||
|
categoryLayout.layoutParams.height = currentHeight
|
||||||
|
categoryLayout.requestLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageSelected(position: Int) {
|
||||||
|
val source = ProductsFragment.Source.values()[position]
|
||||||
|
updateUpdateNotificationBlocker(source)
|
||||||
|
tabLayout.selectTab(tabLayout.getTabAt(source.ordinal))
|
||||||
|
if (showCategories && !source.categories) {
|
||||||
|
showCategories = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageScrollStateChanged(state: Int) {
|
||||||
|
categoryChange.isEnabled = state != ViewPager.SCROLL_STATE_DRAGGING &&
|
||||||
|
ProductsFragment.Source.values()[this@apply.currentItem].categories
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
categoriesDisposable = Observable.just(Unit)
|
||||||
|
.concatWith(Database.observable(Database.Subject.Products))
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMapSingle { RxUtils.querySingle { Database.CategoryAdapter.getAll(it) } }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe {
|
||||||
|
val categories = it.sorted()
|
||||||
|
if (this.categories != categories) {
|
||||||
|
this.categories = categories
|
||||||
|
updateCategory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCategory()
|
||||||
|
|
||||||
|
val categoriesList = RecyclerView(toolbar.context).apply {
|
||||||
|
id = R.id.categories_list
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
isMotionEventSplittingEnabled = false
|
||||||
|
isVerticalScrollBarEnabled = false
|
||||||
|
setHasFixedSize(true)
|
||||||
|
adapter = object: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
override fun getItemCount(): Int = categories.size + 1
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
return object: RecyclerView.ViewHolder(AppCompatTextView(parent.context).apply {
|
||||||
|
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||||
|
resources.sizeScaled(48))
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(resources.sizeScaled(16), 0, resources.sizeScaled(16), 0)
|
||||||
|
setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
background = context.getDrawableFromAttr(R.attr.selectableItemBackground)
|
||||||
|
setTextSizeScaled(16)
|
||||||
|
}) {
|
||||||
|
init {
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
if (showCategories) {
|
||||||
|
showCategories = false
|
||||||
|
category = if (adapterPosition == 0) "" else categories[adapterPosition - 1]
|
||||||
|
updateCategory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
(holder.itemView as TextView).text = if (position == 0)
|
||||||
|
getString(R.string.all_applications_category) else categories[position - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBackgroundColor(context.getColorFromAttr(R.attr.colorPrimaryDark).defaultColor)
|
||||||
|
elevation = resources.sizeScaled(4).toFloat()
|
||||||
|
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, 0)
|
||||||
|
visibility = View.GONE
|
||||||
|
}
|
||||||
|
this.categoriesList = categoriesList
|
||||||
|
|
||||||
|
var lastContentHeight = -1
|
||||||
|
content.viewTreeObserver.addOnGlobalLayoutListener {
|
||||||
|
if (this.view != null) {
|
||||||
|
val initial = lastContentHeight <= 0
|
||||||
|
val contentHeight = content.height
|
||||||
|
if (lastContentHeight != contentHeight) {
|
||||||
|
lastContentHeight = contentHeight
|
||||||
|
if (initial) {
|
||||||
|
categoriesList.layoutParams.height = if (showCategories) contentHeight else 0
|
||||||
|
categoriesList.visibility = if (showCategories) View.VISIBLE else View.GONE
|
||||||
|
categoriesList.requestLayout()
|
||||||
|
} else {
|
||||||
|
animateCategoriesList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
|
||||||
|
tabLayout = null
|
||||||
|
categoryName = null
|
||||||
|
categoryIcon = null
|
||||||
|
categoriesList = null
|
||||||
|
viewPager = null
|
||||||
|
|
||||||
|
syncConnection.unbind(requireContext())
|
||||||
|
categoriesDisposable?.dispose()
|
||||||
|
categoriesDisposable = null
|
||||||
|
categoriesAnimator?.cancel()
|
||||||
|
categoriesAnimator = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
outState.putString(STATE_SEARCH_QUERY, searchQuery)
|
||||||
|
outState.putByte(STATE_SHOW_CATEGORIES, if (showCategories) 1 else 0)
|
||||||
|
outState.putStringArrayList(STATE_CATEGORIES, ArrayList(categories))
|
||||||
|
outState.putString(STATE_CATEGORY, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||||
|
super.onViewStateRestored(savedInstanceState)
|
||||||
|
|
||||||
|
if (needSelectUpdates) {
|
||||||
|
needSelectUpdates = false
|
||||||
|
selectUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachFragment(childFragment: Fragment) {
|
||||||
|
super.onAttachFragment(childFragment)
|
||||||
|
|
||||||
|
if (view != null && childFragment is ProductsFragment) {
|
||||||
|
childFragment.setSearchQuery(searchQuery)
|
||||||
|
childFragment.setCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun selectUpdates() {
|
||||||
|
if (view == null) {
|
||||||
|
needSelectUpdates = true
|
||||||
|
} else {
|
||||||
|
tabLayout?.getTabAt(ProductsFragment.Source.UPDATES.ordinal)!!.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUpdateNotificationBlocker(activeSource: ProductsFragment.Source) {
|
||||||
|
val blockerFragment = if (activeSource == ProductsFragment.Source.UPDATES) {
|
||||||
|
productFragments.find { it.source == activeSource }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCategory() {
|
||||||
|
val categories = categories
|
||||||
|
val category = category
|
||||||
|
val categoryName = categoryName!!
|
||||||
|
val index = categories.indexOf(category)
|
||||||
|
if (index < 0) {
|
||||||
|
categoryName.setText(R.string.all_applications_category)
|
||||||
|
if (category.isNotEmpty()) {
|
||||||
|
this.category = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
categoryName.text = category
|
||||||
|
}
|
||||||
|
categoryIcon?.visibility = if (categories.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
productFragments.forEach { it.setCategory(this.category) }
|
||||||
|
categoriesList?.adapter?.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateCategoriesList() {
|
||||||
|
val categoriesList = categoriesList!!
|
||||||
|
val value = if (categoriesList.visibility != View.VISIBLE) 0f else
|
||||||
|
categoriesList.height.toFloat() / (categoriesList.parent as View).height
|
||||||
|
val target = if (showCategories) 1f else 0f
|
||||||
|
categoriesAnimator?.cancel()
|
||||||
|
categoriesAnimator = null
|
||||||
|
|
||||||
|
if (value != target) {
|
||||||
|
categoriesAnimator = ValueAnimator.ofFloat(value, target).apply {
|
||||||
|
duration = (250 * abs(target - value)).toLong()
|
||||||
|
interpolator = if (target >= 1f) AccelerateInterpolator(2f) else DecelerateInterpolator(2f)
|
||||||
|
addUpdateListener {
|
||||||
|
val newValue = animatedValue as Float
|
||||||
|
categoriesList.apply {
|
||||||
|
val height = ((parent as View).height * newValue).toInt()
|
||||||
|
val visible = height > 0
|
||||||
|
if ((visibility == View.VISIBLE) != visible) {
|
||||||
|
visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
if (layoutParams.height != height) {
|
||||||
|
layoutParams.height = height
|
||||||
|
requestLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target <= 0f && newValue <= 0f || target >= 1f && newValue >= 1f) {
|
||||||
|
categoriesAnimator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
|
||||||
|
class Connection<T: IBinder>(private val serviceClass: Class<out Service>,
|
||||||
|
private val onBind: ((Event<T>) -> Unit)? = null,
|
||||||
|
private val onUnbind: ((Event<T>) -> Unit)? = null): ServiceConnection {
|
||||||
|
class Event<T: IBinder>(val connection: Connection<T>, val binder: T)
|
||||||
|
|
||||||
|
var binder: T? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
binder as T
|
||||||
|
this.binder = binder
|
||||||
|
onBind?.invoke(Event(this, binder))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(componentName: ComponentName) {
|
||||||
|
binder?.let {
|
||||||
|
binder = null
|
||||||
|
onUnbind?.invoke(Event(this, it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(context: Context) {
|
||||||
|
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind(context: Context) {
|
||||||
|
context.unbindService(this)
|
||||||
|
binder?.let {
|
||||||
|
binder = null
|
||||||
|
onUnbind?.invoke(Event(this, it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.service
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import nya.kitsunyan.foxydroid.BuildConfig
|
||||||
|
import nya.kitsunyan.foxydroid.Common
|
||||||
|
import nya.kitsunyan.foxydroid.MainActivity
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.content.Cache
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Release
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.network.Downloader
|
||||||
|
import nya.kitsunyan.foxydroid.utility.Utils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class DownloadService: Service() {
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_OPEN = "${BuildConfig.APPLICATION_ID}.intent.action.OPEN"
|
||||||
|
private const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
|
||||||
|
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||||
|
private const val EXTRA_CACHE_FILE_NAME = "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
|
||||||
|
|
||||||
|
private val downloadingSubject = PublishSubject.create<State.Downloading>()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Receiver: BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val action = intent.action.orEmpty()
|
||||||
|
when {
|
||||||
|
action.startsWith("$ACTION_OPEN.") -> {
|
||||||
|
val packageName = action.substring(ACTION_OPEN.length + 1)
|
||||||
|
context.startActivity(Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(Intent.ACTION_VIEW).setData(Uri.parse("package:$packageName"))
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
action.startsWith("$ACTION_INSTALL.") -> {
|
||||||
|
val packageName = action.substring(ACTION_INSTALL.length + 1)
|
||||||
|
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
|
||||||
|
context.startActivity(Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(MainActivity.ACTION_INSTALL).setData(Uri.parse("package:$packageName"))
|
||||||
|
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class State(val packageName: String, val name: String) {
|
||||||
|
class Pending(packageName: String, name: String): State(packageName, name)
|
||||||
|
class Connecting(packageName: String, name: String): State(packageName, name)
|
||||||
|
class Downloading(packageName: String, name: String, val read: Long, val total: Long?): State(packageName, name)
|
||||||
|
class Success(packageName: String, name: String, val release: Release,
|
||||||
|
val consume: () -> Unit): State(packageName, name)
|
||||||
|
class Error(packageName: String, name: String): State(packageName, name)
|
||||||
|
class Cancel(packageName: String, name: String): State(packageName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stateSubject = PublishSubject.create<State>()
|
||||||
|
|
||||||
|
private class Task(val packageName: String, val name: String, val release: Release,
|
||||||
|
val url: String, val authentication: String) {
|
||||||
|
val notificationTag: String
|
||||||
|
get() = "download-$packageName"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CurrentTask(val task: Task, val disposable: Disposable, val lastState: State)
|
||||||
|
|
||||||
|
private var started = false
|
||||||
|
private val tasks = mutableListOf<Task>()
|
||||||
|
private var currentTask: CurrentTask? = null
|
||||||
|
|
||||||
|
inner class Binder: android.os.Binder() {
|
||||||
|
fun events(packageName: String): Observable<State> {
|
||||||
|
return stateSubject.filter { it.packageName == packageName }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueue(packageName: String, name: String, repository: Repository, release: Release) {
|
||||||
|
val task = Task(packageName, name, release, release.getDownloadUrl(repository), repository.authentication)
|
||||||
|
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
|
||||||
|
publishSuccess(task)
|
||||||
|
} else {
|
||||||
|
cancelTasks(packageName)
|
||||||
|
cancelCurrentTask(packageName)
|
||||||
|
notificationManager.cancel(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING)
|
||||||
|
tasks += task
|
||||||
|
if (currentTask == null) {
|
||||||
|
handleDownload()
|
||||||
|
} else {
|
||||||
|
stateSubject.onNext(State.Pending(packageName, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(packageName: String) {
|
||||||
|
cancelTasks(packageName)
|
||||||
|
cancelCurrentTask(packageName)
|
||||||
|
handleDownload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getState(packageName: String): State? = currentTask
|
||||||
|
?.let { if (it.task.packageName == packageName) it.lastState else null }
|
||||||
|
?: tasks.find { it.packageName == packageName }?.let { State.Pending(it.packageName, it.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = Binder()
|
||||||
|
override fun onBind(intent: Intent): Binder = binder
|
||||||
|
|
||||||
|
private var downloadingDisposable: Disposable? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
if (Android.sdk(26)) {
|
||||||
|
NotificationChannel(Common.NOTIFICATION_CHANNEL_DOWNLOADING,
|
||||||
|
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
.apply { setShowBadge(false) }
|
||||||
|
.let(notificationManager::createNotificationChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadingDisposable = downloadingSubject
|
||||||
|
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { publishForegroundState(false, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
|
||||||
|
downloadingDisposable?.dispose()
|
||||||
|
downloadingDisposable = null
|
||||||
|
cancelTasks(null)
|
||||||
|
cancelCurrentTask(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_CANCEL) {
|
||||||
|
currentTask?.let { binder.cancel(it.task.packageName) }
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTasks(packageName: String?) {
|
||||||
|
tasks.removeAll {
|
||||||
|
(packageName == null || it.packageName == packageName) && run {
|
||||||
|
stateSubject.onNext(State.Cancel(it.packageName, it.name))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelCurrentTask(packageName: String?) {
|
||||||
|
currentTask?.let {
|
||||||
|
if (packageName == null || it.task.packageName == packageName) {
|
||||||
|
currentTask = null
|
||||||
|
stateSubject.onNext(State.Cancel(it.task.packageName, it.task.name))
|
||||||
|
it.disposable.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class ValidationError { HASH, STRUCTURE, INTEGRITY, SIGNATURE, PERMISSIONS }
|
||||||
|
|
||||||
|
private sealed class ErrorType {
|
||||||
|
object Network: ErrorType()
|
||||||
|
object Http: ErrorType()
|
||||||
|
class Validation(val validateError: ValidationError): ErrorType()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotificationError(task: Task, errorType: ErrorType) {
|
||||||
|
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
|
||||||
|
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||||
|
.getColorFromAttr(R.attr.colorAccent).defaultColor)
|
||||||
|
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java)
|
||||||
|
.setAction("$ACTION_OPEN.${task.packageName}"), PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
|
.apply {
|
||||||
|
when (errorType) {
|
||||||
|
is ErrorType.Network -> {
|
||||||
|
setContentTitle(getString(R.string.error_downloading_format, task.name))
|
||||||
|
setContentText(getString(R.string.network_error_description))
|
||||||
|
}
|
||||||
|
is ErrorType.Http -> {
|
||||||
|
setContentTitle(getString(R.string.error_downloading_format, task.name))
|
||||||
|
setContentText(getString(R.string.http_error_description))
|
||||||
|
}
|
||||||
|
is ErrorType.Validation -> {
|
||||||
|
setContentTitle(getString(R.string.error_validating_format, task.name))
|
||||||
|
val resId = R.string.validation_package_error_description_format
|
||||||
|
setContentText(getString(resId, getString(when (errorType.validateError) {
|
||||||
|
ValidationError.HASH -> R.string.validation_error_hash_lower
|
||||||
|
ValidationError.STRUCTURE -> R.string.validation_error_structure_lower
|
||||||
|
ValidationError.INTEGRITY -> R.string.validation_error_integrity_lower
|
||||||
|
ValidationError.SIGNATURE -> R.string.validation_error_signature_lower
|
||||||
|
ValidationError.PERMISSIONS -> R.string.validation_error_permissions_lower
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotificationInstall(task: Task) {
|
||||||
|
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
|
||||||
|
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||||
|
.getColorFromAttr(R.attr.colorAccent).defaultColor)
|
||||||
|
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java)
|
||||||
|
.setAction("$ACTION_INSTALL.${task.packageName}")
|
||||||
|
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName), PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
|
.setContentTitle(getString(R.string.finished_downloading_format, task.name))
|
||||||
|
.setContentText(getString(R.string.tap_to_install_description))
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishSuccess(task: Task) {
|
||||||
|
var consumed = false
|
||||||
|
stateSubject.onNext(State.Success(task.packageName, task.name, task.release) { consumed = true })
|
||||||
|
if (!consumed) {
|
||||||
|
showNotificationInstall(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validatePackage(task: Task, file: File): ValidationError? {
|
||||||
|
val hash = try {
|
||||||
|
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
|
||||||
|
val digest = MessageDigest.getInstance(hashType)
|
||||||
|
file.inputStream().use {
|
||||||
|
val bytes = ByteArray(8 * 1024)
|
||||||
|
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) }
|
||||||
|
digest.digest().hex()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
return if (hash.isEmpty() || hash != task.release.hash) {
|
||||||
|
ValidationError.HASH
|
||||||
|
} else {
|
||||||
|
val packageInfo = try {
|
||||||
|
packageManager.getPackageArchiveInfo(file.path, Android.PackageManager.signaturesFlag)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (packageInfo == null) {
|
||||||
|
ValidationError.STRUCTURE
|
||||||
|
} else if (packageInfo.packageName != task.packageName ||
|
||||||
|
packageInfo.versionCodeCompat != task.release.versionCode) {
|
||||||
|
ValidationError.INTEGRITY
|
||||||
|
} else {
|
||||||
|
val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty()
|
||||||
|
if (signature.isEmpty() || signature != task.release.signature) {
|
||||||
|
ValidationError.SIGNATURE
|
||||||
|
} else {
|
||||||
|
val permissions = packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet()
|
||||||
|
if (!task.release.permissions.containsAll(permissions)) {
|
||||||
|
ValidationError.PERMISSIONS
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stateNotificationBuilder by lazy { NotificationCompat
|
||||||
|
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||||
|
.getColorFromAttr(R.attr.colorAccent).defaultColor)
|
||||||
|
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0,
|
||||||
|
Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)) }
|
||||||
|
|
||||||
|
private fun publishForegroundState(force: Boolean, state: State) {
|
||||||
|
if (force || currentTask != null) {
|
||||||
|
currentTask = currentTask?.copy(lastState = state)
|
||||||
|
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
|
||||||
|
when (state) {
|
||||||
|
is State.Connecting -> {
|
||||||
|
setContentTitle(getString(R.string.downloading_format, state.name))
|
||||||
|
setContentText(getString(R.string.connecting))
|
||||||
|
setProgress(1, 0, true)
|
||||||
|
}
|
||||||
|
is State.Downloading -> {
|
||||||
|
setContentTitle(getString(R.string.downloading_format, state.name))
|
||||||
|
if (state.total != null) {
|
||||||
|
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
|
||||||
|
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
|
||||||
|
} else {
|
||||||
|
setContentText(state.read.formatSize())
|
||||||
|
setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is State.Pending, is State.Success, is State.Error, is State.Cancel -> {
|
||||||
|
throw IllegalStateException()
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}.build())
|
||||||
|
stateSubject.onNext(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownload() {
|
||||||
|
if (currentTask == null) {
|
||||||
|
if (tasks.isNotEmpty()) {
|
||||||
|
val task = tasks.removeAt(0)
|
||||||
|
if (!started) {
|
||||||
|
started = true
|
||||||
|
startAnyService(Intent(this, this::class.java))
|
||||||
|
}
|
||||||
|
val initialState = State.Connecting(task.packageName, task.name)
|
||||||
|
stateNotificationBuilder.setWhen(System.currentTimeMillis())
|
||||||
|
publishForegroundState(true, initialState)
|
||||||
|
val partialReleaseFile = Cache.getPartialReleaseFile(this, task.release.cacheFileName)
|
||||||
|
lateinit var disposable: Disposable
|
||||||
|
disposable = Downloader
|
||||||
|
.download(task.url, partialReleaseFile, "", "", task.authentication) { read, total ->
|
||||||
|
if (!disposable.isDisposed) {
|
||||||
|
downloadingSubject.onNext(State.Downloading(task.packageName, task.name, read, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { result, throwable ->
|
||||||
|
currentTask = null
|
||||||
|
throwable?.printStackTrace()
|
||||||
|
if (result == null || !result.success) {
|
||||||
|
showNotificationError(task, if (result != null) ErrorType.Http else ErrorType.Network)
|
||||||
|
stateSubject.onNext(State.Error(task.packageName, task.name))
|
||||||
|
} else {
|
||||||
|
val validationError = validatePackage(task, partialReleaseFile)
|
||||||
|
if (validationError == null) {
|
||||||
|
val releaseFile = Cache.getReleaseFile(this, task.release.cacheFileName)
|
||||||
|
partialReleaseFile.renameTo(releaseFile)
|
||||||
|
publishSuccess(task)
|
||||||
|
} else {
|
||||||
|
partialReleaseFile.delete()
|
||||||
|
showNotificationError(task, ErrorType.Validation(validationError))
|
||||||
|
stateSubject.onNext(State.Error(task.packageName, task.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleDownload()
|
||||||
|
}
|
||||||
|
currentTask = CurrentTask(task, disposable, initialState)
|
||||||
|
} else if (started) {
|
||||||
|
started = false
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.service
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.app.job.JobParameters
|
||||||
|
import android.app.job.JobService
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import nya.kitsunyan.foxydroid.BuildConfig
|
||||||
|
import nya.kitsunyan.foxydroid.Common
|
||||||
|
import nya.kitsunyan.foxydroid.MainActivity
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.content.Preferences
|
||||||
|
import nya.kitsunyan.foxydroid.database.Database
|
||||||
|
import nya.kitsunyan.foxydroid.entity.ProductItem
|
||||||
|
import nya.kitsunyan.foxydroid.entity.Repository
|
||||||
|
import nya.kitsunyan.foxydroid.index.RepositoryUpdater
|
||||||
|
import nya.kitsunyan.foxydroid.utility.RxUtils
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class SyncService: Service() {
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||||
|
|
||||||
|
private val stateSubject = PublishSubject.create<State>()
|
||||||
|
private val finishSubject = PublishSubject.create<Unit>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class State {
|
||||||
|
data class Connecting(val name: String): State()
|
||||||
|
data class Syncing(val name: String, val stage: RepositoryUpdater.Stage,
|
||||||
|
val read: Long, val total: Long?): State()
|
||||||
|
object Finishing: State()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Task(val repositoryId: Long, val manual: Boolean)
|
||||||
|
private data class CurrentTask(val task: Task?, val disposable: Disposable,
|
||||||
|
val hasUpdates: Boolean, val lastState: State)
|
||||||
|
private enum class Started { NO, AUTO, MANUAL }
|
||||||
|
|
||||||
|
private var started = Started.NO
|
||||||
|
private val tasks = mutableListOf<Task>()
|
||||||
|
private var currentTask: CurrentTask? = null
|
||||||
|
|
||||||
|
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
|
||||||
|
|
||||||
|
enum class SyncRequest { AUTO, MANUAL, FORCE }
|
||||||
|
|
||||||
|
inner class Binder: android.os.Binder() {
|
||||||
|
val finish: Observable<Unit>
|
||||||
|
get() = finishSubject
|
||||||
|
|
||||||
|
private fun sync(ids: List<Long>, request: SyncRequest) {
|
||||||
|
val cancelledTask = cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
|
||||||
|
cancelTasks { !it.manual && it.repositoryId in ids }
|
||||||
|
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
|
||||||
|
val manual = request != SyncRequest.AUTO
|
||||||
|
tasks += ids.asSequence().filter { it !in currentIds &&
|
||||||
|
it != currentTask?.task?.repositoryId }.map { Task(it, manual) }
|
||||||
|
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||||
|
if (request != SyncRequest.AUTO && started == Started.AUTO) {
|
||||||
|
started = Started.MANUAL
|
||||||
|
handleSetStarted()
|
||||||
|
currentTask?.lastState?.let { publishForegroundState(true, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(request: SyncRequest) {
|
||||||
|
val ids = Database.RepositoryAdapter.getAll(null)
|
||||||
|
.asSequence().filter { it.enabled }.map { it.id }.toList()
|
||||||
|
sync(ids, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(repository: Repository) {
|
||||||
|
if (repository.enabled) {
|
||||||
|
sync(listOf(repository.id), SyncRequest.FORCE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAuto(): Boolean {
|
||||||
|
val removed = cancelTasks { !it.manual }
|
||||||
|
val currentTask = cancelCurrentTask { it.task?.manual == false }
|
||||||
|
handleNextTask(currentTask?.hasUpdates == true)
|
||||||
|
return removed || currentTask != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUpdateNotificationBlocker(fragment: Fragment?) {
|
||||||
|
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
|
||||||
|
if (fragment != null) {
|
||||||
|
notificationManager.cancel(Common.NOTIFICATION_ID_UPDATES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
|
||||||
|
Database.RepositoryAdapter.put(repository.enable(enabled))
|
||||||
|
if (enabled) {
|
||||||
|
if (repository.id != currentTask?.task?.repositoryId && !tasks.any { it.repositoryId == repository.id }) {
|
||||||
|
tasks += Task(repository.id, true)
|
||||||
|
handleNextTask(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelTasks { it.repositoryId == repository.id }
|
||||||
|
val cancelledTask = cancelCurrentTask { it.task?.repositoryId == repository.id }
|
||||||
|
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCurrentlySyncing(repositoryId: Long): Boolean {
|
||||||
|
return currentTask?.task?.repositoryId == repositoryId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteRepository(repositoryId: Long): Boolean {
|
||||||
|
val repository = Database.RepositoryAdapter.get(repositoryId)
|
||||||
|
return repository != null && run {
|
||||||
|
setEnabled(repository, false)
|
||||||
|
Database.RepositoryAdapter.markAsDeleted(repository.id)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = Binder()
|
||||||
|
override fun onBind(intent: Intent): Binder = binder
|
||||||
|
|
||||||
|
private var stateDisposable: Disposable? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
if (Android.sdk(26)) {
|
||||||
|
NotificationChannel(Common.NOTIFICATION_CHANNEL_SYNCING,
|
||||||
|
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
.apply { setShowBadge(false) }
|
||||||
|
.let(notificationManager::createNotificationChannel)
|
||||||
|
NotificationChannel(Common.NOTIFICATION_CHANNEL_UPDATES,
|
||||||
|
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
.let(notificationManager::createNotificationChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateDisposable = stateSubject
|
||||||
|
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { publishForegroundState(false, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
|
||||||
|
stateDisposable?.dispose()
|
||||||
|
stateDisposable = null
|
||||||
|
cancelTasks { true }
|
||||||
|
cancelCurrentTask { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_CANCEL) {
|
||||||
|
tasks.clear()
|
||||||
|
val cancelledTask = cancelCurrentTask { it.task != null }
|
||||||
|
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
|
||||||
|
return tasks.removeAll(condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
|
||||||
|
return currentTask?.let {
|
||||||
|
if (condition(it)) {
|
||||||
|
currentTask = null
|
||||||
|
it.disposable.dispose()
|
||||||
|
RepositoryUpdater.await()
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotificationError(repository: Repository, exception: Exception) {
|
||||||
|
notificationManager.notify("repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
|
||||||
|
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||||
|
.getColorFromAttr(R.attr.colorAccent).defaultColor)
|
||||||
|
.setContentTitle(getString(R.string.error_syncing_format, repository.name))
|
||||||
|
.setContentText(getString(when (exception) {
|
||||||
|
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
|
||||||
|
RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_description
|
||||||
|
RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_description
|
||||||
|
RepositoryUpdater.ErrorType.VALIDATION -> R.string.validation_index_error_description
|
||||||
|
RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_description
|
||||||
|
}
|
||||||
|
else -> R.string.unknown_error_description
|
||||||
|
}))
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stateNotificationBuilder by lazy { NotificationCompat
|
||||||
|
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
|
||||||
|
.setSmallIcon(R.drawable.ic_sync)
|
||||||
|
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||||
|
.getColorFromAttr(R.attr.colorAccent).defaultColor)
|
||||||
|
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0,
|
||||||
|
Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)) }
|
||||||
|
|
||||||
|
private fun publishForegroundState(force: Boolean, state: State) {
|
||||||
|
if (force || currentTask?.lastState != state) {
|
||||||
|
currentTask = currentTask?.copy(lastState = state)
|
||||||
|
if (started == Started.MANUAL) {
|
||||||
|
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
|
||||||
|
when (state) {
|
||||||
|
is State.Connecting -> {
|
||||||
|
setContentTitle(getString(R.string.syncing_format, state.name))
|
||||||
|
setContentText(getString(R.string.connecting))
|
||||||
|
setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
is State.Syncing -> {
|
||||||
|
setContentTitle(getString(R.string.syncing_format, state.name))
|
||||||
|
when (state.stage) {
|
||||||
|
RepositoryUpdater.Stage.DOWNLOAD -> {
|
||||||
|
if (state.total != null) {
|
||||||
|
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
|
||||||
|
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
|
||||||
|
} else {
|
||||||
|
setContentText(state.read.formatSize())
|
||||||
|
setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RepositoryUpdater.Stage.PROCESS -> {
|
||||||
|
val progress = state.total?.let { 100f * state.read / it }?.roundToInt()
|
||||||
|
setContentText(getString(R.string.processing_format, "${progress ?: 0}%"))
|
||||||
|
setProgress(100, progress ?: 0, progress == null)
|
||||||
|
}
|
||||||
|
RepositoryUpdater.Stage.MERGE -> {
|
||||||
|
val progress = (100f * state.read / (state.total ?: state.read)).roundToInt()
|
||||||
|
setContentText(getString(R.string.merging_format, "${state.read} / ${state.total ?: state.read}"))
|
||||||
|
setProgress(100, progress, false)
|
||||||
|
}
|
||||||
|
RepositoryUpdater.Stage.COMMIT -> {
|
||||||
|
setContentText(getString(R.string.saving_details))
|
||||||
|
setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is State.Finishing -> {
|
||||||
|
setContentTitle(getString(R.string.syncing))
|
||||||
|
setContentText(null)
|
||||||
|
setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
}::class
|
||||||
|
}.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSetStarted() {
|
||||||
|
startAnyService(Intent(this, this::class.java))
|
||||||
|
stateNotificationBuilder.setWhen(System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNextTask(hasUpdates: Boolean) {
|
||||||
|
if (currentTask == null) {
|
||||||
|
if (tasks.isNotEmpty()) {
|
||||||
|
val task = tasks.removeAt(0)
|
||||||
|
val repository = Database.RepositoryAdapter.get(task.repositoryId)
|
||||||
|
if (repository != null && repository.enabled) {
|
||||||
|
val lastStarted = started
|
||||||
|
val newStarted = if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO
|
||||||
|
started = newStarted
|
||||||
|
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
|
||||||
|
handleSetStarted()
|
||||||
|
}
|
||||||
|
val initialState = State.Connecting(repository.name)
|
||||||
|
publishForegroundState(true, initialState)
|
||||||
|
val unstable = Preferences[Preferences.Key.UpdateUnstable]
|
||||||
|
lateinit var disposable: Disposable
|
||||||
|
disposable = RepositoryUpdater
|
||||||
|
.update(repository, unstable) { stage, progress, total ->
|
||||||
|
if (!disposable.isDisposed) {
|
||||||
|
stateSubject.onNext(State.Syncing(repository.name, stage, progress, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { result, throwable ->
|
||||||
|
currentTask = null
|
||||||
|
throwable?.printStackTrace()
|
||||||
|
if (throwable != null && task.manual) {
|
||||||
|
showNotificationError(repository, throwable as Exception)
|
||||||
|
}
|
||||||
|
handleNextTask(result == true || hasUpdates)
|
||||||
|
}
|
||||||
|
currentTask = CurrentTask(task, disposable, hasUpdates, initialState)
|
||||||
|
} else {
|
||||||
|
handleNextTask(hasUpdates)
|
||||||
|
}
|
||||||
|
} else if (started != Started.NO) {
|
||||||
|
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
|
||||||
|
val disposable = RxUtils
|
||||||
|
.querySingle { Database.ProductAdapter
|
||||||
|
.query(true, true, "", "", it)
|
||||||
|
.use { it.asSequence().map(Database.ProductAdapter::transformItem).toList() } }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { result, throwable ->
|
||||||
|
throwable?.printStackTrace()
|
||||||
|
currentTask = null
|
||||||
|
handleNextTask(false)
|
||||||
|
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
|
||||||
|
if (!blocked && result != null && result.isNotEmpty()) {
|
||||||
|
displayUpdatesNotification(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentTask = CurrentTask(null, disposable, true, State.Finishing)
|
||||||
|
} else {
|
||||||
|
finishSubject.onNext(Unit)
|
||||||
|
val needStop = started == Started.MANUAL
|
||||||
|
started = Started.NO
|
||||||
|
if (needStop) {
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
|
||||||
|
val maxUpdates = 5
|
||||||
|
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
|
||||||
|
notificationManager.notify(Common.NOTIFICATION_ID_UPDATES, NotificationCompat
|
||||||
|
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
|
||||||
|
.setSmallIcon(R.drawable.ic_new_releases)
|
||||||
|
.setContentTitle(getString(R.string.new_updates_available))
|
||||||
|
.setContentText(resources.getQuantityString(R.plurals.new_updates_description_format,
|
||||||
|
productItems.size, productItems.size))
|
||||||
|
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
|
||||||
|
.getColorFromAttr(R.attr.colorAccent).defaultColor)
|
||||||
|
.setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java)
|
||||||
|
.setAction(MainActivity.ACTION_UPDATES), PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
|
.setStyle(NotificationCompat.InboxStyle().applyHack {
|
||||||
|
for (productItem in productItems.take(maxUpdates)) {
|
||||||
|
val builder = SpannableStringBuilder(productItem.name)
|
||||||
|
builder.setSpan(ForegroundColorSpan(Color.BLACK), 0, builder.length,
|
||||||
|
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
builder.append(' ').append(productItem.version)
|
||||||
|
addLine(builder)
|
||||||
|
}
|
||||||
|
if (productItems.size > maxUpdates) {
|
||||||
|
val summary = getString(R.string.plus_more_format, productItems.size - maxUpdates)
|
||||||
|
if (Android.sdk(24)) {
|
||||||
|
addLine(summary)
|
||||||
|
} else {
|
||||||
|
setSummaryText(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
class Job: JobService() {
|
||||||
|
private var syncParams: JobParameters? = null
|
||||||
|
private var syncDisposable: Disposable? = null
|
||||||
|
private val syncConnection = Connection<Binder>(SyncService::class.java, onBind = {
|
||||||
|
syncDisposable = it.binder.finish.subscribe { _ ->
|
||||||
|
val params = syncParams
|
||||||
|
if (params != null) {
|
||||||
|
syncParams = null
|
||||||
|
syncDisposable?.dispose()
|
||||||
|
syncDisposable = null
|
||||||
|
it.connection.unbind(this)
|
||||||
|
jobFinished(params, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it.binder.sync(SyncRequest.AUTO)
|
||||||
|
}, onUnbind = {
|
||||||
|
syncDisposable?.dispose()
|
||||||
|
syncDisposable = null
|
||||||
|
it.binder.cancelAuto()
|
||||||
|
val params = syncParams
|
||||||
|
if (params != null) {
|
||||||
|
syncParams = null
|
||||||
|
jobFinished(params, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
override fun onStartJob(params: JobParameters): Boolean {
|
||||||
|
syncParams = params
|
||||||
|
syncConnection.bind(this)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopJob(params: JobParameters): Boolean {
|
||||||
|
syncParams = null
|
||||||
|
syncDisposable?.dispose()
|
||||||
|
syncDisposable = null
|
||||||
|
val reschedule = syncConnection.binder?.cancelAuto() == true
|
||||||
|
syncConnection.unbind(this)
|
||||||
|
return reschedule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.utility
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
|
||||||
|
interface KParcelable: Parcelable {
|
||||||
|
override fun describeContents(): Int = 0
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) = Unit
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline fun <reified T> creator(crossinline create: (source: Parcel) -> T): Parcelable.Creator<T> {
|
||||||
|
return object: Parcelable.Creator<T> {
|
||||||
|
override fun createFromParcel(source: Parcel): T = create(source)
|
||||||
|
override fun newArray(size: Int): Array<T?> = arrayOfNulls(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.utility
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageItemInfo
|
||||||
|
import android.content.pm.PermissionInfo
|
||||||
|
import android.content.res.Resources
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object PackageItemResolver {
|
||||||
|
class LocalCache {
|
||||||
|
internal val resources = mutableMapOf<String, Resources>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CacheKey(val locales: List<Locale>, val packageName: String, val resId: Int)
|
||||||
|
|
||||||
|
private val cache = mutableMapOf<CacheKey, String?>()
|
||||||
|
|
||||||
|
private fun load(context: Context, localCache: LocalCache, packageName: String,
|
||||||
|
nonLocalized: CharSequence?, resId: Int): CharSequence? {
|
||||||
|
return when {
|
||||||
|
nonLocalized != null -> {
|
||||||
|
nonLocalized
|
||||||
|
}
|
||||||
|
resId != 0 -> {
|
||||||
|
val locales = if (Android.sdk(24)) {
|
||||||
|
val localesList = context.resources.configuration.locales
|
||||||
|
(0 until localesList.size()).map(localesList::get)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
listOf(context.resources.configuration.locale)
|
||||||
|
}
|
||||||
|
val cacheKey = CacheKey(locales, packageName, resId)
|
||||||
|
if (cache.containsKey(cacheKey)) {
|
||||||
|
cache[cacheKey]
|
||||||
|
} else {
|
||||||
|
val resources = localCache.resources[packageName] ?: run {
|
||||||
|
val resources = try {
|
||||||
|
val resources = context.packageManager.getResourcesForApplication(packageName)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
resources.updateConfiguration(context.resources.configuration, null)
|
||||||
|
resources
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
resources?.let { localCache.resources[packageName] = it }
|
||||||
|
resources
|
||||||
|
}
|
||||||
|
val label = resources?.getString(resId)
|
||||||
|
cache[cacheKey] = label
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadLabel(context: Context, localCache: LocalCache, packageItemInfo: PackageItemInfo): CharSequence? {
|
||||||
|
return load(context, localCache, packageItemInfo.packageName,
|
||||||
|
packageItemInfo.nonLocalizedLabel, packageItemInfo.labelRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadDescription(context: Context, localCache: LocalCache, permissionInfo: PermissionInfo): CharSequence? {
|
||||||
|
return load(context, localCache, permissionInfo.packageName,
|
||||||
|
permissionInfo.nonLocalizedDescription, permissionInfo.descriptionRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPermissionGroup(permissionInfo: PermissionInfo): String? {
|
||||||
|
return if (Android.sdk(29)) {
|
||||||
|
// Copied from package installer (Utils.java)
|
||||||
|
when (permissionInfo.name) {
|
||||||
|
android.Manifest.permission.READ_CONTACTS,
|
||||||
|
android.Manifest.permission.WRITE_CONTACTS,
|
||||||
|
android.Manifest.permission.GET_ACCOUNTS ->
|
||||||
|
android.Manifest.permission_group.CONTACTS
|
||||||
|
android.Manifest.permission.READ_CALENDAR,
|
||||||
|
android.Manifest.permission.WRITE_CALENDAR ->
|
||||||
|
android.Manifest.permission_group.CALENDAR
|
||||||
|
android.Manifest.permission.SEND_SMS,
|
||||||
|
android.Manifest.permission.RECEIVE_SMS,
|
||||||
|
android.Manifest.permission.READ_SMS,
|
||||||
|
android.Manifest.permission.RECEIVE_MMS,
|
||||||
|
android.Manifest.permission.RECEIVE_WAP_PUSH,
|
||||||
|
"android.permission.READ_CELL_BROADCASTS" ->
|
||||||
|
android.Manifest.permission_group.SMS
|
||||||
|
android.Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||||
|
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||||
|
android.Manifest.permission.ACCESS_MEDIA_LOCATION ->
|
||||||
|
android.Manifest.permission_group.STORAGE
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION ->
|
||||||
|
android.Manifest.permission_group.LOCATION
|
||||||
|
android.Manifest.permission.READ_CALL_LOG,
|
||||||
|
android.Manifest.permission.WRITE_CALL_LOG,
|
||||||
|
@Suppress("DEPRECATION") android.Manifest.permission.PROCESS_OUTGOING_CALLS ->
|
||||||
|
android.Manifest.permission_group.CALL_LOG
|
||||||
|
android.Manifest.permission.READ_PHONE_STATE,
|
||||||
|
android.Manifest.permission.READ_PHONE_NUMBERS,
|
||||||
|
android.Manifest.permission.CALL_PHONE,
|
||||||
|
android.Manifest.permission.ADD_VOICEMAIL,
|
||||||
|
android.Manifest.permission.USE_SIP,
|
||||||
|
android.Manifest.permission.ANSWER_PHONE_CALLS,
|
||||||
|
android.Manifest.permission.ACCEPT_HANDOVER ->
|
||||||
|
android.Manifest.permission_group.PHONE
|
||||||
|
android.Manifest.permission.RECORD_AUDIO ->
|
||||||
|
android.Manifest.permission_group.MICROPHONE
|
||||||
|
android.Manifest.permission.ACTIVITY_RECOGNITION ->
|
||||||
|
android.Manifest.permission_group.ACTIVITY_RECOGNITION
|
||||||
|
android.Manifest.permission.CAMERA ->
|
||||||
|
android.Manifest.permission_group.CAMERA
|
||||||
|
android.Manifest.permission.BODY_SENSORS ->
|
||||||
|
android.Manifest.permission_group.SENSORS
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
permissionInfo.group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.utility
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class ProgressInputStream(private val inputStream: InputStream,
|
||||||
|
private val callback: (Long) -> Unit): InputStream() {
|
||||||
|
private var count = 0L
|
||||||
|
|
||||||
|
private inline fun <reified T: Number> notify(one: Boolean, read: () -> T): T {
|
||||||
|
val result = read()
|
||||||
|
count += if (one) 1L else result.toLong()
|
||||||
|
callback(count)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(): Int = notify(true) { inputStream.read() }
|
||||||
|
override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) }
|
||||||
|
override fun read(b: ByteArray, off: Int, len: Int): Int = notify(false) { inputStream.read(b, off, len) }
|
||||||
|
override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) }
|
||||||
|
|
||||||
|
override fun available(): Int {
|
||||||
|
return inputStream.available()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
inputStream.close()
|
||||||
|
super.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.utility
|
||||||
|
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.OperationCanceledException
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.exceptions.CompositeException
|
||||||
|
import io.reactivex.rxjava3.exceptions.Exceptions
|
||||||
|
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
object RxUtils {
|
||||||
|
private class ManagedDisposable(private val cancel: () -> Unit): Disposable {
|
||||||
|
@Volatile var disposed = false
|
||||||
|
override fun isDisposed(): Boolean = disposed
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
disposed = true
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T, R> managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single<R> {
|
||||||
|
return Single.create {
|
||||||
|
val task = create()
|
||||||
|
val thread = Thread.currentThread()
|
||||||
|
val disposable = ManagedDisposable {
|
||||||
|
thread.interrupt()
|
||||||
|
cancel(task)
|
||||||
|
}
|
||||||
|
it.setDisposable(disposable)
|
||||||
|
if (!disposable.isDisposed) {
|
||||||
|
val result = try {
|
||||||
|
execute(task)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Exceptions.throwIfFatal(e)
|
||||||
|
if (!disposable.isDisposed) {
|
||||||
|
try {
|
||||||
|
it.onError(e)
|
||||||
|
} catch (inner: Throwable) {
|
||||||
|
Exceptions.throwIfFatal(inner)
|
||||||
|
RxJavaPlugins.onError(CompositeException(e, inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (result != null && !disposable.isDisposed) {
|
||||||
|
it.onSuccess(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <R> managedSingle(execute: () -> R): Single<R> {
|
||||||
|
return managedSingle({ Unit }, { }, { execute() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun callSingle(create: () -> Call): Single<Response> {
|
||||||
|
return managedSingle(create, Call::cancel, Call::execute)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> querySingle(query: (CancellationSignal) -> T): Single<T> {
|
||||||
|
return Single.create {
|
||||||
|
val cancellationSignal = CancellationSignal()
|
||||||
|
it.setCancellable {
|
||||||
|
try {
|
||||||
|
cancellationSignal.cancel()
|
||||||
|
} catch (e: OperationCanceledException) {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = try {
|
||||||
|
query(cancellationSignal)
|
||||||
|
} catch (e: OperationCanceledException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (result != null) {
|
||||||
|
it.onSuccess(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.utility
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.Signature
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.LocaleList
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import nya.kitsunyan.foxydroid.BuildConfig
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.android.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.text.*
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.Certificate
|
||||||
|
import java.security.cert.CertificateEncodingException
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object Utils {
|
||||||
|
private fun createDefaultApplicationIcon(context: Context, tintAttrResId: Int): Drawable {
|
||||||
|
return ContextCompat.getDrawable(context, R.drawable.ic_application_default)!!.mutate()
|
||||||
|
.apply { setTintList(context.getColorFromAttr(tintAttrResId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultApplicationIcons(context: Context): Pair<Drawable, Drawable> {
|
||||||
|
val progressIcon: Drawable = createDefaultApplicationIcon(context, android.R.attr.textColorSecondary)
|
||||||
|
val defaultIcon: Drawable = createDefaultApplicationIcon(context, R.attr.colorAccent)
|
||||||
|
return Pair(progressIcon, defaultIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToolbarIcon(context: Context, resId: Int): Drawable {
|
||||||
|
val drawable = ContextCompat.getDrawable(context, resId)!!.mutate()
|
||||||
|
drawable.setTintList(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||||
|
return drawable
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateHash(signature: Signature): String? {
|
||||||
|
return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray()).hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateFingerprint(certificate: Certificate): String {
|
||||||
|
val encoded = try {
|
||||||
|
certificate.encoded
|
||||||
|
} catch (e: CertificateEncodingException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return encoded?.let(::calculateFingerprint).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateFingerprint(key: ByteArray): String {
|
||||||
|
return if (key.size >= 256) {
|
||||||
|
try {
|
||||||
|
val fingerprint = MessageDigest.getInstance("SHA-256").digest(key)
|
||||||
|
val builder = StringBuilder()
|
||||||
|
for (byte in fingerprint) {
|
||||||
|
builder.append("%02X".format(Locale.US, byte.toInt() and 0xff))
|
||||||
|
}
|
||||||
|
builder.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configureLocale(context: Context): Context {
|
||||||
|
val supportedLanguages = BuildConfig.LANGUAGES.toSet()
|
||||||
|
val configuration = context.resources.configuration
|
||||||
|
val currentLocales = if (Android.sdk(24)) {
|
||||||
|
val localesList = configuration.locales
|
||||||
|
(0 until localesList.size()).map(localesList::get)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
listOf(configuration.locale)
|
||||||
|
}
|
||||||
|
val compatibleLocales = currentLocales
|
||||||
|
.filter { it.language in supportedLanguages }
|
||||||
|
.let { if (it.isEmpty()) listOf(Locale.US) else it }
|
||||||
|
Locale.setDefault(compatibleLocales.first())
|
||||||
|
val newConfiguration = Configuration(configuration)
|
||||||
|
if (Android.sdk(24)) {
|
||||||
|
newConfiguration.setLocales(LocaleList(*compatibleLocales.toTypedArray()))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
newConfiguration.locale = compatibleLocales.first()
|
||||||
|
}
|
||||||
|
return context.createConfigurationContext(newConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
@file:Suppress("PackageDirectoryMismatch")
|
||||||
|
package nya.kitsunyan.foxydroid.utility.extension.android
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.Signature
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
fun Cursor.asSequence(): Sequence<Cursor> {
|
||||||
|
return generateSequence { if (moveToNext()) this else null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Cursor.firstOrNull(): Cursor? {
|
||||||
|
return if (moveToFirst()) this else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SQLiteDatabase.execWithResult(sql: String) {
|
||||||
|
rawQuery(sql, null).use { it.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
val Context.notificationManager: NotificationManager
|
||||||
|
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
fun Context.startAnyService(intent: Intent) {
|
||||||
|
if (Android.sdk(26)) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val PackageInfo.versionCodeCompat: Long
|
||||||
|
get() = if (Android.sdk(28)) longVersionCode else @Suppress("DEPRECATION") versionCode.toLong()
|
||||||
|
|
||||||
|
val PackageInfo.singleSignature: Signature?
|
||||||
|
get() {
|
||||||
|
return if (Android.sdk(28)) {
|
||||||
|
val signingInfo = signingInfo
|
||||||
|
if (signingInfo?.hasMultipleSigners() == false) signingInfo.apkContentsSigners
|
||||||
|
?.let { if (it.size == 1) it[0] else null } else null
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
signatures?.let { if (it.size == 1) it[0] else null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Android {
|
||||||
|
val sdk: Int
|
||||||
|
get() = Build.VERSION.SDK_INT
|
||||||
|
|
||||||
|
val name: String
|
||||||
|
get() = "Android ${Build.VERSION.RELEASE}"
|
||||||
|
|
||||||
|
val platforms = Build.SUPPORTED_ABIS.toSet()
|
||||||
|
|
||||||
|
val primaryPlatform: String?
|
||||||
|
get() = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull() ?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull()
|
||||||
|
|
||||||
|
fun sdk(sdk: Int): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= sdk
|
||||||
|
}
|
||||||
|
|
||||||
|
object PackageManager {
|
||||||
|
// GET_SIGNATURES should always present for getPackageArchiveInfo
|
||||||
|
val signaturesFlag: Int
|
||||||
|
get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0) or
|
||||||
|
@Suppress("DEPRECATION") android.content.pm.PackageManager.GET_SIGNATURES
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
@file:Suppress("PackageDirectoryMismatch")
|
||||||
|
package nya.kitsunyan.foxydroid.utility.extension.json
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonFactory
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator
|
||||||
|
import com.fasterxml.jackson.core.JsonParseException
|
||||||
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
|
import com.fasterxml.jackson.core.JsonToken
|
||||||
|
|
||||||
|
object Json {
|
||||||
|
val factory = JsonFactory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun JsonParser.illegal(): Nothing {
|
||||||
|
throw JsonParseException(this, "Illegal state")
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyToken {
|
||||||
|
val key: String
|
||||||
|
val token: JsonToken
|
||||||
|
|
||||||
|
fun number(key: String): Boolean = this.key == key && this.token.isNumeric
|
||||||
|
fun string(key: String): Boolean = this.key == key && this.token == JsonToken.VALUE_STRING
|
||||||
|
fun boolean(key: String): Boolean = this.key == key && this.token.isBoolean
|
||||||
|
fun dictionary(key: String): Boolean = this.key == key && this.token == JsonToken.START_OBJECT
|
||||||
|
fun array(key: String): Boolean = this.key == key && this.token == JsonToken.START_ARRAY
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) {
|
||||||
|
var passKey = ""
|
||||||
|
var passToken = JsonToken.NOT_AVAILABLE
|
||||||
|
val keyToken = object: KeyToken {
|
||||||
|
override val key: String
|
||||||
|
get() = passKey
|
||||||
|
override val token: JsonToken
|
||||||
|
get() = passToken
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
val token = nextToken()
|
||||||
|
if (token == JsonToken.FIELD_NAME) {
|
||||||
|
passKey = currentName
|
||||||
|
passToken = nextToken()
|
||||||
|
callback(keyToken)
|
||||||
|
} else if (token == JsonToken.END_OBJECT) {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
illegal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun JsonParser.forEach(requiredToken: JsonToken, callback: JsonParser.() -> Unit) {
|
||||||
|
while (true) {
|
||||||
|
val token = nextToken()
|
||||||
|
if (token == JsonToken.END_ARRAY) {
|
||||||
|
break
|
||||||
|
} else if (token == requiredToken) {
|
||||||
|
callback()
|
||||||
|
} else if (token.isStructStart) {
|
||||||
|
skipChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> JsonParser.collectNotNull(requiredToken: JsonToken, callback: JsonParser.() -> T?): List<T> {
|
||||||
|
val list = mutableListOf<T>()
|
||||||
|
forEach(requiredToken) {
|
||||||
|
val result = callback()
|
||||||
|
if (result != null) {
|
||||||
|
list += result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun JsonParser.collectNotNullStrings(): List<String> {
|
||||||
|
return collectNotNull(JsonToken.VALUE_STRING) { valueAsString }
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> JsonParser.parseDictionary(callback: JsonParser.() -> T): T {
|
||||||
|
if (nextToken() == JsonToken.START_OBJECT) {
|
||||||
|
val result = callback()
|
||||||
|
if (nextToken() != null) {
|
||||||
|
illegal()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
illegal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun JsonGenerator.writeDictionary(callback: JsonGenerator.() -> Unit) {
|
||||||
|
writeStartObject()
|
||||||
|
callback()
|
||||||
|
writeEndObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun JsonGenerator.writeArray(fieldName: String, callback: JsonGenerator.() -> Unit) {
|
||||||
|
writeArrayFieldStart(fieldName)
|
||||||
|
callback()
|
||||||
|
writeEndArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@file:Suppress("PackageDirectoryMismatch")
|
||||||
|
package nya.kitsunyan.foxydroid.utility.extension.resources
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
object TypefaceExtra {
|
||||||
|
val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!!
|
||||||
|
val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.getColorFromAttr(attrResId: Int): ColorStateList {
|
||||||
|
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
|
||||||
|
val (colorStateList, resId) = try {
|
||||||
|
Pair(typedArray.getColorStateList(0), typedArray.getResourceId(0, 0))
|
||||||
|
} finally {
|
||||||
|
typedArray.recycle()
|
||||||
|
}
|
||||||
|
return colorStateList ?: ContextCompat.getColorStateList(this, resId)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.getDrawableFromAttr(attrResId: Int): Drawable {
|
||||||
|
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
|
||||||
|
val resId = try {
|
||||||
|
typedArray.getResourceId(0, 0)
|
||||||
|
} finally {
|
||||||
|
typedArray.recycle()
|
||||||
|
}
|
||||||
|
return ContextCompat.getDrawable(this, resId)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Resources.sizeScaled(size: Int): Int {
|
||||||
|
return (size * displayMetrics.density).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TextView.setTextSizeScaled(size: Int) {
|
||||||
|
val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt()
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ViewGroup.inflate(layoutResId: Int): View {
|
||||||
|
return LayoutInflater.from(context).inflate(layoutResId, this, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@file:Suppress("PackageDirectoryMismatch")
|
||||||
|
package nya.kitsunyan.foxydroid.utility.extension.text
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
fun <T: CharSequence> T.nullIfEmpty(): T? {
|
||||||
|
return if (isNullOrEmpty()) null else this
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
|
||||||
|
|
||||||
|
fun Long.formatSize(): String {
|
||||||
|
val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) -> if (size >= 1000f)
|
||||||
|
Pair(size / 1000f, index + 1) else null }.take(sizeFormats.size).last()
|
||||||
|
return sizeFormats[index].format(Locale.US, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Char.halfByte(): Int {
|
||||||
|
return when (this) {
|
||||||
|
in '0' .. '9' -> this - '0'
|
||||||
|
in 'a' .. 'f' -> this - 'a' + 10
|
||||||
|
in 'A' .. 'F' -> this - 'A' + 10
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CharSequence.unhex(): ByteArray? {
|
||||||
|
return if (length % 2 == 0) {
|
||||||
|
val ints = windowed(2, 2, false).map {
|
||||||
|
val high = it[0].halfByte()
|
||||||
|
val low = it[1].halfByte()
|
||||||
|
if (high >= 0 && low >= 0) {
|
||||||
|
(high shl 4) or low
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ints.any { it < 0 }) null else ints.map { it.toByte() }.toByteArray()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.hex(): String {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
for (byte in this) {
|
||||||
|
builder.append("%02x".format(Locale.US, byte.toInt() and 0xff))
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Any.debug(message: String) {
|
||||||
|
val tag = this::class.java.name.let {
|
||||||
|
val index = it.lastIndexOf('.')
|
||||||
|
if (index >= 0) it.substring(index + 1) else it
|
||||||
|
}.replace('$', '.')
|
||||||
|
Log.d(tag, message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import android.text.Selection
|
||||||
|
import android.text.Spannable
|
||||||
|
import android.text.method.MovementMethod
|
||||||
|
import android.text.style.ClickableSpan
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
object ClickableMovementMethod: MovementMethod {
|
||||||
|
override fun initialize(widget: TextView, text: Spannable) {
|
||||||
|
Selection.removeSelection(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean {
|
||||||
|
val action = event.action
|
||||||
|
val down = action == MotionEvent.ACTION_DOWN
|
||||||
|
val up = action == MotionEvent.ACTION_UP
|
||||||
|
return (down || up) && run {
|
||||||
|
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
|
||||||
|
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
|
||||||
|
val layout = widget.layout
|
||||||
|
val line = layout.getLineForVertical(y)
|
||||||
|
val offset = layout.getOffsetForHorizontal(line, x.toFloat())
|
||||||
|
val span = text.getSpans(offset, offset, ClickableSpan::class.java)?.firstOrNull()
|
||||||
|
if (span != null) {
|
||||||
|
if (down) {
|
||||||
|
Selection.setSelection(text, text.getSpanStart(span), text.getSpanEnd(span))
|
||||||
|
} else {
|
||||||
|
span.onClick(widget)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Selection.removeSelection(text)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyDown(widget: TextView, text: Spannable, keyCode: Int, event: KeyEvent): Boolean = false
|
||||||
|
override fun onKeyUp(widget: TextView, text: Spannable, keyCode: Int, event: KeyEvent): Boolean = false
|
||||||
|
override fun onKeyOther(view: TextView, text: Spannable, event: KeyEvent): Boolean = false
|
||||||
|
override fun onTakeFocus(widget: TextView, text: Spannable, direction: Int) = Unit
|
||||||
|
override fun onTrackballEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean = false
|
||||||
|
override fun onGenericMotionEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean = false
|
||||||
|
override fun canSelectArbitrarily(): Boolean = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
abstract class CursorRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() {
|
||||||
|
init {
|
||||||
|
super.setHasStableIds(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rowIdIndex = 0
|
||||||
|
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field?.close()
|
||||||
|
field = value
|
||||||
|
rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun setHasStableIds(hasStableIds: Boolean) {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = cursor?.count ?: 0
|
||||||
|
override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex)
|
||||||
|
|
||||||
|
fun moveTo(position: Int): Cursor {
|
||||||
|
val cursor = cursor!!
|
||||||
|
cursor.moveToPosition(position)
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import nya.kitsunyan.foxydroid.R
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class DividerItemDecoration(context: Context, private val configure: (context: Context,
|
||||||
|
position: Int, configuration: Configuration) -> Unit): RecyclerView.ItemDecoration() {
|
||||||
|
companion object {
|
||||||
|
fun fixed(start: Int, end: Int): (Context, Int, Configuration) -> Unit = { _, _, configuration ->
|
||||||
|
configuration.set(true, false, start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Configuration {
|
||||||
|
fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ConfigurationHolder: Configuration {
|
||||||
|
var needDivider = false
|
||||||
|
var toTop = false
|
||||||
|
var paddingStart = 0
|
||||||
|
var paddingEnd = 0
|
||||||
|
|
||||||
|
override fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) {
|
||||||
|
this.needDivider = needDivider
|
||||||
|
this.toTop = toTop
|
||||||
|
this.paddingStart = paddingStart
|
||||||
|
this.paddingEnd = paddingEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val View.configuration: ConfigurationHolder
|
||||||
|
get() = getTag(R.id.divider_configuration) as? ConfigurationHolder ?: run {
|
||||||
|
val configuration = ConfigurationHolder()
|
||||||
|
setTag(R.id.divider_configuration, configuration)
|
||||||
|
configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private val divider = context.getDrawableFromAttr(android.R.attr.listDivider)
|
||||||
|
private val bounds = Rect()
|
||||||
|
|
||||||
|
private fun draw(c: Canvas, configuration: ConfigurationHolder, view: View, top: Int, width: Int, rtl: Boolean) {
|
||||||
|
val divider = divider
|
||||||
|
val left = if (rtl) configuration.paddingEnd else configuration.paddingStart
|
||||||
|
val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd)
|
||||||
|
val translatedTop = top + view.translationY.roundToInt()
|
||||||
|
divider.alpha = (view.alpha * 0xff).toInt()
|
||||||
|
divider.setBounds(left, translatedTop, right, translatedTop + divider.intrinsicHeight)
|
||||||
|
divider.draw(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
val divider = divider
|
||||||
|
val bounds = bounds
|
||||||
|
val rtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||||
|
for (i in 0 until parent.childCount) {
|
||||||
|
val view = parent.getChildAt(i)
|
||||||
|
val configuration = view.configuration
|
||||||
|
if (configuration.needDivider) {
|
||||||
|
val position = parent.getChildAdapterPosition(view)
|
||||||
|
if (position == parent.adapter!!.itemCount - 1) {
|
||||||
|
parent.getDecoratedBoundsWithMargins(view, bounds)
|
||||||
|
draw(c, configuration, view, bounds.bottom, parent.width, rtl)
|
||||||
|
} else {
|
||||||
|
val toTopView = if (configuration.toTop && position >= 0)
|
||||||
|
parent.findViewHolderForAdapterPosition(position + 1)?.itemView else null
|
||||||
|
if (toTopView != null) {
|
||||||
|
parent.getDecoratedBoundsWithMargins(toTopView, bounds)
|
||||||
|
draw(c, configuration, toTopView, bounds.top - divider.intrinsicHeight, parent.width, rtl)
|
||||||
|
} else {
|
||||||
|
parent.getDecoratedBoundsWithMargins(view, bounds)
|
||||||
|
draw(c, configuration, view, bounds.bottom - divider.intrinsicHeight, parent.width, rtl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
val configuration = view.configuration
|
||||||
|
val position = parent.getChildAdapterPosition(view)
|
||||||
|
if (position >= 0) {
|
||||||
|
configure(view.context, position, configuration)
|
||||||
|
}
|
||||||
|
val needDivider = position < parent.adapter!!.itemCount - 1 && configuration.needDivider
|
||||||
|
outRect.set(0, 0, 0, if (needDivider) divider.intrinsicHeight else 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import android.util.SparseArray
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
abstract class EnumRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: RecyclerView.Adapter<VH>() {
|
||||||
|
abstract val viewTypeClass: Class<VT>
|
||||||
|
|
||||||
|
private val names = SparseArray<String>()
|
||||||
|
|
||||||
|
private fun getViewType(viewType: Int): VT {
|
||||||
|
return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType))
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getItemViewType(position: Int): Int {
|
||||||
|
val enum = getItemEnumViewType(position)
|
||||||
|
names.put(enum.ordinal, enum.name)
|
||||||
|
return enum.ordinal
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
return onCreateViewHolder(parent, getViewType(viewType))
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun getItemEnumViewType(position: Int): VT
|
||||||
|
abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
|
||||||
|
class FragmentLinearLayout: LinearLayout {
|
||||||
|
constructor(context: Context): super(context)
|
||||||
|
constructor(context: Context, attrs: AttributeSet?): super(context, attrs)
|
||||||
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr)
|
||||||
|
|
||||||
|
init {
|
||||||
|
fitsSystemWindows = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
var percentTranslationY: Float
|
||||||
|
get() = height.let { if (it > 0) translationY / it else 0f }
|
||||||
|
set(value) {
|
||||||
|
translationY = value * height
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import nya.kitsunyan.foxydroid.utility.extension.resources.*
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
class RecyclerFastScroller(private val recyclerView: RecyclerView) {
|
||||||
|
companion object {
|
||||||
|
private const val TRANSITION_IN = 100L
|
||||||
|
private const val TRANSITION_OUT = 200L
|
||||||
|
private const val TRANSITION_OUT_DELAY = 1000L
|
||||||
|
|
||||||
|
private val stateNormal = intArrayOf()
|
||||||
|
private val statePressed = intArrayOf(android.R.attr.state_pressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val thumbDrawable = recyclerView.context.getDrawableFromAttr(android.R.attr.fastScrollThumbDrawable)
|
||||||
|
private val trackDrawable = recyclerView.context.getDrawableFromAttr(android.R.attr.fastScrollTrackDrawable)
|
||||||
|
|
||||||
|
private data class FastScrolling(val startAtThumbOffset: Float?, val startY: Float, val currentY: Float)
|
||||||
|
|
||||||
|
private var scrolling = false
|
||||||
|
private var fastScrolling: FastScrolling? = null
|
||||||
|
private var display = Pair(0L, false)
|
||||||
|
|
||||||
|
private val invalidateTransition = Runnable(recyclerView::invalidate)
|
||||||
|
|
||||||
|
private fun updateState(scrolling: Boolean, fastScrolling: FastScrolling?) {
|
||||||
|
val oldDisplay = this.scrolling || this.fastScrolling != null
|
||||||
|
val newDisplay = scrolling || fastScrolling != null
|
||||||
|
this.scrolling = scrolling
|
||||||
|
this.fastScrolling = fastScrolling
|
||||||
|
if (oldDisplay != newDisplay) {
|
||||||
|
recyclerView.removeCallbacks(invalidateTransition)
|
||||||
|
val time = SystemClock.elapsedRealtime()
|
||||||
|
val passed = time - display.first
|
||||||
|
val start = if (newDisplay && passed < (TRANSITION_OUT + TRANSITION_OUT_DELAY)) {
|
||||||
|
if (passed <= TRANSITION_OUT_DELAY) {
|
||||||
|
0L
|
||||||
|
} else {
|
||||||
|
time - ((TRANSITION_OUT_DELAY + TRANSITION_OUT - passed).toFloat() /
|
||||||
|
TRANSITION_OUT * TRANSITION_IN).toLong()
|
||||||
|
}
|
||||||
|
} else if (!newDisplay && passed < TRANSITION_IN) {
|
||||||
|
time - ((TRANSITION_IN - passed).toFloat() / TRANSITION_IN *
|
||||||
|
TRANSITION_OUT).toLong() - TRANSITION_OUT_DELAY
|
||||||
|
} else {
|
||||||
|
if (!newDisplay) {
|
||||||
|
recyclerView.postDelayed(invalidateTransition, TRANSITION_OUT_DELAY)
|
||||||
|
}
|
||||||
|
time
|
||||||
|
}
|
||||||
|
display = Pair(start, newDisplay)
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scrollListener = object: RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||||
|
updateState(newState != RecyclerView.SCROLL_STATE_IDLE, fastScrolling)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
if (fastScrolling == null) {
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun withScroll(callback: (itemHeight: Int, thumbHeight: Int, range: Int) -> Unit): Boolean {
|
||||||
|
val count = recyclerView.adapter?.itemCount ?: 0
|
||||||
|
return count > 0 && run {
|
||||||
|
val itemHeight = Rect().apply { recyclerView
|
||||||
|
.getDecoratedBoundsWithMargins(recyclerView.getChildAt(0), this) }.height()
|
||||||
|
val scrollCount = count - recyclerView.height / itemHeight
|
||||||
|
scrollCount > 0 && run {
|
||||||
|
val range = count * itemHeight
|
||||||
|
val thumbHeight = max(recyclerView.height * recyclerView.height / range, thumbDrawable.intrinsicHeight)
|
||||||
|
range >= recyclerView.height * 2 && run {
|
||||||
|
callback(itemHeight, thumbHeight, range)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateOffset(thumbHeight: Int, fastScrolling: FastScrolling): Float {
|
||||||
|
return if (fastScrolling.startAtThumbOffset != null) {
|
||||||
|
(fastScrolling.startAtThumbOffset + (fastScrolling.currentY - fastScrolling.startY) /
|
||||||
|
(recyclerView.height - thumbHeight)).coerceIn(0f, 1f)
|
||||||
|
} else {
|
||||||
|
((fastScrolling.currentY - thumbHeight / 2f) / (recyclerView.height - thumbHeight)).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentOffset(itemHeight: Int, range: Int): Float {
|
||||||
|
val view = recyclerView.getChildAt(0)
|
||||||
|
val position = recyclerView.getChildAdapterPosition(view)
|
||||||
|
val positionOffset = -view.top
|
||||||
|
val scrollPosition = position * itemHeight + positionOffset
|
||||||
|
return scrollPosition.toFloat() / (range - recyclerView.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scroll(itemHeight: Int, thumbHeight: Int, range: Int, fastScrolling: FastScrolling) {
|
||||||
|
val offset = calculateOffset(thumbHeight, fastScrolling)
|
||||||
|
val scrollPosition = ((range - recyclerView.height) * offset).roundToInt()
|
||||||
|
val position = scrollPosition / itemHeight
|
||||||
|
val positionOffset = scrollPosition - position * itemHeight
|
||||||
|
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||||
|
layoutManager.scrollToPositionWithOffset(position, -positionOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val touchListener = object: RecyclerView.OnItemTouchListener {
|
||||||
|
private var disallowIntercept = false
|
||||||
|
|
||||||
|
private fun handleTouchEvent(event: MotionEvent, intercept: Boolean): Boolean {
|
||||||
|
val recyclerView = recyclerView
|
||||||
|
val lastFastScrolling = fastScrolling
|
||||||
|
return when {
|
||||||
|
intercept && disallowIntercept -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
event.action == MotionEvent.ACTION_DOWN -> {
|
||||||
|
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
|
||||||
|
val maxWidth = max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicWidth)
|
||||||
|
val atThumbVertical = if (rtl) event.x <= maxWidth else event.x >= recyclerView.width - maxWidth
|
||||||
|
atThumbVertical && run {
|
||||||
|
withScroll { itemHeight, thumbHeight, range ->
|
||||||
|
val offset = currentOffset(itemHeight, range)
|
||||||
|
val thumbY = ((recyclerView.height - thumbHeight) * offset).roundToInt()
|
||||||
|
val atThumb = event.y >= thumbY && event.y <= thumbY + thumbHeight
|
||||||
|
val fastScrolling = FastScrolling(if (atThumb) offset else null, event.y, event.y)
|
||||||
|
scroll(itemHeight, thumbHeight, range, fastScrolling)
|
||||||
|
updateState(scrolling, fastScrolling)
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> lastFastScrolling != null && run {
|
||||||
|
val success = withScroll { itemHeight, thumbHeight, range ->
|
||||||
|
val fastScrolling = lastFastScrolling.copy(currentY = event.y)
|
||||||
|
scroll(itemHeight, thumbHeight, range, fastScrolling)
|
||||||
|
updateState(scrolling, fastScrolling)
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
val cancel = event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL
|
||||||
|
if (!success || cancel) {
|
||||||
|
updateState(scrolling, null)
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
|
||||||
|
return handleTouchEvent(e, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
|
||||||
|
handleTouchEvent(e, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
|
||||||
|
this.disallowIntercept = disallowIntercept
|
||||||
|
if (disallowIntercept && fastScrolling != null) {
|
||||||
|
updateState(scrolling, null)
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDraw(canvas: Canvas) {
|
||||||
|
withScroll { itemHeight, thumbHeight, range ->
|
||||||
|
val display = display
|
||||||
|
val time = SystemClock.elapsedRealtime()
|
||||||
|
val passed = time - display.first
|
||||||
|
val shouldInvalidate = display.second && passed < TRANSITION_IN ||
|
||||||
|
!display.second && passed >= TRANSITION_OUT_DELAY && passed < TRANSITION_OUT_DELAY + TRANSITION_OUT
|
||||||
|
val stateValue = (if (display.second) {
|
||||||
|
passed.toFloat() / TRANSITION_IN
|
||||||
|
} else {
|
||||||
|
1f - (passed - TRANSITION_OUT_DELAY).toFloat() / TRANSITION_OUT
|
||||||
|
}).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
if (stateValue > 0f) {
|
||||||
|
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
|
||||||
|
val thumbDrawable = thumbDrawable
|
||||||
|
val trackDrawable = trackDrawable
|
||||||
|
val maxWidth = max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicHeight)
|
||||||
|
val translateX = (maxWidth * (1f - stateValue)).roundToInt()
|
||||||
|
val fastScrolling = fastScrolling
|
||||||
|
|
||||||
|
val scrollValue = (if (fastScrolling != null) {
|
||||||
|
calculateOffset(thumbHeight, fastScrolling)
|
||||||
|
} else {
|
||||||
|
currentOffset(itemHeight, range)
|
||||||
|
}).coerceIn(0f, 1f)
|
||||||
|
val thumbY = ((recyclerView.height - thumbHeight) * scrollValue).roundToInt()
|
||||||
|
|
||||||
|
trackDrawable.state = if (fastScrolling != null) statePressed else stateNormal
|
||||||
|
val trackExtra = (maxWidth - trackDrawable.intrinsicWidth) / 2
|
||||||
|
if (rtl) {
|
||||||
|
trackDrawable.setBounds(trackExtra - translateX, 0,
|
||||||
|
trackExtra + trackDrawable.intrinsicWidth - translateX, recyclerView.height)
|
||||||
|
} else {
|
||||||
|
trackDrawable.setBounds(recyclerView.width - trackExtra - trackDrawable.intrinsicWidth + translateX,
|
||||||
|
0, recyclerView.width - trackExtra + translateX, recyclerView.height)
|
||||||
|
}
|
||||||
|
trackDrawable.draw(canvas)
|
||||||
|
val thumbExtra = (maxWidth - thumbDrawable.intrinsicWidth) / 2
|
||||||
|
thumbDrawable.state = if (fastScrolling != null) statePressed else stateNormal
|
||||||
|
if (rtl) {
|
||||||
|
thumbDrawable.setBounds(thumbExtra - translateX, thumbY,
|
||||||
|
thumbExtra + thumbDrawable.intrinsicWidth - translateX, thumbY + thumbHeight)
|
||||||
|
} else {
|
||||||
|
thumbDrawable.setBounds(recyclerView.width - thumbExtra - thumbDrawable.intrinsicWidth + translateX,
|
||||||
|
thumbY, recyclerView.width - thumbExtra + translateX, thumbY + thumbHeight)
|
||||||
|
}
|
||||||
|
thumbDrawable.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldInvalidate) {
|
||||||
|
recyclerView.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
recyclerView.addOnScrollListener(scrollListener)
|
||||||
|
recyclerView.addOnItemTouchListener(touchListener)
|
||||||
|
recyclerView.addItemDecoration(object: RecyclerView.ItemDecoration() {
|
||||||
|
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) = handleDraw(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package nya.kitsunyan.foxydroid.widget
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
abstract class StableRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() {
|
||||||
|
private var nextId = 1L
|
||||||
|
private val descriptorToId = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
super.setHasStableIds(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun setHasStableIds(hasStableIds: Boolean) {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemId(position: Int): Long {
|
||||||
|
val descriptor = getItemDescriptor(position)
|
||||||
|
return descriptorToId[descriptor] ?: run {
|
||||||
|
val id = nextId++
|
||||||
|
descriptorToId[descriptor] = id
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun getItemDescriptor(position: Int): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<set
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<objectAnimator
|
||||||
|
android:propertyName="alpha"
|
||||||
|
android:valueFrom="0"
|
||||||
|
android:valueTo="1"
|
||||||
|
android:duration="100"
|
||||||
|
android:interpolator="@android:interpolator/decelerate_quad" />
|
||||||
|
|
||||||
|
<objectAnimator
|
||||||
|
android:propertyName="percentTranslationY"
|
||||||
|
android:valueFrom="0.08"
|
||||||
|
android:valueTo="0"
|
||||||
|
android:duration="175"
|
||||||
|
android:interpolator="@android:interpolator/decelerate_quad" />
|
||||||
|
|
||||||
|
</set>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<objectAnimator
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:duration="175" />
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<set
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<objectAnimator
|
||||||
|
android:propertyName="alpha"
|
||||||
|
android:valueFrom="1"
|
||||||
|
android:valueTo="0"
|
||||||
|
android:startOffset="50"
|
||||||
|
android:duration="75"
|
||||||
|
android:interpolator="@android:interpolator/linear" />
|
||||||
|
|
||||||
|
<objectAnimator
|
||||||
|
android:propertyName="percentTranslationY"
|
||||||
|
android:valueFrom="0"
|
||||||
|
android:valueTo="0.08"
|
||||||
|
android:duration="125"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_quad" />
|
||||||
|
|
||||||
|
</set>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<selector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:state_enabled="false"
|
||||||
|
android:color="@color/accent_default_dark"
|
||||||
|
android:alpha="0.3" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:color="@color/accent_default_dark" />
|
||||||
|
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<selector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:state_enabled="false"
|
||||||
|
android:color="@color/accent_default_light"
|
||||||
|
android:alpha="0.26" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:color="@color/accent_default_light" />
|
||||||
|
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<selector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:state_enabled="false"
|
||||||
|
android:color="@color/error_default_dark"
|
||||||
|
android:alpha="0.3" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:color="@color/error_default_dark" />
|
||||||
|
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<selector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:state_enabled="false"
|
||||||
|
android:color="@color/error_default_light"
|
||||||
|
android:alpha="0.26" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:color="@color/error_default_light" />
|
||||||
|
|
||||||
|
</selector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp"
|
||||||
|
android:height="96dp"
|
||||||
|
android:viewportWidth="96"
|
||||||
|
android:viewportHeight="96">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M48 4C23.699 4 4 23.699 4 48C4 72.301 23.699 92 48 92C72.301 92 92 72.301 92 48C92 23.699 72.301 4
|
||||||
|
48 4zM28.125 29.102C28.938 29.05 29.867 29.412 30.602 30.146L36.609 36.152A26.1 26.1 0 0 1 48 33.5A26.1 26.1 0 0 1
|
||||||
|
59.4 36.143L65.398 30.146C66.574 28.971 68.245 28.751 69.146 29.652C70.047 30.553 69.826 32.223 68.65 33.398L
|
||||||
|
63.449 38.602A26.1 26.1 0 0 1 74.1 59.6A26.1 26.1 0 0 1 74.092 59.801L21.91 59.801A26.1 26.1 0 0 1 21.9 59.6A26.1
|
||||||
|
26.1 0 0 1 32.551 38.602L27.35 33.398C26.174 32.223 25.953 30.553 26.854 29.652C27.191 29.314 27.637 29.133 28.125
|
||||||
|
29.102zM37.35 46.801A2.25 2.25 0 0 0 35.1 49.051A2.25 2.25 0 0 0 37.35 51.301A2.25 2.25 0 0 0 39.6 49.051A2.25
|
||||||
|
2.25 0 0 0 37.35 46.801zM58.65 46.801A2.25 2.25 0 0 0 56.4 49.051A2.25 2.25 0 0 0 58.65 51.301A2.25 2.25 0 0 0
|
||||||
|
60.9 49.051A2.25 2.25 0 0 0 58.65 46.801z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M12 17.005L3.996 9 5.41 7.586 12 14.176 18.59 7.586 20.005 9Z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0
|
||||||
|
-.96 .06-1.41 .17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05 .33-.09 .66-.09 1v1H4v2h2v1c0
|
||||||
|
.34 .04 .67 .09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33 .09-.66 .09-1v-1h2v-2h-2v
|
||||||
|
-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M10.08 10.86c.05-.33 .16-.62 .3-.87s.34-.46 .59-.62c.24-.15 .54-.22 .91-.23 .23 .01 .44 .05 .63
|
||||||
|
.13 .2 .09 .38 .21 .52 .36s.25 .33 .34 .53 .13 .42 .14 .64h1.79c-.02-.47-.11-.9-.28-1.29s-.4-.73-.7-1.01-.66-.5
|
||||||
|
-1.08-.66-.88-.23-1.39-.23c-.65 0-1.22 .11-1.7 .34s-.88 .53-1.2 .92-.56 .84-.71 1.36S8 11.29 8 11.87v.27c0 .58 .08
|
||||||
|
1.12 .23 1.64s.39 .97 .71 1.35 .72 .69 1.2 .91 1.05 .34 1.7 .34c.47 0 .91-.08 1.32-.23s.77-.36 1.08-.63 .56-.58
|
||||||
|
.74-.94 .29-.74 .3-1.15h-1.79c-.01 .21-.06 .4-.15 .58s-.21 .33-.36 .46-.32 .23-.52 .3c-.19 .07-.39 .09-.6 .1-.36
|
||||||
|
-.01-.66-.08-.89-.23-.25-.16-.45-.37-.59-.62s-.25-.55-.3-.88-.08-.67-.08-1v-.27c0-.35 .03-.68 .08-1.01zM12 2C6.48
|
||||||
|
2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8
|
||||||
|
-8 8z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M6 19c0 1.1 .9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M19.441 9.596C19.777 7.351 18.067 6.144 15.729 5.338L16.488 2.297 14.636 1.835 13.898 4.797C13.411
|
||||||
|
4.675 12.911 4.561 12.414 4.448L13.158 1.467 11.307 1.005 10.548 4.046C10.145 3.954 9.75 3.863 9.366 3.768l.002
|
||||||
|
-.009L6.814 3.121 6.321 5.098c0 0 1.374 .315 1.345 .334 .75 .187 .886 .683 .863 1.077L7.665 9.975c.052 .013 .119
|
||||||
|
.032 .193 .062-.062-.015-.128-.032-.196-.049L6.451 14.842c-.092 .228-.324 .57-.849 .44 .018 .027-1.346-.336-1.346
|
||||||
|
-.336l-.919 2.119 2.41 .601c.448 .112 .888 .23 1.32 .341l-.766 3.076 1.85 .461 .759-3.044c.505 .137 .996 .264
|
||||||
|
1.476 .383l-.756 3.03 1.852 .461 .766-3.071c3.158 .597 5.532 .356 6.531-2.499 .805-2.299-.04-3.625-1.701-4.49 1.21
|
||||||
|
-.279 2.121-1.075 2.364-2.718zm-4.231 5.932c-.572 2.299-4.444 1.056-5.699 .745L10.528 12.197c1.255 .313 5.28 .933
|
||||||
|
4.682 3.331zM15.783 9.563C15.261 11.654 12.038 10.592 10.993 10.331l.922-3.697c1.045 .261 4.412 .747
|
||||||
|
3.868 2.928z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M9.461 3C5.183 3 3 5.464 3 10.064v3.213 6.438L7.19 15.52v-4.902c0-1.906 .505-3.118 2.199-3.391
|
||||||
|
.592-.116 1.824-.075 2.607-.075v2.911c0 .027 .004 .074 .01 .098 .033 .118 .139 .204 .266 .204 .071 0 .138-.037
|
||||||
|
.207-.105l7.262-7.259-4.875-.001zM21 4.285L16.81 8.48v4.902c0 1.906-.505 3.118-2.199 3.391-.592 .116-1.824 .075
|
||||||
|
-2.607 .075v-2.911c0-.026-.004-.074-.01-.098-.033-.118-.139-.204-.266-.204-.071 0-.138 .037-.207 .105L4.258 20.999
|
||||||
|
9.133 21H14.539C18.817 21 21 18.536 21 13.936v-3.214z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M11.393 1.347L8.018 1.869 5.253 13.43c-.16 .682-.244 1.325-.251 1.927-.007 .604 .116 1.136 .37 1.6
|
||||||
|
.254 .465 .675 .831 1.262 1.1 .588 .268 1.397 .402 2.428 .402v-.002L9.716 15.781C9.339 15.752 9.045 15.687 8.834
|
||||||
|
15.585 8.624 15.484 8.475 15.35 8.388 15.183 8.301 15.016 8.261 14.823 8.269 14.605c.007-.217 .04-.457 .098-.718zm
|
||||||
|
5.201 5.184c-.871 0-1.68 .069-2.428 .207-.747 .138-1.412 .294-1.992 .468L8.559 22.27H11.782l.98-3.941c.493 .087
|
||||||
|
.987 .13 1.48 .13 1.015 0 1.956-.178 2.82-.534 .864-.355 1.603-.851 2.22-1.491 .617-.639 1.099-1.397 1.448-2.276
|
||||||
|
.348-.878 .522-1.846 .523-2.905 0 0 0-.001 0-.001 0 0 0-.001 0-.001C21.252 10.601 21.162 9.987 20.98 9.415 20.799
|
||||||
|
8.842 20.519 8.342 20.142 7.913 19.765 7.485 19.283 7.147 18.695 6.901 18.107 6.654 17.407 6.53 16.594 6.53Zm-.414
|
||||||
|
2.722c.682 0 1.161 .218 1.437 .653 .275 .436 .413 .966 .413 1.59 0 .639-.091 1.223-.272 1.752-.182 .53-.436 .984
|
||||||
|
-.762 1.361-.327 .378-.723 .671-1.187 .881-.465 .211-.98 .316-1.546 .316-.363 0-.667-.029-.914-.087l1.523-6.335c
|
||||||
|
.406-.087 .842-.131 1.307-.131z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="m11.065 16.274l1.039-3.913 2.46-.899 .612-2.3-.021-.057-2.422 .885 1.745-6.571H9.53L7.248 11.994
|
||||||
|
5.342 12.69 4.713 15.061 6.617 14.366 5.272 19.419H18.443l.844-3.145h-8.222" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39 .39-1.02 0-1.41l-2.34-2.34c-.39
|
||||||
|
-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M20 4H4c-1.1 0-1.99 .9-1.99 2L2 18c0 1.1 .9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5
|
||||||
|
V6l8 5 8-5v2z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89 .07 .14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93
|
||||||
|
0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54 .72-1.21
|
||||||
|
-3.5-2.08V8H12z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffffff"
|
||||||
|
android:pathData="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1 .89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83
|
||||||
|
9.83 1.41 1.41L19 6.41V10h2V3h-7z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user