Initall push of the 1.13.5 source

This commit is contained in:
Felisp 2024-05-12 20:31:16 +02:00
commit 797924598f
555 changed files with 69958 additions and 0 deletions

41
.build.yml Normal file
View file

@ -0,0 +1,41 @@
image: debian/stretch
packages:
- git
- openjdk-8-jdk-headless
- wget
- tar
- unzip
- lib32stdc++6
- lib32z1
- file
- mesa-utils
- pciutils
environment:
ANDROID_COMPILE_SDK: "28"
ANDROID_BUILD_TOOLS: "28.0.3"
ANDROID_EMULATOR_SDK: "28"
GRADLE_USER_HOME: "/home/build/.gradle"
ANDROID_HOME: "/home/build/.androidhome"
sources:
- https://git.sr.ht/~cowboyprogrammer/feeder
triggers:
- action: email
condition: failure
to: jonas.srht@cowboyprogrammer.org
secrets:
- d9eb6ad0-7288-447a-954b-74e22ef4d054
- c492e32e-551e-42e8-b8d5-c252fc20b625
- 8a654fa4-6c85-480f-abee-d3b50d92d5f7
tasks:
- setup: |
export PATH="${ANDROID_HOME}/emulator/:${ANDROID_HOME}/tools/bin/:${ANDROID_HOME}/tools/:${ANDROID_HOME}/platform-tools/:${PATH}"
env
cd feeder
echo 'org.gradle.jvmargs=-Xmx1g' >> gradle.properties
ci/before
- build: |
cd feeder
./gradlew build
- deploy: |
cd feeder
ci/deploy_playstore

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
*.iml
build
.gradle
.idea
local.properties
*.db
*.substvars
.pybuild
*.debhelper
captures
creds.json
report.xml
app/creds.b64
keystore.b64
devenv.local
keystore

57
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,57 @@
stages:
- build
- validate_deploy
- deploy
image: registry.gitlab.com/spacecowboy/feeder:builder
variables:
GIT_SUBMODULE_STRATEGY: recursive
cache:
key: "uber"
paths:
- .gradle/caches
- .gradle/wrapper
lint:
stage: build
script:
- source devenv && ./gradlew :app:lint
needs: []
test:
stage: build
script:
- source devenv && ./gradlew test :jsonfeed-parser:check
needs: []
compile:
stage: build
script:
- source devenv && ./gradlew assembleDebug packageDebugAndroidTest -PdisablePreDex
needs: []
artifacts:
paths:
- app/build/outputs/
- build/logs/
validate_deployment:
stage: validate_deploy
script:
- source devenv && ./deploy_playstore.sh --dry-run
needs: []
only:
- master
- tags
deploy_playstore:
stage: deploy
script:
- source devenv && ./deploy_playstore.sh
needs: ["validate_deployment", "compile", "lint", "test"]
only:
- tags
environment:
name: Play
url: https://play.google.com/store/apps/details?id=com.nononsenseapps.feeder.play

View file

@ -0,0 +1,14 @@
<!--
Please do NOT submit a bug report because you don't like the choice
of color in the app.
-->
## Description
<!-- Please describe what the problem is -->
## URL to affected feed
<!-- Please include a link to a feed where the bug manifests -->

View file

@ -0,0 +1,19 @@
<!--
Please do NOT open a feature request unless you are willing to do
some work to make it happen.
Feeder is Free Software - which means you have the power to change
it to make it better *for you*.
It is NOT a charity project where the author writes code because
you ask him to.
Essentially, if you open a feature request, I expect you to follow
it up with a patch to implement the feature.
-->
## Description
<!-- Explain what you think would make the app better -->

View file

@ -0,0 +1,19 @@
<!--
Please
* Set a proper description for your merge request and commit messages.
For example DO NOT put this in your commit / title
Update strings.xml
Instead DO THIS
Updated german translation
The reason is that what you write here will end up in the change log
Thanks!
-->

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "rome"]
path = rome
url = https://gitlab.com/spacecowboy/rome.git

1083
CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

674
LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
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 <http://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:
{project} Copyright (C) {year} {fullname}
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
<http://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
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

49
README.md Normal file
View file

@ -0,0 +1,49 @@
Feeder
=====
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y44OYQL)
<a href="https://f-droid.org/repository/browse/?fdid=com.nononsenseapps.feeder" target="_blank">
<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80"/></a>
<a href='https://play.google.com/store/apps/details?id=com.nononsenseapps.feeder.play'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' height="80"/></a>
<a href="https://hosted.weblate.org/engage/feeder/">
<img src="https://hosted.weblate.org/widgets/feeder/-/android-strings/svg-badge.svg" alt="Translation status" />
</a>
### License
**GPLv3**, for more info see *LICENSE*.
### Contributions are welcome!
If you want to translate Feeder into your native language the easiest way is to go to [Weblate](https://hosted.weblate.org/engage/feeder/) but making a merge request is of course fine if that is something you are comfortable with.
In case you want to contribute with fixing bugs or features - you probably know what you need to do. If not just ping me.
### Quick install
Clone the project:
git clone --recursive https://github.com/spacecowboy/Feeder.git
Then build and install the app to your phone which is connected via USB:
./gradlew installDebug
### More details
This is a no-nonsense RSS/Atom/JSON feed reader app for Android.
### Features
* Offline reading
* Notification support
* OPML Import/Export
* Material design
### Screenshots
<img src="https://gitlab.com/spacecowboy/Feeder/-/raw/9e9c46f55ac528bd619413c21891fda23d2d3ac6/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png" width=50%/><img src="https://gitlab.com/spacecowboy/Feeder/-/raw/9e9c46f55ac528bd619413c21891fda23d2d3ac6/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png" width=50%/>
<img src="https://gitlab.com/spacecowboy/Feeder/-/raw/9e9c46f55ac528bd619413c21891fda23d2d3ac6/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png" width=50%/><img src="https://gitlab.com/spacecowboy/Feeder/-/raw/9e9c46f55ac528bd619413c21891fda23d2d3ac6/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png" width=50%/>
<img src="https://gitlab.com/spacecowboy/Feeder/-/raw/9e9c46f55ac528bd619413c21891fda23d2d3ac6/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png" width=50%/><img src="https://gitlab.com/spacecowboy/Feeder/-/raw/9e9c46f55ac528bd619413c21891fda23d2d3ac6/fastlane/metadata/android/en-US/images/phoneScreenshots/6_en-US.png" width=50%/>

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

229
app/build.gradle Normal file
View file

@ -0,0 +1,229 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
buildToolsVersion "$build_tools_version"
lintOptions {
abortOnError true
explainIssues true
ignoreWarnings true
textReport true
textOutput 'stdout'
// Should try to remove last two here
disable "MissingTranslation", "AppCompatCustomView", "InvalidPackage"
// I really want some to show as errors
error "InlinedApi", "StringEscaping"
}
defaultConfig {
applicationId "com.nononsenseapps.feeder"
versionCode 106
versionName "1.13.5"
compileSdkVersion 30
minSdkVersion 23
targetSdkVersion 30
multiDexEnabled true
vectorDrawables.useSupportLibrary = true
// For espresso tests
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// Export Room schemas
javaCompileOptions {
annotationProcessorOptions {
arguments = [
"room.schemaLocation": "$projectDir/schemas".toString(),
"room.incremental" : "true"
]
}
}
}
sourceSets {
// To test Room we need to include the schema dir in resources
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
if (project.hasProperty('STORE_FILE')) {
signingConfigs {
release {
storeFile file(STORE_FILE)
storePassword STORE_PASSWORD
keyAlias KEY_ALIAS
keyPassword KEY_PASSWORD
}
}
}
buildTypes {
debug {
minifyEnabled false
shrinkResources false
applicationIdSuffix ".debug"
pseudoLocalesEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debugMini {
initWith debug
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
matchingFallbacks = ['debug']
}
release {
minifyEnabled false
shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (project.hasProperty('STORE_FILE')) {
signingConfig signingConfigs.release
}
}
play {
applicationIdSuffix ".play"
// If you re-enable this - fix the issues on Android 4.3
minifyEnabled false
shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (project.hasProperty('STORE_FILE')) {
signingConfig signingConfigs.release
}
}
}
testOptions {
unitTests.returnDefaultValues = true
}
kotlinOptions {
jvmTarget = "1.8"
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/ASL2.0'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LGPL2.1'
}
}
configurations.all {
resolutionStrategy {
// failOnVersionConflict()
force "com.squareup.okhttp3:okhttp:$okhttp_version"
force "com.squareup.okio:okio:$okio_version"
force "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
force "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
force "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
force "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
}
}
dependencies {
kapt "androidx.room:room-compiler:$room_version"
// BOMS
api(platform("com.squareup.okhttp3:okhttp-bom:$okhttp_version"))
// Needed pre SDK21
implementation "com.android.support:multidex:$multi_dex_version"
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.work:work-runtime-ktx:$workmanager_version"
implementation "androidx.core:core-ktx:$androidx_core_version"
implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version"
implementation "androidx.recyclerview:recyclerview:$recyclerview_version"
implementation "androidx.legacy:legacy-support-v4:$legacy_support_version"
implementation "androidx.appcompat:appcompat:$appcompat_version"
implementation "androidx.preference:preference:$preference_version"
implementation "com.google.android.material:material:$material_version"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
// Better times
implementation "com.jakewharton.threetenabp:threetenabp:$threetenabp_version"
// HTML parsing
implementation "org.jsoup:jsoup:$jsoup_version"
implementation "org.ccil.cowan.tagsoup:tagsoup:1.2.1"
// RSS
implementation "com.rometools:rome:$rome_version"
implementation "com.rometools:rome-modules:$rome_version"
// JSONFeed
implementation project(":jsonfeed-parser")
// For better fetching
implementation("com.squareup.okhttp3:okhttp:$okhttp_version")
// For supporting TLSv1.3 on pre Android-10
implementation "org.conscrypt:conscrypt-android:$conscrypt_version"
// Image loading
implementation("io.coil-kt:coil-base:1.1.1")
implementation("io.coil-kt:coil-gif:1.1.1")
implementation("io.coil-kt:coil-svg:1.1.1")
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
// For doing coroutines on UI thread
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// Dependency injection
implementation "org.kodein.di:kodein-di-generic-jvm:$kodein_version"
implementation "org.kodein.di:kodein-di-framework-android-x:$kodein_version"
// Custom tabs
implementation "com.android.support:customtabs:28.0.0"
// Full text
implementation "net.dankito.readability4j:readability4j:$readability4j_version"
// tests
testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation "junit:junit:4.12"
testImplementation "org.mockito:mockito-core:$mockito_version"
testImplementation "io.mockk:mockk:$mockk_version"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
// Needed for unit testing timezone stuff
testImplementation "org.threeten:threetenbp:$threetentest_version"
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation "io.mockk:mockk-android:1.8.10.kotlin13"
androidTestImplementation "junit:junit:4.12"
androidTestImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
androidTestImplementation "androidx.test:core:$androidx_core_version"
androidTestImplementation "androidx.test:runner:$test_runner_version"
androidTestImplementation "androidx.test:rules:$test_rules_version"
androidTestImplementation "androidx.test.ext:junit:$test_ext_junit_version"
androidTestImplementation "androidx.recyclerview:recyclerview:$recyclerview_version"
androidTestImplementation "androidx.legacy:legacy-support-v4:$legacy_support_version"
androidTestImplementation "androidx.appcompat:appcompat:$appcompat_version"
androidTestImplementation "com.google.android.material:material:$material_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "androidx.test.uiautomator:uiautomator:$uiautomator_version"
}

38
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,38 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /home/jonas/android-sdk-linux/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
-dontobfuscate
## The following is necessary to avoid R8, which is used by desugaring
## lib, from breaking even the debug build
# Keep kotlin.Metadata annotations to maintain metadata on kept items.
-keepattributes RuntimeVisibleAnnotations
-keep class kotlin.Metadata { *; }
# Everything in the app is essential
-keep class com.nononsenseapps.** { *; }
# For Okio
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# For Jsoup
-keep class org.jsoup.** { *; }
# For Rome
-keep class com.rometools.** { *; }

View file

@ -0,0 +1,223 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "3751990a008660981fd56c7aabd5e0a8",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseHash",
"columnName": "response_hash",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3751990a008660981fd56c7aabd5e0a8')"
]
}
}

View file

@ -0,0 +1,229 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "e65228e117d6d836cc934e7bd4bc2965",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseHash",
"columnName": "response_hash",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, `first_synced_time` INTEGER NOT NULL, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "firstSyncedTime",
"columnName": "first_synced_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e65228e117d6d836cc934e7bd4bc2965')"
]
}
}

View file

@ -0,0 +1,235 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "acf17b478a707d7bc42c9bfb01115c6e",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseHash",
"columnName": "response_hash",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, `first_synced_time` INTEGER NOT NULL, `primary_sort_time` INTEGER NOT NULL, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "firstSyncedTime",
"columnName": "first_synced_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primarySortTime",
"columnName": "primary_sort_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'acf17b478a707d7bc42c9bfb01115c6e')"
]
}
}

View file

@ -0,0 +1,241 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "113988d7df71524c1053ccc8283bea01",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL, `fulltext_by_default` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseHash",
"columnName": "response_hash",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fullTextByDefault",
"columnName": "fulltext_by_default",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, `first_synced_time` INTEGER NOT NULL, `primary_sort_time` INTEGER NOT NULL, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "firstSyncedTime",
"columnName": "first_synced_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primarySortTime",
"columnName": "primary_sort_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '113988d7df71524c1053ccc8283bea01')"
]
}
}

View file

@ -0,0 +1,247 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "b9ed9812b00c71906ab4a1b08a0f9eaa",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL, `fulltext_by_default` INTEGER NOT NULL, `open_articles_with` TEXT NOT NULL DEFAULT '')",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseHash",
"columnName": "response_hash",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fullTextByDefault",
"columnName": "fulltext_by_default",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "openArticlesWith",
"columnName": "open_articles_with",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, `first_synced_time` INTEGER NOT NULL, `primary_sort_time` INTEGER NOT NULL, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "firstSyncedTime",
"columnName": "first_synced_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "primarySortTime",
"columnName": "primary_sort_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b9ed9812b00c71906ab4a1b08a0f9eaa')"
]
}
}

View file

@ -0,0 +1,216 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "5c773fd70806bc703b78e14bfe756ac0",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"5c773fd70806bc703b78e14bfe756ac0\")"
]
}
}

View file

@ -0,0 +1,222 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "080c1a6ec37c16dfe668b173edda572b",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"080c1a6ec37c16dfe668b173edda572b\")"
]
}
}

View file

@ -0,0 +1,228 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "6a5fd4757cbb75d7e3ff6effc344326b",
"entities": [
{
"tableName": "feeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customTitle",
"columnName": "custom_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notify",
"columnName": "notify",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastSync",
"columnName": "last_sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "responseHash",
"columnName": "response_hash",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feeds_url",
"unique": true,
"columnNames": [
"url"
],
"createSql": "CREATE UNIQUE INDEX `index_feeds_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_feeds_id_url_title",
"unique": true,
"columnNames": [
"id",
"url",
"title"
],
"createSql": "CREATE UNIQUE INDEX `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainTitle",
"columnName": "plain_title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plainSnippet",
"columnName": "plain_snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "enclosureLink",
"columnName": "enclosure_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notified",
"columnName": "notified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_items_guid_feed_id",
"unique": true,
"columnNames": [
"guid",
"feed_id"
],
"createSql": "CREATE UNIQUE INDEX `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)"
},
{
"name": "index_feed_items_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"createSql": "CREATE INDEX `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "feeds",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"6a5fd4757cbb75d7e3ff6effc344326b\")"
]
}
}

View file

@ -0,0 +1,144 @@
package com.nononsenseapps.feeder.db.legacy
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.nononsenseapps.feeder.db.room.DATABASE_NAME
const val LEGACY_DATABASE_VERSION = 6
const val LEGACY_DATABASE_NAME = DATABASE_NAME
class LegacyDatabaseHandler constructor(
context: Context,
name: String = LEGACY_DATABASE_NAME,
version: Int = LEGACY_DATABASE_VERSION
) : SQLiteOpenHelper(context, name, null, version) {
override fun onCreate(db: SQLiteDatabase) {
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
override fun onOpen(db: SQLiteDatabase) {
super.onOpen(db)
if (!db.isReadOnly) {
// Enable foreign key constraints
db.setForeignKeyConstraintsEnabled(true)
}
}
}
fun createViewsAndTriggers(db: SQLiteDatabase) {
// Create triggers
db.execSQL(CREATE_TAG_TRIGGER)
// Create views if not exists
db.execSQL(CREATE_COUNT_VIEW)
db.execSQL(CREATE_TAGS_VIEW)
}
// SQL convention says Table name should be "singular"
const val FEED_TABLE_NAME = "Feed"
// SQL convention says Table name should be "singular"
const val FEED_ITEM_TABLE_NAME = "FeedItem"
// Naming the id column with an underscore is good to be consistent
// with other Android things. This is ALWAYS needed
const val COL_ID = "_id"
// These fields can be anything you want.
const val COL_TITLE = "title"
const val COL_CUSTOM_TITLE = "customtitle"
const val COL_URL = "url"
const val COL_TAG = "tag"
const val COL_NOTIFY = "notify"
const val COL_GUID = "guid"
const val COL_DESCRIPTION = "description"
const val COL_PLAINTITLE = "plainTitle"
const val COL_PLAINSNIPPET = "plainSnippet"
const val COL_IMAGEURL = "imageUrl"
const val COL_ENCLOSURELINK = "enclosureLink"
const val COL_LINK = "link"
const val COL_AUTHOR = "author"
const val COL_PUBDATE = "pubdate"
const val COL_UNREAD = "unread"
const val COL_NOTIFIED = "notified"
// These fields corresponds to columns in Feed table
const val COL_FEED = "feed"
const val COL_FEEDTITLE = "feedtitle"
const val COL_FEEDURL = "feedurl"
const val CREATE_FEED_TABLE = """
CREATE TABLE $FEED_TABLE_NAME (
$COL_ID INTEGER PRIMARY KEY,
$COL_TITLE TEXT NOT NULL,
$COL_CUSTOM_TITLE TEXT NOT NULL,
$COL_URL TEXT NOT NULL,
$COL_TAG TEXT NOT NULL DEFAULT '',
$COL_NOTIFY INTEGER NOT NULL DEFAULT 0,
$COL_IMAGEURL TEXT,
UNIQUE($COL_URL) ON CONFLICT REPLACE
)"""
const val CREATE_COUNT_VIEW = """
CREATE TEMP VIEW IF NOT EXISTS WithUnreadCount
AS SELECT $COL_ID, $COL_TITLE, $COL_URL, $COL_TAG, $COL_CUSTOM_TITLE, $COL_NOTIFY, $COL_IMAGEURL, "unreadcount"
FROM $FEED_TABLE_NAME
LEFT JOIN (SELECT COUNT(1) AS ${"unreadcount"}, $COL_FEED
FROM $FEED_ITEM_TABLE_NAME
WHERE $COL_UNREAD IS 1
GROUP BY $COL_FEED)
ON $FEED_TABLE_NAME.$COL_ID = $COL_FEED"""
const val CREATE_TAGS_VIEW = """
CREATE TEMP VIEW IF NOT EXISTS TagsWithUnreadCount
AS SELECT $COL_ID, $COL_TAG, "unreadcount"
FROM $FEED_TABLE_NAME
LEFT JOIN (SELECT COUNT(1) AS ${"unreadcount"}, $COL_TAG AS itemtag
FROM $FEED_ITEM_TABLE_NAME
WHERE $COL_UNREAD IS 1
GROUP BY itemtag)
ON $FEED_TABLE_NAME.$COL_TAG IS itemtag
GROUP BY $COL_TAG"""
const val CREATE_FEED_ITEM_TABLE = """
CREATE TABLE $FEED_ITEM_TABLE_NAME (
$COL_ID INTEGER PRIMARY KEY,
$COL_GUID TEXT NOT NULL,
$COL_TITLE TEXT NOT NULL,
$COL_DESCRIPTION TEXT NOT NULL,
$COL_PLAINTITLE TEXT NOT NULL,
$COL_PLAINSNIPPET TEXT NOT NULL,
$COL_IMAGEURL TEXT,
$COL_LINK TEXT,
$COL_ENCLOSURELINK TEXT,
$COL_AUTHOR TEXT,
$COL_PUBDATE TEXT,
$COL_UNREAD INTEGER NOT NULL DEFAULT 1,
$COL_NOTIFIED INTEGER NOT NULL DEFAULT 0,
$COL_FEED INTEGER NOT NULL,
$COL_FEEDTITLE TEXT NOT NULL,
$COL_FEEDURL TEXT NOT NULL,
$COL_TAG TEXT NOT NULL,
FOREIGN KEY($COL_FEED)
REFERENCES $FEED_TABLE_NAME($COL_ID)
ON DELETE CASCADE,
UNIQUE($COL_GUID,$COL_FEED)
ON CONFLICT IGNORE
)"""
const val CREATE_TAG_TRIGGER = """
CREATE TEMP TRIGGER IF NOT EXISTS ${"trigger_tag_updater"}
AFTER UPDATE OF $COL_TAG,$COL_TITLE
ON $FEED_TABLE_NAME
WHEN
new.$COL_TAG IS NOT old.$COL_TAG
OR
new.$COL_TITLE IS NOT old.$COL_TITLE
BEGIN
UPDATE $FEED_ITEM_TABLE_NAME
SET $COL_TAG = new.$COL_TAG,
$COL_FEEDTITLE = new.$COL_TITLE
WHERE $COL_FEED IS old.$COL_ID;
END
"""

View file

@ -0,0 +1,62 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ExperimentalCoroutinesApi
@FlowPreview
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom10To11 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate10to11() {
var db = testHelper.createDatabase(dbName, 10)
db.use {
db.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, unread, notified, feed_id)
VALUES(8, 'http://item', 'title', 'ptitle', 'psnippet', 1, 0, 1)
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 11, true, MIGRATION_10_11)
db.query(
"""
SELECT first_synced_time FROM feed_items
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0L, it.getLong(0))
}
}
}

View file

@ -0,0 +1,62 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ExperimentalCoroutinesApi
@FlowPreview
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom11To12 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate11to12() {
var db = testHelper.createDatabase(dbName, 11)
db.use {
db.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, unread, notified, feed_id, first_synced_time)
VALUES(8, 'http://item', 'title', 'ptitle', 'psnippet', 1, 0, 1, 0)
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 12, true, MIGRATION_11_12)
db.query(
"""
SELECT primary_sort_time FROM feed_items
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0L, it.getLong(0))
}
}
}

View file

@ -0,0 +1,62 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ExperimentalCoroutinesApi
@FlowPreview
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom12To13 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate12to13() {
var db = testHelper.createDatabase(dbName, 12)
db.use {
db.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, unread, notified, feed_id, first_synced_time, primary_sort_time)
VALUES(8, 'http://item', 'title', 'ptitle', 'psnippet', 1, 0, 1, 0, 0)
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 13, true, MIGRATION_12_13)
db.query(
"""
SELECT fulltext_by_default FROM feeds
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0L, it.getLong(0))
}
}
}

View file

@ -0,0 +1,62 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ExperimentalCoroutinesApi
@FlowPreview
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom13To14 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate13to14() {
var db = testHelper.createDatabase(dbName, 13)
db.use {
db.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash, fulltext_by_default)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666, 0)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, unread, notified, feed_id, first_synced_time, primary_sort_time)
VALUES(8, 'http://item', 'title', 'ptitle', 'psnippet', 1, 0, 1, 0, 0)
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 14, true, MIGRATION_13_14)
db.query(
"""
SELECT open_articles_with FROM feeds
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0L, it.getLong(0))
}
}
}

View file

@ -0,0 +1,53 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom7To8 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate7to8() {
var db = testHelper.createDatabase(dbName, 7)
db.use {
db.execSQL(
"""
INSERT INTO feeds(title, url, custom_title, tag, notify)
VALUES('feed', 'http://url', '', '', 0)
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 8, true, MIGRATION_7_8)
db.query(
"""
SELECT title, url, last_sync FROM feeds
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals("feed", it.getString(0))
assertEquals("http://url", it.getString(1))
assertEquals(0L, it.getLong(2))
}
}
}

View file

@ -0,0 +1,53 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom8To9 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate8to9() {
var db = testHelper.createDatabase(dbName, 8)
db.use {
db.execSQL(
"""
INSERT INTO feeds(title, url, custom_title, tag, notify, last_sync)
VALUES('feed', 'http://url', '', '', 0, 0)
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 9, true, MIGRATION_8_9)
db.query(
"""
SELECT title, url, response_hash FROM feeds
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals("feed", it.getString(0))
assertEquals("http://url", it.getString(1))
assertEquals(0L, it.getLong(2))
}
}
}

View file

@ -0,0 +1,88 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.nononsenseapps.feeder.FeederApplication
import com.nononsenseapps.feeder.blob.blobInputStream
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ExperimentalCoroutinesApi
@FlowPreview
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFrom9To10 {
private val dbName = "testDb"
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrate9to10() {
var db = testHelper.createDatabase(dbName, 9)
db.use {
db.execSQL(
"""
INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash)
VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, unread, notified, feed_id, description)
VALUES(8, 'http://item', 'title', 'ptitle', 'psnippet', 1, 0, 1, '$bigBody')
""".trimIndent()
)
}
db = testHelper.runMigrationsAndValidate(dbName, 10, true, MIGRATION_9_10)
db.query(
"""
SELECT response_hash FROM feeds WHERE id IS 1
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(0L, it.getLong(0))
}
db.query(
"""
SELECT id, title FROM feed_items
""".trimIndent()
)!!.use {
assert(it.count == 1)
assert(it.moveToFirst())
assertEquals(8L, it.getLong(0))
assertEquals("title", it.getString(1))
}
blobInputStream(
itemId = 8,
filesDir = ApplicationProvider.getApplicationContext<FeederApplication>().filesDir
).bufferedReader().useLines {
val lines = it.toList()
assertEquals(1, lines.size)
assertEquals(bigBody.take(999_999), lines.first())
}
}
// 4MB field
private val bigBody: String = "a".repeat(4 * 1024 * 1024)
}

View file

@ -0,0 +1,297 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.nononsenseapps.feeder.FeederApplication
import com.nononsenseapps.feeder.db.legacy.COL_AUTHOR
import com.nononsenseapps.feeder.db.legacy.COL_CUSTOM_TITLE
import com.nononsenseapps.feeder.db.legacy.COL_DESCRIPTION
import com.nononsenseapps.feeder.db.legacy.COL_ENCLOSURELINK
import com.nononsenseapps.feeder.db.legacy.COL_FEED
import com.nononsenseapps.feeder.db.legacy.COL_FEEDTITLE
import com.nononsenseapps.feeder.db.legacy.COL_FEEDURL
import com.nononsenseapps.feeder.db.legacy.COL_GUID
import com.nononsenseapps.feeder.db.legacy.COL_ID
import com.nononsenseapps.feeder.db.legacy.COL_IMAGEURL
import com.nononsenseapps.feeder.db.legacy.COL_LINK
import com.nononsenseapps.feeder.db.legacy.COL_NOTIFIED
import com.nononsenseapps.feeder.db.legacy.COL_NOTIFY
import com.nononsenseapps.feeder.db.legacy.COL_PLAINSNIPPET
import com.nononsenseapps.feeder.db.legacy.COL_PLAINTITLE
import com.nononsenseapps.feeder.db.legacy.COL_PUBDATE
import com.nononsenseapps.feeder.db.legacy.COL_TAG
import com.nononsenseapps.feeder.db.legacy.COL_TITLE
import com.nononsenseapps.feeder.db.legacy.COL_UNREAD
import com.nononsenseapps.feeder.db.legacy.COL_URL
import com.nononsenseapps.feeder.db.legacy.CREATE_FEED_ITEM_TABLE
import com.nononsenseapps.feeder.db.legacy.CREATE_TAGS_VIEW
import com.nononsenseapps.feeder.db.legacy.CREATE_TAG_TRIGGER
import com.nononsenseapps.feeder.db.legacy.FEED_ITEM_TABLE_NAME
import com.nononsenseapps.feeder.db.legacy.FEED_TABLE_NAME
import com.nononsenseapps.feeder.db.legacy.LegacyDatabaseHandler
import com.nononsenseapps.feeder.util.contentValues
import com.nononsenseapps.feeder.util.setInt
import com.nononsenseapps.feeder.util.setLong
import com.nononsenseapps.feeder.util.setString
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.threeten.bp.Instant
import org.threeten.bp.ZoneOffset
import org.threeten.bp.ZonedDateTime
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFromLegacy5ToLatest {
private val feederApplication: FeederApplication = getApplicationContext()
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
private val testDbName = "TestingDatabase"
private val legacyDb: LegacyDatabaseHandler
get() = LegacyDatabaseHandler(
context = feederApplication,
name = testDbName,
version = 5
)
private val roomDb: AppDatabase
get() =
Room.databaseBuilder(
feederApplication,
AppDatabase::class.java,
testDbName
)
.addMigrations(*allMigrations)
.build().also { testHelper.closeWhenFinished(it) }
@Before
fun setup() {
legacyDb.writableDatabase.use { db ->
db.execSQL(
"""
CREATE TABLE $FEED_TABLE_NAME (
$COL_ID INTEGER PRIMARY KEY,
$COL_TITLE TEXT NOT NULL,
$COL_CUSTOM_TITLE TEXT NOT NULL,
$COL_URL TEXT NOT NULL,
$COL_TAG TEXT NOT NULL DEFAULT '',
$COL_NOTIFY INTEGER NOT NULL DEFAULT 0,
UNIQUE($COL_URL) ON CONFLICT REPLACE
)"""
)
db.execSQL(CREATE_FEED_ITEM_TABLE)
db.execSQL(CREATE_TAG_TRIGGER)
db.execSQL(
"""
CREATE TEMP VIEW IF NOT EXISTS WithUnreadCount
AS SELECT $COL_ID, $COL_TITLE, $COL_URL, $COL_TAG, $COL_CUSTOM_TITLE, $COL_NOTIFY, "unreadcount"
FROM $FEED_TABLE_NAME
LEFT JOIN (SELECT COUNT(1) AS ${"unreadcount"}, $COL_FEED
FROM $FEED_ITEM_TABLE_NAME
WHERE $COL_UNREAD IS 1
GROUP BY $COL_FEED)
ON $FEED_TABLE_NAME.$COL_ID = $COL_FEED"""
)
db.execSQL(CREATE_TAGS_VIEW)
// Bare minimum non-null feeds
val idA = db.insert(
FEED_TABLE_NAME, null,
contentValues {
setString(COL_TITLE to "feedA")
setString(COL_CUSTOM_TITLE to "feedACustom")
setString(COL_URL to "https://feedA")
setString(COL_TAG to "")
}
)
// All fields filled
val idB = db.insert(
FEED_TABLE_NAME, null,
contentValues {
setString(COL_TITLE to "feedB")
setString(COL_CUSTOM_TITLE to "feedBCustom")
setString(COL_URL to "https://feedB")
setString(COL_TAG to "tag")
setInt(COL_NOTIFY to 1)
}
)
IntRange(0, 1).forEach { index ->
db.insert(
FEED_ITEM_TABLE_NAME, null,
contentValues {
setLong(COL_FEED to idA)
setString(COL_GUID to "guid$index")
setString(COL_TITLE to "title$index")
setString(COL_DESCRIPTION to "desc$index")
setString(COL_PLAINTITLE to "plain$index")
setString(COL_PLAINSNIPPET to "snippet$index")
setString(COL_FEEDTITLE to "feedA")
setString(COL_FEEDURL to "https://feedA")
setString(COL_TAG to "")
}
)
db.insert(
FEED_ITEM_TABLE_NAME, null,
contentValues {
setLong(COL_FEED to idB)
setString(COL_GUID to "guid$index")
setString(COL_TITLE to "title$index")
setString(COL_DESCRIPTION to "desc$index")
setString(COL_PLAINTITLE to "plain$index")
setString(COL_PLAINSNIPPET to "snippet$index")
setString(COL_FEEDTITLE to "feedB")
setString(COL_FEEDURL to "https://feedB")
setString(COL_TAG to "tag")
setInt(COL_NOTIFIED to 1)
setInt(COL_UNREAD to 0)
setString(COL_AUTHOR to "author$index")
setString(COL_ENCLOSURELINK to "https://enclosure$index")
setString(COL_IMAGEURL to "https://image$index")
setString(COL_PUBDATE to "2018-02-03T04:05:00Z")
setString(COL_LINK to "https://link$index")
}
)
}
}
}
@After
fun tearDown() {
assertTrue(feederApplication.deleteDatabase(testDbName))
}
@Test
fun legacyMigrationTo7MinimalFeed() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_5_7, MIGRATION_7_8
)
roomDb.let { db ->
val feeds = db.feedDao().loadFeeds()
assertEquals("Wrong number of feeds", 2, feeds.size)
val feedA = feeds[0]
assertEquals("feedA", feedA.title)
assertEquals("feedACustom", feedA.customTitle)
assertEquals(URL("https://feedA"), feedA.url)
assertEquals("", feedA.tag)
assertEquals(Instant.EPOCH, feedA.lastSync)
assertFalse(feedA.notify)
assertNull(feedA.imageUrl)
}
}
@Test
fun legacyMigrationTo7CompleteFeed() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_5_7, MIGRATION_7_8
)
roomDb.let { db ->
val feeds = db.feedDao().loadFeeds()
assertEquals("Wrong number of feeds", 2, feeds.size)
val feedB = feeds[1]
assertEquals("feedB", feedB.title)
assertEquals("feedBCustom", feedB.customTitle)
assertEquals(URL("https://feedB"), feedB.url)
assertEquals("tag", feedB.tag)
assertEquals(Instant.EPOCH, feedB.lastSync)
assertTrue(feedB.notify)
assertNull(feedB.imageUrl)
}
}
@Test
fun legacyMigrationTo7MinimalFeedItem() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_5_7, MIGRATION_7_8
)
roomDb.let { db ->
val feed = db.feedDao().loadFeeds()[0]
assertEquals("feedA", feed.title)
val items = db.feedItemDao().loadFeedItemsInFeedDesc(feedId = feed.id)
assertEquals(2, items.size)
items.forEachIndexed { index, it ->
assertEquals(feed.id, it.feedId)
assertEquals("guid$index", it.guid)
assertEquals("title$index", it.title)
assertEquals("plain$index", it.plainTitle)
assertEquals("snippet$index", it.plainSnippet)
assertTrue(it.unread)
assertNull(it.author)
assertNull(it.enclosureLink)
assertNull(it.imageUrl)
assertNull(it.pubDate)
assertNull(it.link)
assertFalse(it.notified)
}
}
}
@Test
fun legacyMigrationTo7CompleteFeedItem() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_5_7, MIGRATION_7_8
)
roomDb.let { db ->
val feed = db.feedDao().loadFeeds()[1]
assertEquals("feedB", feed.title)
val items = db.feedItemDao().loadFeedItemsInFeedDesc(feedId = feed.id)
assertEquals(2, items.size)
items.forEachIndexed { index, it ->
assertEquals(feed.id, it.feedId)
assertEquals("guid$index", it.guid)
assertEquals("title$index", it.title)
assertEquals("plain$index", it.plainTitle)
assertEquals("snippet$index", it.plainSnippet)
assertFalse(it.unread)
assertEquals("author$index", it.author)
assertEquals("https://enclosure$index", it.enclosureLink)
assertEquals("https://image$index", it.imageUrl)
assertEquals(ZonedDateTime.of(2018, 2, 3, 4, 5, 0, 0, ZoneOffset.UTC), it.pubDate)
assertEquals("https://link$index", it.link)
assertTrue(it.notified)
}
}
}
}

View file

@ -0,0 +1,274 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.nononsenseapps.feeder.FeederApplication
import com.nononsenseapps.feeder.db.legacy.COL_AUTHOR
import com.nononsenseapps.feeder.db.legacy.COL_CUSTOM_TITLE
import com.nononsenseapps.feeder.db.legacy.COL_DESCRIPTION
import com.nononsenseapps.feeder.db.legacy.COL_ENCLOSURELINK
import com.nononsenseapps.feeder.db.legacy.COL_FEED
import com.nononsenseapps.feeder.db.legacy.COL_FEEDTITLE
import com.nononsenseapps.feeder.db.legacy.COL_FEEDURL
import com.nononsenseapps.feeder.db.legacy.COL_GUID
import com.nononsenseapps.feeder.db.legacy.COL_IMAGEURL
import com.nononsenseapps.feeder.db.legacy.COL_LINK
import com.nononsenseapps.feeder.db.legacy.COL_NOTIFIED
import com.nononsenseapps.feeder.db.legacy.COL_NOTIFY
import com.nononsenseapps.feeder.db.legacy.COL_PLAINSNIPPET
import com.nononsenseapps.feeder.db.legacy.COL_PLAINTITLE
import com.nononsenseapps.feeder.db.legacy.COL_PUBDATE
import com.nononsenseapps.feeder.db.legacy.COL_TAG
import com.nononsenseapps.feeder.db.legacy.COL_TITLE
import com.nononsenseapps.feeder.db.legacy.COL_UNREAD
import com.nononsenseapps.feeder.db.legacy.COL_URL
import com.nononsenseapps.feeder.db.legacy.CREATE_FEED_ITEM_TABLE
import com.nononsenseapps.feeder.db.legacy.CREATE_FEED_TABLE
import com.nononsenseapps.feeder.db.legacy.FEED_ITEM_TABLE_NAME
import com.nononsenseapps.feeder.db.legacy.FEED_TABLE_NAME
import com.nononsenseapps.feeder.db.legacy.LegacyDatabaseHandler
import com.nononsenseapps.feeder.db.legacy.createViewsAndTriggers
import com.nononsenseapps.feeder.util.contentValues
import com.nononsenseapps.feeder.util.setInt
import com.nononsenseapps.feeder.util.setLong
import com.nononsenseapps.feeder.util.setString
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.threeten.bp.Instant
import org.threeten.bp.ZoneOffset
import org.threeten.bp.ZonedDateTime
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class MigrationFromLegacy6ToLatest {
private val feederApplication: FeederApplication = getApplicationContext()
@Rule
@JvmField
val testHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
private val testDbName = "TestingDatabase"
private val legacyDb: LegacyDatabaseHandler
get() = LegacyDatabaseHandler(
context = feederApplication,
name = testDbName,
version = 6
)
private val roomDb: AppDatabase
get() =
Room.databaseBuilder(
feederApplication,
AppDatabase::class.java,
testDbName
)
.addMigrations(*allMigrations)
.build().also { testHelper.closeWhenFinished(it) }
@Before
fun setup() {
legacyDb.writableDatabase.use { db ->
db.execSQL(CREATE_FEED_TABLE)
db.execSQL(CREATE_FEED_ITEM_TABLE)
createViewsAndTriggers(db)
// Bare minimum non-null feeds
val idA = db.insert(
FEED_TABLE_NAME, null,
contentValues {
setString(COL_TITLE to "feedA")
setString(COL_CUSTOM_TITLE to "feedACustom")
setString(COL_URL to "https://feedA")
setString(COL_TAG to "")
}
)
// All fields filled
val idB = db.insert(
FEED_TABLE_NAME, null,
contentValues {
setString(COL_TITLE to "feedB")
setString(COL_CUSTOM_TITLE to "feedBCustom")
setString(COL_URL to "https://feedB")
setString(COL_TAG to "tag")
setString(COL_IMAGEURL to "https://image")
setInt(COL_NOTIFY to 1)
}
)
IntRange(0, 1).forEach { index ->
db.insert(
FEED_ITEM_TABLE_NAME, null,
contentValues {
setLong(COL_FEED to idA)
setString(COL_GUID to "guid$index")
setString(COL_TITLE to "title$index")
setString(COL_DESCRIPTION to "desc$index")
setString(COL_PLAINTITLE to "plain$index")
setString(COL_PLAINSNIPPET to "snippet$index")
setString(COL_FEEDTITLE to "feedA")
setString(COL_FEEDURL to "https://feedA")
setString(COL_TAG to "")
}
)
db.insert(
FEED_ITEM_TABLE_NAME, null,
contentValues {
setLong(COL_FEED to idB)
setString(COL_GUID to "guid$index")
setString(COL_TITLE to "title$index")
setString(COL_DESCRIPTION to "desc$index")
setString(COL_PLAINTITLE to "plain$index")
setString(COL_PLAINSNIPPET to "snippet$index")
setString(COL_FEEDTITLE to "feedB")
setString(COL_FEEDURL to "https://feedB")
setString(COL_TAG to "tag")
setInt(COL_NOTIFIED to 1)
setInt(COL_UNREAD to 0)
setString(COL_AUTHOR to "author$index")
setString(COL_ENCLOSURELINK to "https://enclosure$index")
setString(COL_IMAGEURL to "https://image$index")
setString(COL_PUBDATE to "2018-02-03T04:05:00Z")
setString(COL_LINK to "https://link$index")
}
)
}
}
}
@After
fun tearDown() {
assertTrue(feederApplication.deleteDatabase(testDbName))
}
@Test
fun legacyMigrationTo7MinimalFeed() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_6_7
)
roomDb.let { db ->
val feeds = db.feedDao().loadFeeds()
assertEquals("Wrong number of feeds", 2, feeds.size)
val feedA = feeds[0]
assertEquals("feedA", feedA.title)
assertEquals("feedACustom", feedA.customTitle)
assertEquals(URL("https://feedA"), feedA.url)
assertEquals("", feedA.tag)
assertEquals(Instant.EPOCH, feedA.lastSync)
assertFalse(feedA.notify)
assertNull(feedA.imageUrl)
}
}
@Test
fun legacyMigrationTo7CompleteFeed() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_6_7
)
roomDb.let { db ->
val feeds = db.feedDao().loadFeeds()
assertEquals("Wrong number of feeds", 2, feeds.size)
val feedB = feeds[1]
assertEquals("feedB", feedB.title)
assertEquals("feedBCustom", feedB.customTitle)
assertEquals(URL("https://feedB"), feedB.url)
assertEquals("tag", feedB.tag)
assertEquals(Instant.EPOCH, feedB.lastSync)
assertTrue(feedB.notify)
assertEquals(URL("https://image"), feedB.imageUrl)
}
}
@Test
fun legacyMigrationTo7MinimalFeedItem() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_6_7
)
roomDb.let { db ->
val feed = db.feedDao().loadFeeds()[0]
assertEquals("feedA", feed.title)
val items = db.feedItemDao().loadFeedItemsInFeedDesc(feedId = feed.id)
assertEquals(2, items.size)
items.forEachIndexed { index, it ->
assertEquals(feed.id, it.feedId)
assertEquals("guid$index", it.guid)
assertEquals("title$index", it.title)
assertEquals("plain$index", it.plainTitle)
assertEquals("snippet$index", it.plainSnippet)
assertTrue(it.unread)
assertNull(it.author)
assertNull(it.enclosureLink)
assertNull(it.imageUrl)
assertNull(it.pubDate)
assertNull(it.link)
assertFalse(it.notified)
}
}
}
@Test
fun legacyMigrationTo7CompleteFeedItem() = runBlocking {
testHelper.runMigrationsAndValidate(
testDbName, 7, true,
MIGRATION_6_7
)
roomDb.let { db ->
val feed = db.feedDao().loadFeeds()[1]
assertEquals("feedB", feed.title)
val items = db.feedItemDao().loadFeedItemsInFeedDesc(feedId = feed.id)
assertEquals(2, items.size)
items.forEachIndexed { index, it ->
assertEquals(feed.id, it.feedId)
assertEquals("guid$index", it.guid)
assertEquals("title$index", it.title)
assertEquals("plain$index", it.plainTitle)
assertEquals("snippet$index", it.plainSnippet)
assertFalse(it.unread)
assertEquals("author$index", it.author)
assertEquals("https://enclosure$index", it.enclosureLink)
assertEquals("https://image$index", it.imageUrl)
assertEquals(ZonedDateTime.of(2018, 2, 3, 4, 5, 0, 0, ZoneOffset.UTC), it.pubDate)
assertEquals("https://link$index", it.link)
assertTrue(it.notified)
}
}
}
}

View file

@ -0,0 +1,256 @@
package com.nononsenseapps.feeder.model
import android.content.Intent
import android.text.Spanned
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.base.KodeinAwareActivity
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.db.room.ID_UNSET
import com.nononsenseapps.feeder.ui.FeedActivity
import com.nononsenseapps.feeder.ui.TestDatabaseRule
import com.nononsenseapps.feeder.util.ARG_ID
import io.mockk.clearMocks
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.generic.instance
import java.net.URL
import kotlin.test.fail
@RunWith(AndroidJUnit4::class)
class FeedItemViewModelTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private var feedId: Long = ID_UNSET
private var itemId: Long = ID_UNSET
@Before
fun initDb() = runBlocking {
feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http://foo")
)
)
}
@Test
fun databaseLoadCallsOnChangeTwiceWhenImages() {
val observer = mockk<Observer<Spanned>>(relaxed = true)
itemId = runBlocking {
testDb.insertFeedItemWithBlob(
FeedItem(
feedId = feedId,
guid = "foobar",
title = "title"
),
description = "description <img src='file://img.png' alt='img here'></img>"
)
}
activityRule.launchActivity(
Intent().also {
it.putExtra(ARG_ID, itemId)
}
)
runBlocking {
withContext(Dispatchers.Main) {
activityRule.activity.getLiveFeedItemImageText(itemId).observe(activityRule.activity, observer)
}
verify(exactly = 3, timeout = 500) {
observer.onChanged(any())
}
}
}
@Test
fun databaseLoadCallsOnChangeOnceWhenNoImages() {
val observer = mockk<Observer<Spanned>>(relaxed = true)
itemId = runBlocking {
testDb.insertFeedItemWithBlob(
FeedItem(
feedId = feedId,
guid = "foobar",
title = "title"
),
description = "description <b>bold</b>"
)
}
activityRule.launchActivity(
Intent().also {
it.putExtra(ARG_ID, itemId)
}
)
runBlocking {
withContext(Dispatchers.Main) {
activityRule.activity.getLiveFeedItemImageText(itemId).observe(activityRule.activity, observer)
}
verify(exactly = 2, timeout = 500) {
observer.onChanged(any())
}
}
}
@Test
fun databaseLoadCallsOnChangeNeverOnSyncAndNoUpdateOnBody() {
val observer = mockk<Observer<Spanned>>(relaxed = true)
var item = FeedItem(
feedId = feedId,
guid = "foobar",
title = "title"
)
val description = "description <b>bold</b>"
itemId = runBlocking {
testDb.insertFeedItemWithBlob(item, description)
}
item = item.copy(id = itemId)
activityRule.launchActivity(
Intent().also {
it.putExtra(ARG_ID, itemId)
}
)
runBlocking {
withContext(Dispatchers.Main) {
activityRule.activity.getLiveFeedItemImageText(itemId).observe(activityRule.activity, observer)
}
verify(exactly = 2, timeout = 500) {
observer.onChanged(any())
}
clearMocks(observer)
assertEquals(1, testDb.db.feedItemDao().updateFeedItem(item.copy(title = "updated title")))
verify(exactly = 0, timeout = 500) {
observer.onChanged(any())
}
}
}
@Test
@Ignore("Not monitoring file")
fun databaseLoadCallsOnChangeOnceOnSyncWithBodyUpdate() {
val observer = mockk<Observer<Spanned>>(relaxed = true)
var item = FeedItem(
feedId = feedId,
guid = "foobar",
title = "title"
)
val description = "description <b>bold</b>"
itemId = runBlocking {
testDb.insertFeedItemWithBlob(item, description)
}
item = item.copy(id = itemId)
activityRule.launchActivity(
Intent().also {
it.putExtra(ARG_ID, itemId)
}
)
runBlocking {
withContext(Dispatchers.Main) {
activityRule.activity.getLiveFeedItemImageText(itemId).observe(activityRule.activity, observer)
}
verify(exactly = 2, timeout = 500) {
observer.onChanged(any())
}
clearMocks(observer)
fail("Not monitoring changes to file")
// assertEquals(1, testDb.db.feedItemDao().updateFeedItem(item.copy(description = "updated body")))
// verify(exactly = 1, timeout = 500) {
// observer.onChanged(any())
// }
}
}
@Test
@Ignore("Not monitoring file")
fun databaseLoadCallsOnChangeOnceOnSyncWithBodyUpdateWithImage() {
val observer = mockk<Observer<Spanned>>(relaxed = true)
val item = FeedItem(
feedId = feedId,
guid = "foobar",
title = "title"
)
val description = "description <img src='file://img.png' alt='img here'></img>"
itemId = runBlocking {
testDb.insertFeedItemWithBlob(item, description)
}
activityRule.launchActivity(
Intent().also {
it.putExtra(ARG_ID, itemId)
}
)
runBlocking {
withContext(Dispatchers.Main) {
activityRule.activity.getLiveFeedItemImageText(itemId).observe(activityRule.activity, observer)
}
verify(exactly = 2, timeout = 500) {
observer.onChanged(any())
}
clearMocks(observer)
fail("Not monitoring file")
/*assertEquals(1, testDb.db.feedItemDao().updateFeedItem(item.copy(
id = itemId,
description = "updated <img src='file://img.png' alt='img here'></img>")))*/
// verify(exactly = 1, timeout = 500) {
// observer.onChanged(any())
// }
}
}
}
suspend fun KodeinAwareActivity.getLiveFeedItemImageText(id: Long): LiveData<Spanned> {
val viewModel: FeedItemViewModel by instance()
return viewModel.getLiveDefaultText(
TextOptions(
id,
maxImageSize(),
false
),
null
)
}

View file

@ -0,0 +1,110 @@
package com.nononsenseapps.feeder.model
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.ID_UNSET
import com.nononsenseapps.feeder.ui.TestDatabaseRule
import com.nononsenseapps.feeder.util.minusMinutes
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.threeten.bp.Instant
import java.net.URL
@RunWith(AndroidJUnit4::class)
class FeedsToSyncTest {
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
@Test
fun returnsStaleFeed() = runBlocking {
// with stale feed
val feed = withFeed()
// when
val result = feedsToSync(testDb.db.feedDao(), feedId = feed.id, tag = "")
// then
assertEquals(listOf(feed), result)
}
@Test
fun doesNotReturnFreshFeed() = runBlocking {
val now = Instant.now()
val feed = withFeed(lastSync = now.minusMinutes(1))
// when
val result = feedsToSync(
testDb.db.feedDao(), feedId = feed.id, tag = "",
staleTime = now.minusMinutes(2).toEpochMilli()
)
// then
assertEquals(emptyList<Feed>(), result)
}
@Test
fun returnsAllStaleFeeds() = runBlocking {
val items = listOf(
withFeed(url = URL("http://one")),
withFeed(url = URL("http://two"))
)
val result = feedsToSync(testDb.db.feedDao(), feedId = ID_UNSET, tag = "")
assertEquals(items, result)
}
@Test
fun doesNotReturnAllFreshFeeds() = runBlocking {
val now = Instant.now()
val items = listOf(
withFeed(url = URL("http://one"), lastSync = now.minusMinutes(1)),
withFeed(url = URL("http://two"), lastSync = now.minusMinutes(3))
)
val result = feedsToSync(testDb.db.feedDao(), feedId = ID_UNSET, tag = "", staleTime = now.minusMinutes(2).toEpochMilli())
assertEquals(listOf(items[1]), result)
}
@Test
fun returnsTaggedStaleFeeds() = runBlocking {
val items = listOf(
withFeed(url = URL("http://one"), tag = "tag"),
withFeed(url = URL("http://two"), tag = "tag")
)
val result = feedsToSync(testDb.db.feedDao(), feedId = ID_UNSET, tag = "")
assertEquals(items, result)
}
@Test
fun doesNotReturnTaggedFreshFeeds() = runBlocking {
val now = Instant.now()
val items = listOf(
withFeed(url = URL("http://one"), lastSync = now.minusMinutes(1), tag = "tag"),
withFeed(url = URL("http://two"), lastSync = now.minusMinutes(3), tag = "tag")
)
val result = feedsToSync(testDb.db.feedDao(), feedId = ID_UNSET, tag = "tag", staleTime = now.minusMinutes(2).toEpochMilli())
assertEquals(listOf(items[1]), result)
}
private suspend fun withFeed(lastSync: Instant = Instant.ofEpochMilli(0), url: URL = URL("http://url"), tag: String = ""): Feed {
val feed = Feed(
lastSync = lastSync,
url = url,
tag = tag
)
val id = testDb.db.feedDao().insertFeed(feed)
return feed.copy(id = id)
}
}

View file

@ -0,0 +1,23 @@
package com.nononsenseapps.feeder.model
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import com.nononsenseapps.feeder.FeederApplication
import com.nononsenseapps.feeder.blob.blobOutputStream
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.ui.TestDatabaseRule
suspend fun TestDatabaseRule.insertFeedItemWithBlob(
feedItem: FeedItem,
description: String
): Long {
val feedItemId = db.feedItemDao().insertFeedItem(feedItem)
blobOutputStream(
itemId = feedItemId,
filesDir = getApplicationContext<FeederApplication>().filesDir
).bufferedWriter().use {
it.write(description)
}
return feedItemId
}

View file

@ -0,0 +1,550 @@
package com.nononsenseapps.feeder.model
import android.content.Context
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.nononsenseapps.feeder.FeederApplication
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.ID_UNSET
import com.nononsenseapps.feeder.ui.TestDatabaseRule
import com.nononsenseapps.feeder.util.minusMinutes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import org.threeten.bp.Instant
import java.io.InputStream
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.test.assertTrue
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@MediumTest
class RssLocalSyncKtTest {
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private val filesDir = getApplicationContext<FeederApplication>().filesDir
private val kodein by closestKodein(getApplicationContext() as Context)
val server = MockWebServer()
val responses = mutableMapOf<URL?, MockResponse>()
@After
fun stopServer() {
server.shutdown()
}
@Before
fun setup() {
server.start()
}
suspend fun insertFeed(title: String, url: URL, raw: String, isJson: Boolean = true): Long {
val id = testDb.db.feedDao().insertFeed(
Feed(
title = title,
url = url,
tag = ""
)
)
server.dispatcher = object: Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return responses.getOrDefault(request.requestUrl?.toUrl(), MockResponse().setResponseCode(404))
}
}
responses[url] = MockResponse().apply {
setResponseCode(200)
if (isJson) {
setHeader("Content-Type", "application/json")
}
setBody(raw)
}
return id
}
@Test
fun syncCowboyJsonWorks() = runBlocking {
val cowboyJsonId = insertFeed(
"cowboyjson", server.url("/feed.json").toUrl(),
cowboyJson
)
runBlocking {
syncFeeds(
kodein = kodein,
filesDir = filesDir,
feedId = cowboyJsonId
)
}
assertEquals(
"Unexpected number of items in feed",
10,
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(cowboyJsonId).size
)
}
@Test
fun syncCowboyAtomWorks() = runBlocking {
val cowboyAtomId = insertFeed(
"cowboyatom", server.url("/atom.xml").toUrl(),
cowboyAtom, isJson = false
)
runBlocking {
syncFeeds(
kodein = kodein,
filesDir = filesDir,
feedId = cowboyAtomId
)
}
assertEquals(
"Unexpected number of items in feed",
15,
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(cowboyAtomId).size
)
}
@Test
fun syncAllWorks() = runBlocking {
val cowboyJsonId = insertFeed(
"cowboyjson", server.url("/feed.json").toUrl(),
cowboyJson
)
val cowboyAtomId = insertFeed(
"cowboyatom", server.url("/atom.xml").toUrl(),
cowboyAtom, isJson = false
)
runBlocking {
syncFeeds(
kodein = kodein,
filesDir = filesDir,
feedId = ID_UNSET,
parallel = true
)
}
assertEquals(
"Unexpected number of items in feed",
10,
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(cowboyJsonId).size
)
assertEquals(
"Unexpected number of items in feed",
15,
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(cowboyAtomId).size
)
}
@Test
fun responsesAreNotParsedUnlessFeedHashHasChanged() = runBlocking {
val cowboyJsonId = insertFeed(
"cowboyjson", server.url("/feed.json").toUrl(),
cowboyJson
)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = cowboyJsonId, forceNetwork = true)
testDb.db.feedDao().loadFeed(cowboyJsonId)!!.let { feed ->
assertTrue("Feed should have been synced", feed.lastSync.toEpochMilli() > 0)
assertTrue("Feed should have a valid response hash", feed.responseHash > 0)
// "Long time" ago, but not unset
testDb.db.feedDao().updateFeed(feed.copy(lastSync = Instant.ofEpochMilli(999L)))
}
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = cowboyJsonId, forceNetwork = true)
}
assertEquals("Feed should have been fetched twice", 2, server.requestCount)
assertNotEquals(
"Cached response should still have updated feed last sync",
999L,
testDb.db.feedDao().loadFeed(cowboyJsonId)!!.lastSync.toEpochMilli()
)
}
@Test
fun feedsSyncedWithin15MinAreIgnored() = runBlocking {
val cowboyJsonId = insertFeed(
"cowboyjson", server.url("/feed.json").toUrl(),
cowboyJson
)
val fourteenMinsAgo = Instant.now().minusMinutes(14)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = cowboyJsonId, forceNetwork = true)
testDb.db.feedDao().loadFeed(cowboyJsonId)!!.let { feed ->
assertTrue("Feed should have been synced", feed.lastSync.toEpochMilli() > 0)
assertTrue("Feed should have a valid response hash", feed.responseHash > 0)
testDb.db.feedDao().updateFeed(feed.copy(lastSync = fourteenMinsAgo))
}
syncFeeds(
kodein = kodein, filesDir = filesDir,
feedId = cowboyJsonId, forceNetwork = false, minFeedAgeMinutes = 15
)
}
assertEquals(
"Recently synced feed should not get a second network request",
1, server.requestCount
)
assertEquals(
"Last sync should not have changed",
fourteenMinsAgo,
testDb.db.feedDao().loadFeed(cowboyJsonId)!!.lastSync
)
}
@Test
fun feedsSyncedWithin15MinAreNotIgnoredWhenForcingNetwork() = runBlocking {
val cowboyJsonId = insertFeed(
"cowboyjson", server.url("/feed.json").toUrl(),
cowboyJson
)
val fourteenMinsAgo = Instant.now().minusMinutes(14)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = cowboyJsonId, forceNetwork = true)
testDb.db.feedDao().loadFeed(cowboyJsonId)!!.let { feed ->
assertTrue("Feed should have been synced", feed.lastSync.toEpochMilli() > 0)
assertTrue("Feed should have a valid response hash", feed.responseHash > 0)
testDb.db.feedDao().updateFeed(feed.copy(lastSync = fourteenMinsAgo))
}
syncFeeds(
kodein = kodein, filesDir = filesDir,
feedId = cowboyJsonId, forceNetwork = true, minFeedAgeMinutes = 15
)
}
assertEquals("Request should have been sent due to forced network", 2, server.requestCount)
assertNotEquals(
"Last sync should have changed",
fourteenMinsAgo,
testDb.db.feedDao().loadFeed(cowboyJsonId)!!.lastSync
)
}
@Test
fun feedShouldNotBeUpdatedIfRequestFails() = runBlocking {
val response = MockResponse().also {
it.setResponseCode(500)
}
server.enqueue(response)
val url = server.url("/feed.json")
val failingJsonId = testDb.db.feedDao().insertFeed(
Feed(
title = "failJson",
url = URL("$url"),
tag = ""
)
)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = failingJsonId)
}
assertEquals(
"Last sync should not have been updated",
Instant.EPOCH,
testDb.db.feedDao().loadFeed(failingJsonId)!!.lastSync
)
// Assert the feed was retrieved
assertEquals("/feed.json", server.takeRequest().path)
}
@Test
fun feedWithNoUniqueLinksGetsSomeGeneratedGUIDsFromTitles() = runBlocking {
val response = MockResponse().also {
it.setResponseCode(200)
it.setBody(String(nixosRss.readBytes()))
}
server.enqueue(response)
val url = server.url("/news-rss.xml")
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "NixOS",
url = URL("$url"),
tag = ""
)
)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = feedId)
}
// Assert the feed was retrieved
assertEquals("/news-rss.xml", server.takeRequest().path)
val items = testDb.db.feedItemDao().loadFeedItemsInFeedDesc(feedId)
assertEquals(
"Unique IDs should have been generated for items",
99, items.size
)
// Should be unique to item so that it stays the same after updates
assertEquals(
"Unexpected ID",
"NixOS 18.09 released-NixOS 18.09 “Jellyfish” has been released, the tenth stable release branch. See the release notes for details. You can get NixOS 18.09 ISOs and VirtualBox appliances from the download page. For inform",
items.first().guid
)
}
@Test
fun feedWithNoDatesShouldGetSomeGenerated() = runBlocking {
val response = MockResponse().also {
it.setResponseCode(200)
it.setBody(fooRss(2))
}
server.enqueue(response)
val url = server.url("/rss")
val feedId = testDb.db.feedDao().insertFeed(
Feed(
url = URL("$url")
)
)
val beforeSyncTime = Instant.now()
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = feedId)
}
// Assert the feed was retrieved
assertEquals("/rss", server.takeRequest().path)
val items = testDb.db.feedItemDao().loadFeedItemsInFeedDesc(feedId)
assertNotNull(
"Item should have gotten a pubDate generated",
items[0].pubDate
)
assertNotEquals(
"Items should have distinct pubDates",
items[0].pubDate, items[1].pubDate
)
assertTrue(
"The pubDate should be after 'before sync time'",
items[0].pubDate!!.toInstant() > beforeSyncTime
)
// Compare ID to compare insertion order (and thus pubdate compared to raw feed)
assertTrue("The pubDates' magnitude should match descending iteration order") {
items[0].guid == "https://foo.bar/1" &&
items[1].guid == "https://foo.bar/2" &&
items[0].pubDate!! > items[1].pubDate!!
}
}
@Test
fun feedWithNoDatesShouldNotGetOverriddenDatesNextSync() = runBlocking {
server.enqueue(
MockResponse().also {
it.setResponseCode(200)
it.setBody(fooRss(1))
}
)
server.enqueue(
MockResponse().also {
it.setResponseCode(200)
it.setBody(fooRss(2))
}
)
val url = server.url("/rss")
val feedId = testDb.db.feedDao().insertFeed(
Feed(
url = URL("$url")
)
)
// Sync first time
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = feedId)
}
// Assert the feed was retrieved
assertEquals("/rss", server.takeRequest(100, TimeUnit.MILLISECONDS)!!.path)
val firstItem = testDb.db.feedItemDao().loadFeedItemsInFeedDesc(feedId).let { items ->
assertNotNull(
"Item should have gotten a pubDate generated",
items[0].pubDate
)
items[0]
}
// Sync second time
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = feedId, forceNetwork = true)
}
// Assert the feed was retrieved
assertEquals("/rss", server.takeRequest(100, TimeUnit.MILLISECONDS)!!.path)
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(feedId).let { items ->
assertEquals(
"Should be 2 items in feed",
2, items.size
)
val item = items.last()
assertEquals(
"Making sure we are comparing the same item",
firstItem.id, item.id
)
assertEquals(
"Pubdate should not have changed",
firstItem.pubDate, item.pubDate
)
}
}
@Test
fun feedShouldNotBeCleanedToHaveLessItemsThanActualFeed() = runBlocking {
val feedItemCount = 9
server.enqueue(
MockResponse().also {
it.setResponseCode(200)
it.setBody(fooRss(feedItemCount))
}
)
val url = server.url("/rss")
val feedId = testDb.db.feedDao().insertFeed(
Feed(
url = URL("$url")
)
)
val maxFeedItemCount = 5
// Sync first time
runBlocking {
syncFeeds(
kodein = kodein, filesDir = filesDir,
feedId = feedId,
maxFeedItemCount = maxFeedItemCount
)
}
// Assert the feed was retrieved
assertEquals("/rss", server.takeRequest(100, TimeUnit.MILLISECONDS)!!.path)
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(feedId).let { items ->
assertEquals(
"Feed should have no less items than in the raw feed even if that's more than cleanup count",
feedItemCount, items.size
)
}
}
@Test
fun slowResponseShouldBeOk() = runBlocking {
val url = server.url("/atom.xml").toUrl()
val cowboyAtomId = insertFeed("cowboy", url, cowboyAtom, isJson = false)
responses[url]!!.throttleBody(1024 * 100, 29, TimeUnit.SECONDS)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = cowboyAtomId)
}
assertEquals(
"Feed should have been parsed from slow response",
15,
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(cowboyAtomId).size
)
}
@Test
fun verySlowResponseShouldBeCancelled() = runBlocking {
val url = server.url("/atom.xml").toUrl()
val cowboyAtomId = insertFeed("cowboy", url, cowboyAtom, isJson = false)
responses[url]!!.throttleBody(1024 * 100, 31, TimeUnit.SECONDS)
runBlocking {
syncFeeds(kodein = kodein, filesDir = filesDir, feedId = cowboyAtomId)
}
assertEquals(
"Feed should not have been parsed from extremely slow response",
0,
testDb.db.feedItemDao().loadFeedItemsInFeedDesc(cowboyAtomId).size
)
}
val nixosRss: InputStream
get() = javaClass.getResourceAsStream("rss_nixos.xml")!!
val cowboyJson: String
get() = String(javaClass.getResourceAsStream("cowboyprogrammer_feed.json")!!.use { it.readBytes() })
val cowboyAtom: String
get() = String(javaClass.getResourceAsStream("cowboyprogrammer_atom.xml")!!.use { it.readBytes() })
fun fooRss(itemsCount: Int = 1): String {
return """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Foo Feed</title>
<link>https://foo.bar</link>
${
(1..itemsCount).map {
"""
<item>
<title>Foo Item $it</title>
<link>https://foo.bar/$it</link>
<description>Woop woop $it</description>
</item>
""".trimIndent()
}.fold("") { acc, s ->
"$acc\n$s"
}
}
</channel>
</rss>
""".trimIndent()
}
}

View file

@ -0,0 +1,78 @@
package com.nononsenseapps.feeder.model
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.nononsenseapps.feeder.db.COL_LINK
import com.nononsenseapps.feeder.db.room.FeedItem
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
@RunWith(AndroidJUnit4::class)
class RssNotificationsKtTest {
@Test
fun openInBrowserIntentPointsToActivityWithIdAndLink() {
val intent: Intent = getOpenInDefaultActivityIntent(getInstrumentation().context, 99, "http://foo")
assertEquals(
"com.nononsenseapps.feeder.ui.OpenLinkInDefaultActivity",
intent.component?.className
)
assertEquals("99", intent.data?.lastPathSegment)
assertEquals("http://foo", intent.data?.getQueryParameter(COL_LINK))
}
@Test
fun openInDefaultActivityIntentsAreConsideredDifferentForSameItem() {
val feedItem = FeedItem(
id = 5,
link = "http://foo",
enclosureLink = "ftp://bar"
)
val linkIntent = getOpenInDefaultActivityIntent(getInstrumentation().context, feedItem.id, link = feedItem.link)
val enclosureIntent = getOpenInDefaultActivityIntent(getInstrumentation().context, feedItem.id, link = feedItem.enclosureLink)
val markAsReadIntent = getOpenInDefaultActivityIntent(getInstrumentation().context, feedItem.id, link = null)
assertFalse(
linkIntent.filterEquals(enclosureIntent),
message = "linkIntent should not be considered equal to enclosureIntent"
)
assertFalse(
linkIntent.filterEquals(markAsReadIntent),
message = "linkIntent should not be considered equal to markAsReadIntent"
)
assertFalse(
enclosureIntent.filterEquals(markAsReadIntent),
message = "enclosureIntent should not be considered equal to markAsReadIntent"
)
}
@Test
fun queryParameterDoesntGetGarbled() {
val magnetLink = "magnet:?xt=urn:btih:82B1726F2D1B22F383A2B2CD6977B00F908FB315&dn=Crazy+Ex+Girlfriend+S04E10+720p+HDTV+x264+LucidTV&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=http%3A%2F%2Ftracker.trackerfix.com%3A80%2Fannounce"
val enclosureIntent = getOpenInDefaultActivityIntent(getInstrumentation().context, 5, link = magnetLink)
assertEquals(
magnetLink, enclosureIntent.data?.getQueryParameter(COL_LINK),
message = "Expected link to not get garbled as query parameter"
)
}
@Test
fun nullLinkIsNullQueryParam() {
val enclosureIntent = getOpenInDefaultActivityIntent(getInstrumentation().context, 5, link = null)
assertNull(
enclosureIntent.data?.getQueryParameter(COL_LINK),
message = "Expected a null query parameter"
)
}
}

View file

@ -0,0 +1,489 @@
package com.nononsenseapps.feeder.model.opml
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.filters.SmallTest
import com.nononsenseapps.feeder.db.room.AppDatabase
import com.nononsenseapps.feeder.db.room.Feed
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
import java.net.URL
import java.util.ArrayList
private val sampleFile: List<String> = """<?xml version="1.0" encoding="UTF-8"?>
|<opml version="1.1">
| <head>
| <title>
| Feeder
| </title>
| </head>
| <body>
| <outline title="&quot;0&quot;" text="&quot;0&quot;" type="rss" xmlUrl="http://somedomain0.com/rss.xml"/>
| <outline title="custom &quot;3&quot;" text="custom &quot;3&quot;" type="rss" xmlUrl="http://somedomain3.com/rss.xml"/>
| <outline title="custom &quot;6&quot;" text="custom &quot;6&quot;" type="rss" xmlUrl="http://somedomain6.com/rss.xml"/>
| <outline title="custom &quot;9&quot;" text="custom &quot;9&quot;" type="rss" xmlUrl="http://somedomain9.com/rss.xml"/>
| <outline title="tag1" text="tag1">
| <outline title="custom &quot;1&quot;" text="custom &quot;1&quot;" type="rss" xmlUrl="http://somedomain1.com/rss.xml"/>
| <outline title="custom &quot;4&quot;" text="custom &quot;4&quot;" type="rss" xmlUrl="http://somedomain4.com/rss.xml"/>
| <outline title="custom &quot;7&quot;" text="custom &quot;7&quot;" type="rss" xmlUrl="http://somedomain7.com/rss.xml"/>
| </outline>
| <outline title="tag2" text="tag2">
| <outline title="custom &quot;2&quot;" text="custom &quot;2&quot;" type="rss" xmlUrl="http://somedomain2.com/rss.xml"/>
| <outline title="custom &quot;5&quot;" text="custom &quot;5&quot;" type="rss" xmlUrl="http://somedomain5.com/rss.xml"/>
| <outline title="custom &quot;8&quot;" text="custom &quot;8&quot;" type="rss" xmlUrl="http://somedomain8.com/rss.xml"/>
| </outline>
| </body>
|</opml>""".trimMargin().split("\n")
@RunWith(AndroidJUnit4::class)
class OPMLTest {
private val context: Context = getApplicationContext()
lateinit var db: AppDatabase
private var dir: File? = null
private var path: File? = null
@Before
fun setup() {
// Get internal data dir
dir = createTempDir()
path = createTempFile()
assertTrue("Need to be able to write to data dir $dir", dir!!.canWrite())
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
@After
fun tearDown() {
// Remove everything in database
}
@MediumTest
@Test
@Throws(IOException::class)
fun testWrite() = runBlocking {
// Create some feeds
createSampleFeeds()
writeFile(
path!!.absolutePath,
getTags()
) { tag ->
db.feedDao().loadFeeds(tag = tag)
}
// check contents of file
path!!.bufferedReader().useLines { lines ->
lines.forEachIndexed { i, line ->
assertEquals("line $i differed", sampleFile[i], line)
}
}
}
@MediumTest
@Test
@Throws(Exception::class)
fun testRead() = runBlocking {
writeSampleFile()
val parser = OpmlParser(OPMLToRoom(db))
parser.parseFile(path!!.canonicalPath)
// Verify database is correct
val seen = ArrayList<Int>()
val feeds = db.feedDao().loadFeeds()
assertFalse("No feeds in DB!", feeds.isEmpty())
for (feed in feeds) {
val i = Integer.parseInt(feed.title.replace("[custom \"]".toRegex(), ""))
seen.add(i)
assertEquals("URL doesn't match", URL("http://somedomain$i.com/rss.xml"), feed.url)
when (i) {
0 -> {
assertEquals("title should be the same", "\"$i\"", feed.title)
assertEquals("custom title should have been set to title", "\"$i\"", feed.customTitle)
}
else -> {
assertEquals("custom title should have overridden title", "custom \"$i\"", feed.title)
assertEquals("title and custom title should match", feed.customTitle, feed.title)
}
}
when {
i % 3 == 1 -> assertEquals("tag1", feed.tag)
i % 3 == 2 -> assertEquals("tag2", feed.tag)
else -> assertEquals("", feed.tag)
}
}
for (i in 0..9) {
assertTrue("Missing $i", seen.contains(i))
}
}
@MediumTest
@Test
@Throws(Exception::class)
fun testReadExisting() = runBlocking {
writeSampleFile()
// Create something that does not exist
var feednew = Feed(
url = URL("http://somedomain20.com/rss.xml"),
title = "\"20\"",
tag = "kapow"
)
var id = db.feedDao().insertFeed(feednew)
feednew = feednew.copy(id = id)
// Create something that will exist
var feedold = Feed(
url = URL("http://somedomain0.com/rss.xml"),
title = "\"0\""
)
id = db.feedDao().insertFeed(feedold)
feedold = feedold.copy(id = id)
// Read file
val parser = OpmlParser(OPMLToRoom(db))
parser.parseFile(path!!.canonicalPath)
// should not kill the existing stuff
val seen = ArrayList<Int>()
val feeds = db.feedDao().loadFeeds()
assertFalse("No feeds in DB!", feeds.isEmpty())
for (feed in feeds) {
val i = Integer.parseInt(feed.title.replace("[custom \"]".toRegex(), ""))
seen.add(i)
assertEquals(URL("http://somedomain$i.com/rss.xml"), feed.url)
when {
i == 20 -> {
assertEquals("Should not have changed", feednew.id, feed.id)
assertEquals("Should not have changed", feednew.url, feed.url)
assertEquals("Should not have changed", feednew.tag, feed.tag)
}
i % 3 == 1 -> assertEquals("tag1", feed.tag)
i % 3 == 2 -> assertEquals("tag2", feed.tag)
else -> assertEquals("", feed.tag)
}
// Ensure titles are correct
when (i) {
0 -> {
assertEquals("title should be the same", feedold.title, feed.title)
assertEquals("custom title should have been set to title", feedold.title, feed.customTitle)
}
20 -> {
assertEquals("feed not present in OPML should not have changed", feednew.title, feed.title)
assertEquals("feed not present in OPML should not have changed", feednew.customTitle, feednew.customTitle)
}
else -> {
assertEquals("custom title should have overridden title", "custom \"$i\"", feed.title)
assertEquals("title and custom title should match", feed.customTitle, feed.title)
}
}
if (i == 0) {
// Make sure id is same as old
assertEquals("Id should be same still", feedold.id, feed.id)
}
}
assertTrue("Missing 20", seen.contains(20))
for (i in 0..9) {
assertTrue("Missing $i", seen.contains(i))
}
}
@MediumTest
@Test
@Throws(Exception::class)
fun testReadBadFile() = runBlocking {
// val path = File(dir, "feeds.opml")
path!!.bufferedWriter().use {
it.write("This is just some bullshit in the file\n")
}
// Read file
val parser = OpmlParser(OPMLToRoom(db))
parser.parseFile(path!!.absolutePath)
}
@SmallTest
@Test
@Throws(Exception::class)
fun testReadMissingFile() = runBlocking {
val path = File(dir, "lsadflibaslsdfa.opml")
// Read file
val parser = OpmlParser(OPMLToRoom(db))
var raised = false
try {
parser.parseFile(path.absolutePath)
} catch (e: IOException) {
raised = true
}
assertTrue("Should raise exception", raised)
}
@Throws(IOException::class)
private fun writeSampleFile() = runBlocking {
// Use test write to write the sample file
testWrite()
// Then delete all feeds again
db.runInTransaction {
runBlocking {
db.feedDao().loadFeeds().forEach {
db.feedDao().deleteFeed(it)
}
}
}
}
private suspend fun createSampleFeeds() {
for (i in 0..9) {
val feed = Feed(
url = URL("http://somedomain$i.com/rss.xml"),
title = "\"$i\"",
customTitle = if (i == 0) "" else "custom \"$i\"",
tag = when (i % 3) {
1 -> "tag1"
2 -> "tag2"
else -> ""
}
)
db.feedDao().insertFeed(feed)
}
}
private suspend fun getTags(): List<String> =
db.feedDao().loadTags()
@Test
@MediumTest
fun antennaPodOPMLImports() = runBlocking {
// given
val opmlStream = this@OPMLTest.javaClass.getResourceAsStream("antennapod-feeds.opml")!!
// when
val parser = OpmlParser(OPMLToRoom(db))
parser.parseInputStream(opmlStream)
// then
val feeds = db.feedDao().loadFeeds()
val tags = db.feedDao().loadTags()
assertEquals("Expecting 8 feeds", 8, feeds.size)
assertEquals("Expecting 1 tags (incl empty)", 1, tags.size)
feeds.forEach { feed ->
assertEquals("No tag expected", "", feed.tag)
when (feed.url) {
URL("http://aliceisntdead.libsyn.com/rss") -> {
assertEquals("Alice Isn't Dead", feed.title)
}
URL("http://feeds.soundcloud.com/users/soundcloud:users:154104768/sounds.rss") -> {
assertEquals("Invisible City", feed.title)
}
URL("http://feeds.feedburner.com/PodCastle_Main") -> {
assertEquals("PodCastle", feed.title)
}
URL("http://www.artofstorytellingshow.com/podcast/storycast.xml") -> {
assertEquals("The Art of Storytelling with Brother Wolf", feed.title)
}
URL("http://feeds.feedburner.com/TheCleansed") -> {
assertEquals("The Cleansed: A Post-Apocalyptic Saga", feed.title)
}
URL("http://media.signumuniversity.org/tolkienprof/feed") -> {
assertEquals("The Tolkien Professor", feed.title)
}
URL("http://nightvale.libsyn.com/rss") -> {
assertEquals("Welcome to Night Vale", feed.title)
}
URL("http://withinthewires.libsyn.com/rss") -> {
assertEquals("Within the Wires", feed.title)
}
else -> fail("Unexpected URI. Feed: $feed")
}
}
}
@Test
@MediumTest
fun flymOPMLImports() = runBlocking {
// given
val opmlStream = this@OPMLTest.javaClass.getResourceAsStream("Flym_auto_backup.opml")!!
// when
val parser = OpmlParser(OPMLToRoom(db))
parser.parseInputStream(opmlStream)
// then
val feeds = db.feedDao().loadFeeds()
val tags = db.feedDao().loadTags()
assertEquals("Expecting 11 feeds", 11, feeds.size)
assertEquals("Expecting 4 tags (incl empty)", 4, tags.size)
feeds.forEach { feed ->
when (feed.url) {
URL("http://www.smbc-comics.com/rss.php") -> {
assertEquals("black humor", feed.tag)
assertEquals("SMBC", feed.customTitle)
}
URL("http://www.deathbulge.com/rss.xml") -> {
assertEquals("black humor", feed.tag)
assertEquals("Deathbulge", feed.customTitle)
}
URL("http://www.sandraandwoo.com/gaia/feed/") -> {
assertEquals("comics", feed.tag)
assertEquals("Gaia", feed.customTitle)
}
URL("http://replaycomic.com/feed/") -> {
assertEquals("comics", feed.tag)
assertEquals("Replay", feed.customTitle)
}
URL("http://www.cuttimecomic.com/rss.php") -> {
assertEquals("comics", feed.tag)
assertEquals("Cut Time", feed.customTitle)
}
URL("http://www.commitstrip.com/feed/") -> {
assertEquals("comics", feed.tag)
assertEquals("Commit strip", feed.customTitle)
}
URL("http://www.sandraandwoo.com/feed/") -> {
assertEquals("comics", feed.tag)
assertEquals("Sandra and Woo", feed.customTitle)
}
URL("http://www.awakencomic.com/rss.php") -> {
assertEquals("comics", feed.tag)
assertEquals("Awaken", feed.customTitle)
}
URL("http://www.questionablecontent.net/QCRSS.xml") -> {
assertEquals("comics", feed.tag)
assertEquals("Questionable Content", feed.customTitle)
}
URL("https://www.archlinux.org/feeds/news/") -> {
assertEquals("Tech", feed.tag)
assertEquals("Arch news", feed.customTitle)
}
URL("https://grisebouille.net/feed/") -> {
assertEquals("Political humour", feed.tag)
assertEquals("Grisebouille", feed.customTitle)
}
else -> fail("Unexpected URI. Feed: $feed")
}
}
}
@Test
@MediumTest
fun rssGuardOPMLImports1() = runBlocking {
// given
val opmlStream = this@OPMLTest.javaClass.getResourceAsStream("rssguard_1.opml")!!
// when
val parser = OpmlParser(OPMLToRoom(db))
parser.parseInputStream(opmlStream)
// then
val feeds = db.feedDao().loadFeeds()
val tags = db.feedDao().loadTags()
assertEquals("Expecting 30 feeds", 30, feeds.size)
assertEquals("Expecting 6 tags (incl empty)", 6, tags.size)
feeds.forEach { feed ->
when (feed.url) {
URL("http://www.les-trois-sagesses.org/rss-articles.xml") -> {
assertEquals("Religion", feed.tag)
assertEquals("Les trois sagesses", feed.customTitle)
}
URL("http://www.avrildeperthuis.com/feed/") -> {
assertEquals("Amis", feed.tag)
assertEquals("avril de perthuis", feed.customTitle)
}
URL("http://www.fashioningtech.com/profiles/blog/feed?xn_auth=no") -> {
assertEquals("Actu Geek", feed.tag)
assertEquals("Everyone's Blog Posts - Fashioning Technology", feed.customTitle)
}
URL("http://feeds2.feedburner.com/ChartPorn") -> {
assertEquals("Graphs", feed.tag)
assertEquals("Chart Porn", feed.customTitle)
}
URL("http://www.mosqueedeparis.net/index.php?format=feed&amp;type=atom") -> {
assertEquals("Religion", feed.tag)
assertEquals("Mosquee de Paris", feed.customTitle)
}
URL("http://sourceforge.net/projects/stuntrally/rss") -> {
assertEquals("Mainstream update", feed.tag)
assertEquals("Stunt Rally", feed.customTitle)
}
URL("http://www.mairie6.lyon.fr/cs/Satellite?Thematique=&TypeContenu=Actualite&pagename=RSSFeed&site=Mairie6") -> {
assertEquals("", feed.tag)
assertEquals("Actualités", feed.customTitle)
}
}
}
}
@Test
@MediumTest
fun rssGuardOPMLImports2() = runBlocking {
// given
val opmlStream = this@OPMLTest.javaClass.getResourceAsStream("rssguard_2.opml")!!
// when
val parser = OpmlParser(OPMLToRoom(db))
parser.parseInputStream(opmlStream)
// then
val feeds = db.feedDao().loadFeeds()
val tags = db.feedDao().loadTags()
assertEquals("Expecting 30 feeds", 30, feeds.size)
assertEquals("Expecting 6 tags (incl empty)", 6, tags.size)
feeds.forEach { feed ->
when (feed.url) {
URL("http://www.les-trois-sagesses.org/rss-articles.xml") -> {
assertEquals("Religion", feed.tag)
assertEquals("Les trois sagesses", feed.customTitle)
}
URL("http://www.avrildeperthuis.com/feed/") -> {
assertEquals("Amis", feed.tag)
assertEquals("avril de perthuis", feed.customTitle)
}
URL("http://www.fashioningtech.com/profiles/blog/feed?xn_auth=no") -> {
assertEquals("Actu Geek", feed.tag)
assertEquals("Everyone's Blog Posts - Fashioning Technology", feed.customTitle)
}
URL("http://feeds2.feedburner.com/ChartPorn") -> {
assertEquals("Graphs", feed.tag)
assertEquals("Chart Porn", feed.customTitle)
}
URL("http://www.mosqueedeparis.net/index.php?format=feed&amp;type=atom") -> {
assertEquals("Religion", feed.tag)
assertEquals("Mosquee de Paris", feed.customTitle)
}
URL("http://sourceforge.net/projects/stuntrally/rss") -> {
assertEquals("Mainstream update", feed.tag)
assertEquals("Stunt Rally", feed.customTitle)
}
URL("http://www.mairie6.lyon.fr/cs/Satellite?Thematique=&TypeContenu=Actualite&pagename=RSSFeed&site=Mairie6") -> {
assertEquals("", feed.tag)
assertEquals("Actualités", feed.customTitle)
}
}
}
}
}

View file

@ -0,0 +1,46 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers.hasTextColor
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.util.Prefs
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
@RunWith(AndroidJUnit4::class)
@LargeTest
class AddFeedDialogThemeTest {
@get:Rule
var activityRule: ActivityTestRule<EditFeedActivity> = ActivityTestRule(EditFeedActivity::class.java, false, false)
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Test
fun startsInDarkModeIfSet() {
prefs.isNightMode = true
activityRule.launchActivity(null)
onView(withId(R.id.feed_url)).check(ViewAssertions.matches(hasTextColor(R.color.white_87)))
}
@Test
fun startsInLightModeIfSet() {
prefs.isNightMode = false
activityRule.launchActivity(null)
onView(withId(R.id.feed_url)).check(ViewAssertions.matches(hasTextColor(R.color.black_87)))
}
}

View file

@ -0,0 +1,209 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.URI_FEEDITEMS
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.model.insertFeedItemWithBlob
import com.nononsenseapps.feeder.util.Prefs
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class BadImagePlaceHolderArticleTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private val server = MockWebServer()
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Before
fun startServer() {
server.enqueue(
MockResponse().also {
it.setResponseCode(400)
}
)
server.start()
}
@After
fun stopServer() {
server.shutdown()
}
@Test
fun placeHolderIsShownOnBadImageNightTheme() = runBlocking {
prefs.isNightMode = true
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_YES)
val imgUrl = server.url("/img.png")
val itemId = insertData(imgUrl) {
"""
Image is: <img src="$imgUrl" alt="alt text"></img>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
runBlocking {
delay(50)
}
onView(withId(R.id.story_body))
.check(matches(withText(containsString("alt text"))))
}
@Test
fun placeHolderIsShownOnBadImageDayTheme() = runBlocking {
prefs.isNightMode = false
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_NO)
val imgUrl = server.url("/img.png")
val itemId = insertData(imgUrl) {
"""
Image is: <img src="$imgUrl" alt="alt text"></img>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
runBlocking {
delay(50)
}
onView(withId(R.id.story_body))
.check(matches(withText(containsString("alt text"))))
}
@Test
fun imgWithNoSrcIsNotDisplayed() = runBlocking {
val itemId = insertData {
"""
Image is: <img src="" alt="alt text"></img>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
runBlocking {
delay(50)
}
onView(withId(R.id.story_body))
.check(matches(withText(not(containsString("alt text")))))
}
@Test
fun imgHasAltTextDisplayed() = runBlocking {
val imgUrl = server.url("/img.png")
val itemId = insertData {
"""
Image is: <img src="$imgUrl" alt="alt text"></img>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
runBlocking {
delay(50)
}
onView(withId(R.id.story_body))
.check(matches(withText(containsString("alt text"))))
}
@Test
fun imgAppendsNewLineBeforeAndAfter() = runBlocking {
val imgUrl = server.url("/img.png")
val itemId = insertData {
"""
Image is:<img src="$imgUrl" alt="alt text"></img>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
runBlocking {
delay(50)
}
onView(withId(R.id.story_body))
.check(matches(withText(containsString("Image is:\n"))))
onView(withId(R.id.story_body))
.check(matches(withText(containsString("\nalt text\n"))))
}
private suspend fun insertData(imgUrl: HttpUrl? = null, description: () -> String): Long {
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http://foo")
)
)
return testDb.insertFeedItemWithBlob(
FeedItem(
guid = "bar",
feedId = feedId,
title = "foo",
imageUrl = imgUrl?.let { "$it" }
),
description = description()
)
}
}
fun ActivityTestRule<FeedActivity>.launchReader(itemId: Long) =
launchActivity(
Intent().also {
it.data = URI_FEEDITEMS.buildUpon().appendPath("$itemId").build()
}
)

View file

@ -0,0 +1,118 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.View
import android.widget.ImageView
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.URI_FEEDS
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.util.Prefs
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class BadImagePlaceHolderTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private val server = MockWebServer()
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@After
fun stopServer() {
server.shutdown()
}
@Test
fun placeHolderIsShownOnBadImageNightTheme() = runBlocking {
prefs.isNightMode = true
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_YES)
placeHolderIsShownOnBadImage()
}
@Test
fun placeHolderIsShownOnBadImageDayTheme() = runBlocking {
prefs.isNightMode = false
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_NO)
placeHolderIsShownOnBadImage()
}
private suspend fun placeHolderIsShownOnBadImage() {
server.enqueue(
MockResponse().also {
it.setResponseCode(400)
}
)
server.start()
val imgUrl = server.url("/img.png")
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http://foo")
)
)
testDb.db.feedItemDao().insertFeedItem(
FeedItem(
guid = "bar",
feedId = feedId,
title = "foo",
imageUrl = "$imgUrl"
)
)
activityRule.launchActivity(Intent(Intent.ACTION_VIEW, Uri.withAppendedPath(URI_FEEDS, "$feedId")))
runBlocking {
delay(50)
val recyclerView = activityRule.activity.findViewById<RecyclerView>(android.R.id.list)!!
val viewHolder = recyclerView.findViewHolderForAdapterPosition(0)!!
val imageView = viewHolder.itemView.findViewById<ImageView>(R.id.story_image)!!
withTimeout(10000) {
while (true) {
if (imageView.visibility == View.VISIBLE && imageView.drawable != null) {
break // good
}
delay(50)
}
}
}
onView(withId(R.id.story_image)).check(matches(isDisplayed()))
}
}

View file

@ -0,0 +1,94 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.URI_FEEDS
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.util.Prefs
import kotlinx.android.synthetic.main.content_navigation.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class CustomFeedTitleIsShownInListItemsTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Before
fun keepNavDrawerClosed() {
prefs.welcomeDone = true
}
@Test
fun feedTitleIsShownIfNoCustomTitle() = runBlocking {
insertDataAndLaunch("foo", "")
assertFeedTitleShownIs("foo")
}
@Test
fun customTitleIsShownIfCustomTitle() = runBlocking {
insertDataAndLaunch("foo", "bar")
assertFeedTitleShownIs("bar")
}
private suspend fun insertDataAndLaunch(title: String, customTitle: String) {
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = title,
customTitle = customTitle,
url = URL("http://foo")
)
)
testDb.db.feedItemDao().insertFeedItem(
FeedItem(
guid = "fooitem1",
feedId = feedId,
title = "fooitem"
)
)
activityRule.launchActivity(Intent(Intent.ACTION_VIEW, Uri.withAppendedPath(URI_FEEDS, "$feedId")))
}
private fun assertFeedTitleShownIs(title: String) {
runBlocking {
val recyclerView = activityRule.activity.nav_host_fragment?.view?.findViewById<RecyclerView>(android.R.id.list)!!
withTimeout(200) {
while (recyclerView.findViewHolderForAdapterPosition(0) == null) {
delay(20)
}
}
onView(withId(R.id.story_author)).check(matches(withText(title)))
}
}
}

View file

@ -0,0 +1,76 @@
package com.nononsenseapps.feeder.ui
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class CustomFeedTitleIsShownInReaderTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
@Test
fun feedTitleIsShownIfNoCustomTitle() = runBlocking {
insertDataAndLaunch("foo", "")
assertFeedTitleShownIs("foo")
}
@Test
fun customTitleIsShownIfCustomTitle() = runBlocking {
insertDataAndLaunch("foo", "bar")
assertFeedTitleShownIs("bar")
}
private suspend fun insertDataAndLaunch(title: String, customTitle: String) {
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = title,
customTitle = customTitle,
url = URL("http://foo")
)
)
val feedItemId = testDb.db.feedItemDao().insertFeedItem(
FeedItem(
guid = "fooitem1",
feedId = feedId,
title = "fooitem"
)
)
activityRule.launchReader(feedItemId)
}
private fun assertFeedTitleShownIs(title: String) {
runBlocking {
val feedTitle = activityRule.activity.findViewById<TextView>(R.id.story_feedtitle)!!
withTimeout(200) {
while (feedTitle.text.toString() != title) {
delay(20)
}
}
onView(withId(R.id.story_feedtitle)).check(matches(withText(title)))
}
}
}

View file

@ -0,0 +1,160 @@
package com.nononsenseapps.feeder.ui
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.pressImeActionButton
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.ui.MockResponses.cowboy_feed_json_body
import com.nononsenseapps.feeder.ui.MockResponses.cowboyprogrammer_feed_json_headers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.hamcrest.Matchers.not
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class EditFeedTest {
@get:Rule
var activityRule: ActivityTestRule<EditFeedActivity> = ActivityTestRule(EditFeedActivity::class.java)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
val server = MockWebServer()
@After
fun stopServer() {
server.shutdown()
}
@Test
fun activityStarts() {
assertNotNull(activityRule.activity)
}
@Test
@Ignore("Not sure how to make a bad URL")
fun badUrlDisplaysEmptyView() {
onView(withId(android.R.id.empty)).check(matches(not(isDisplayed())))
onView(withId(R.id.search_view))
.perform(
typeText("abc123-_.\\"),
pressImeActionButton()
)
runBlocking {
untilEq(true) {
activityRule.activity?.searchJob?.isCompleted
}
}
onView(withId(android.R.id.empty)).check(matches(isDisplayed()))
}
@Test
fun badResponseShowsEmptyView() {
server.enqueue(
MockResponse().also {
it.setBody("NOT VALID XML")
}
)
server.start()
val url = server.url("/rss.xml")
onView(withId(android.R.id.empty)).check(matches(not(isDisplayed())))
onView(withId(R.id.search_view))
.perform(
typeText("$url"),
pressImeActionButton()
)
runBlocking {
untilEq(true) {
activityRule.activity?.searchJob?.isCompleted
}
}
onView(withId(android.R.id.empty)).check(matches(isDisplayed()))
}
@Test
fun endToEnd() = runBlocking {
val response = MockResponse().also {
it.setBody(cowboy_feed_json_body)
cowboyprogrammer_feed_json_headers.entries.forEach { entry ->
it.setHeader(entry.key, entry.value)
}
}
server.enqueue(response)
server.enqueue(response)
server.start()
val url = server.url("/feed.json")
onView(withId(android.R.id.empty)).check(matches(not(isDisplayed())))
onView(withId(R.id.search_view))
.perform(
typeText("$url"),
pressImeActionButton()
)
val recyclerView: RecyclerView = activityRule.activity.findViewById(R.id.results_listview)
// Wait for search to be done
untilEq(true) {
activityRule.activity?.searchJob?.isCompleted
}
// Then wait for recyclerView to update
untilNotEq(null) {
recyclerView.findViewHolderForAdapterPosition(0)
}
// Assert the feed was retrieved
val request = server.takeRequest()
assertEquals("/feed.json", request.path)
val viewHolder = recyclerView.findViewHolderForAdapterPosition(0)!!
assertEquals(
"https://cowboyprogrammer.org/feed.json",
viewHolder.itemView.findViewById<TextView>(R.id.feed_url)!!.text
)
onView(withId(R.id.results_listview)).perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click())
)
onView(withId(R.id.feed_details_frame)).check(matches(isDisplayed()))
Espresso.closeSoftKeyboard()
onView(withId(R.id.add_button)).perform(click())
untilEq(false) {
activityRule.activity?.lifecycleScope?.isActive
}
val feed = untilNotEq(null) {
testDb.db.feedDao().loadFeedWithUrl(URL("https://cowboyprogrammer.org/feed.json"))
}
assertEquals(
"Cowboy Programmer",
feed?.title
)
}
}

View file

@ -0,0 +1,22 @@
package com.nononsenseapps.feeder.ui
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class FeedsTest {
@Rule
@JvmField
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java)
@Test
fun activityStarts() {
assertNotNull(activityRule.activity)
}
}

View file

@ -0,0 +1,72 @@
package com.nononsenseapps.feeder.ui
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
/**
* Delays until the factory provides the correct object
*/
suspend fun <T> whileNotEq(
other: Any?,
timeoutMillis: Long = 500,
sleepMillis: Long = 50,
body: (suspend () -> T)
): T =
withTimeout(timeoutMillis) {
var item = body.invoke()
while (item != other) {
delay(sleepMillis)
item = body.invoke()
}
item
}
/**
* Delays until the factory provides a different object
*/
suspend fun <T> whileEq(
other: Any?,
timeoutMillis: Long = 500,
sleepMillis: Long = 50,
body: (suspend () -> T)
): T =
withTimeout(timeoutMillis) {
var item = body.invoke()
while (item == other) {
delay(sleepMillis)
item = body.invoke()
}
item
}
/**
* Delays until the factory provides a different object
*/
suspend fun <T> untilNotEq(
other: Any?,
timeoutMillis: Long = 500,
sleepMillis: Long = 50,
body: (suspend () -> T)
): T =
whileEq(
other = other,
timeoutMillis = timeoutMillis,
sleepMillis = sleepMillis,
body = body
)
/**
* Delays until the factory provides the correct object
*/
suspend fun <T> untilEq(
other: Any?,
timeoutMillis: Long = 500,
sleepMillis: Long = 50,
body: (suspend () -> T)
): T =
whileNotEq(
other = other,
timeoutMillis = timeoutMillis,
sleepMillis = sleepMillis,
body = body
)

View file

@ -0,0 +1 @@
package com.nononsenseapps.feeder.ui

View file

@ -0,0 +1,17 @@
package com.nononsenseapps.feeder.ui
object MockResponses {
val cowboyprogrammer_feed_json_headers = mapOf(
"cache-control" to "public",
"content-type" to "application/json",
"date" to "Tue, 30 Oct 2018 14:25:58 GMT",
"etag" to "W/\"5b6ca19c-146ab\"",
"expires" to "Tue, 30 Oct 2018 15:25:58 GMT",
"last-modified" to "Thu, 09 Aug 2018 20:18:36 GMT",
"vary" to "Accept-Encoding"
)
val cowboy_feed_json_body: String
get() = String(javaClass.getResourceAsStream("cowboy_feed.json")!!.readBytes())
}

View file

@ -0,0 +1,99 @@
package com.nononsenseapps.feeder.ui
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.db.room.FeedItemWithFeed
import com.nononsenseapps.feeder.model.RssNotificationBroadcastReceiver
import com.nononsenseapps.feeder.model.getDeleteIntent
import com.nononsenseapps.feeder.model.notify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URL
@RunWith(AndroidJUnit4::class)
class NotificationClearingTest {
private val receiver: RssNotificationBroadcastReceiver = RssNotificationBroadcastReceiver()
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
@Test
fun clearingNotificationMarksAsNotified() = runBlocking {
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "testFeed",
url = URL("http://testfeed"),
tag = "testTag"
)
)
val item1Id = testDb.db.feedItemDao().insertFeedItem(
FeedItem(
feedId = feedId,
guid = "item1",
title = "item1",
notified = false
)
)
val di = getDeleteIntent(
getApplicationContext(),
FeedItemWithFeed(
id = item1Id, feedId = feedId, guid = "item1", title = "item1"
)
)
runBlocking {
// Receiver runs on main thread
withContext(Dispatchers.Main) {
receiver.onReceive(getApplicationContext(), di)
}
delay(50)
val item = testDb.db.feedItemDao().loadFeedItem(guid = "item1", feedId = feedId)
assertTrue(item!!.notified)
}
}
@Test
fun notifyWorksOnMainThread() = runBlocking {
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "testFeed",
url = URL("http://testfeed"),
tag = "testTag"
)
)
testDb.db.feedItemDao().insertFeedItem(
FeedItem(
feedId = feedId,
guid = "item1",
title = "item1",
notified = false
)
)
runBlocking {
// Try to notify on main thread
withContext(Dispatchers.Main) {
notify(getApplicationContext())
}
delay(50)
// Only care that the above call didn't crash because we ran on the main thread
val item = testDb.db.feedItemDao().loadFeedItem(guid = "item1", feedId = feedId)
assertFalse(item!!.notified)
}
}
}

View file

@ -0,0 +1,77 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.URI_FEEDITEMS
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.util.Prefs
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.net.URL
@RunWith(AndroidJUnit4::class)
class OpenFeedFromTitleTest {
@get:Rule
val activityRule = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private lateinit var feedItem: FeedItem
private var feedId: Long? = null
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Before
fun keepNavDrawerClosed() {
prefs.welcomeDone = true
}
@Before
fun setup() = runBlocking {
feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "ANON",
url = URL("http://ANON.com/sub")
)
)
val item = FeedItem(
feedId = feedId,
guid = "http://ANON.com/sub/##",
title = "ANON",
plainTitle = "ANON",
plainSnippet = "ANON"
)
val feedItemId = testDb.db.feedItemDao().insertFeedItem(item)
feedItem = item.copy(id = feedItemId)
}
@Test
fun clickingFirstItemOpensReader() {
activityRule.launchActivity(Intent(Intent.ACTION_VIEW, Uri.withAppendedPath(URI_FEEDITEMS, "${feedItem.id}")))
onView(withId(R.id.story_feedtitle))
.perform(click())
onView(withId(android.R.id.list)).check(matches(isDisplayed()))
}
}

View file

@ -0,0 +1,79 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.URI_FEEDS
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.util.Prefs
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.net.URL
@RunWith(AndroidJUnit4::class)
class OpenFeedItemTest {
@get:Rule
val activityRule = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private lateinit var feedItem: FeedItem
private var feedId: Long? = null
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Before
fun keepNavDrawerClosed() {
prefs.welcomeDone = true
}
@Before
fun setup() = runBlocking {
feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "ANON",
url = URL("http://ANON.com/sub")
)
)
val item = FeedItem(
feedId = feedId,
guid = "http://ANON.com/sub/##",
title = "ANON",
plainTitle = "ANON",
plainSnippet = "ANON"
)
val feedItemId = testDb.db.feedItemDao().insertFeedItem(item)
feedItem = item.copy(id = feedItemId)
}
@Test
fun clickingFirstItemOpensReader() {
activityRule.launchActivity(Intent(Intent.ACTION_VIEW, Uri.withAppendedPath(URI_FEEDS, "$feedId")))
onView(withId(android.R.id.list))
.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))
onView(withId(R.id.story_body)).check(matches(isDisplayed()))
}
}

View file

@ -0,0 +1,93 @@
package com.nononsenseapps.feeder.ui
import android.content.Intent
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import com.nononsenseapps.feeder.db.URI_FEEDITEMS
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.model.getOpenInDefaultActivityIntent
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URL
import kotlin.test.assertFalse
@RunWith(AndroidJUnit4::class)
class OpenLinkInDefaultActivityTaskTest {
@get:Rule
val activityTestRule = ActivityTestRule(OpenLinkInDefaultActivity::class.java, false, false)
@get:Rule
val mainTaskTestRule = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private lateinit var feedItem: FeedItem
@Before
fun setup() = runBlocking {
val db = testDb.db
val feedId = db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http://foo")
)
)
val item = FeedItem(
feedId = feedId,
guid = "foobar",
title = "bla",
link = "http://foo",
notified = false,
unread = true
)
val feedItemId = db.feedItemDao().insertFeedItem(item)
feedItem = item.copy(id = feedItemId)
}
@After
fun pressHome() {
UiDevice.getInstance(getInstrumentation()).pressHome()
}
@Test
fun openInBrowserThenGoingBackDoesNotGoToMainTask() {
mainTaskTestRule.launchActivity(
Intent(
Intent.ACTION_VIEW,
URI_FEEDITEMS.buildUpon().appendPath("${feedItem.id}").build()
)
)
UiDevice.getInstance(getInstrumentation()).pressHome()
activityTestRule.launchActivity(
getOpenInDefaultActivityIntent(
getApplicationContext(),
feedItemId = feedItem.id,
link = feedItem.link!!
)
)
// Hack - first back exits browser, second back exits main task if it is shown after the first back
// if it's not shown, then pressing back will not finish it
UiDevice.getInstance(getInstrumentation()).pressBack()
UiDevice.getInstance(getInstrumentation()).pressBack()
assertFalse(
mainTaskTestRule.activity.isFinishing,
message = "Main activity should not be on screen after pressing back"
)
}
}

View file

@ -0,0 +1,144 @@
package com.nononsenseapps.feeder.ui
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.rule.ActivityTestRule
import androidx.test.uiautomator.UiDevice
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.model.getOpenInDefaultActivityIntent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URL
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
class OpenLinkInDefaultActivityTest {
@get:Rule
val activityTestRule = ActivityTestRule(OpenLinkInDefaultActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private lateinit var feedItem: FeedItem
@Before
fun setup() = runBlocking {
val db = testDb.db
val feedId = db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http://foo")
)
)
val item = FeedItem(
feedId = feedId,
guid = "foobar",
title = "bla",
link = "http://foo",
notified = false,
unread = true
)
val feedItemId = db.feedItemDao().insertFeedItem(item)
feedItem = item.copy(id = feedItemId)
}
@After
fun pressHome() {
UiDevice.getInstance(getInstrumentation()).pressBack()
}
@Test
fun noIntentDoesNothing() {
activityTestRule.launchActivity(null)
runBlocking {
val item = withContext(Dispatchers.Default) {
untilEq(feedItem) {
testDb.db.feedItemDao().loadFeedItem(feedItem.id)
}
}
assertEquals(feedItem, item)
}
}
@Test
fun faultyLinkDoesntCrash() {
activityTestRule.launchActivity(
getOpenInDefaultActivityIntent(
getApplicationContext(),
feedItemId = -252,
link = "bob"
)
)
runBlocking {
val item = withContext(Dispatchers.Default) {
untilEq(feedItem) {
testDb.db.feedItemDao().loadFeedItem(feedItem.id)
}
}
assertEquals(feedItem, item)
}
}
@Test
fun withIntentItemIsMarkedAsReadAndNotified() {
activityTestRule.launchActivity(
getOpenInDefaultActivityIntent(
getApplicationContext(),
feedItemId = feedItem.id,
link = feedItem.link!!
)
)
val expected = feedItem.copy(
unread = false,
notified = true
)
runBlocking {
val item = withContext(Dispatchers.Default) {
untilEq(expected) {
testDb.db.feedItemDao().loadFeedItem(feedItem.id)
}
}
assertEquals(expected, item)
}
}
@Test
fun noLinkButItemIsMarkedAsReadAndNotified() {
activityTestRule.launchActivity(
getOpenInDefaultActivityIntent(
getApplicationContext(),
feedItemId = feedItem.id
)
)
val expected = feedItem.copy(
unread = false,
notified = true
)
runBlocking {
val item = withContext(Dispatchers.Default) {
untilEq(expected) {
testDb.db.feedItemDao().loadFeedItem(feedItem.id)
}
}
assertEquals(expected, item)
}
}
}

View file

@ -0,0 +1,44 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.util.Prefs
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
@RunWith(AndroidJUnit4::class)
@LargeTest
class ReportBugTest {
@get:Rule
val activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java)
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Before
fun keepNavDrawerClosed() {
prefs.welcomeDone = true
}
@Test
fun clickingReportBugOpensEmailComposer() {
openActionBarOverflowOrOptionsMenu(getApplicationContext())
onView(withText(R.string.send_bug_report)).perform(click())
// Can't assert anything except that it didn't crash
assertNotNull(activityRule.activity)
}
}

View file

@ -0,0 +1,24 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import androidx.room.Room
import com.nononsenseapps.feeder.db.room.AppDatabase
import org.junit.rules.ExternalResource
class TestDatabaseRule(val context: Context) : ExternalResource() {
lateinit var db: AppDatabase
override fun before() {
db = Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
).build().also {
// Ensure all classes use test database
AppDatabase.setInstance(it)
}
}
override fun after() {
db.close()
}
}

View file

@ -0,0 +1,101 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers.hasTextColor
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.util.Prefs
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class WebViewThemeResettingTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private val server = MockWebServer()
private val kodein by closestKodein(getApplicationContext() as Context)
private val prefs by kodein.instance<Prefs>()
@Before
fun setup() = runBlocking {
server.enqueue(
MockResponse().also {
it.setBody("<html><body>Hello!</body></html>")
}
)
server.start()
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http:")
)
)
val feedItemId = testDb.db.feedItemDao().insertFeedItem(
FeedItem(
guid = "bar",
feedId = feedId,
title = "foo",
imageUrl = null,
link = server.url("/bar.html").toUrl().toString()
)
)
prefs.isNightMode = true
activityRule.launchReader(feedItemId)
}
@Test
fun webViewDoesNotResetTheme() {
runBlocking {
assertTextColorIsReadableInNightMode()
delay(10)
openWebView()
delay(10)
pressBack()
delay(10)
assertTextColorIsReadableInNightMode()
}
}
private fun assertTextColorIsReadableInNightMode() {
onView(withId(com.nononsenseapps.feeder.R.id.story_body))
.check(ViewAssertions.matches(hasTextColor(com.nononsenseapps.feeder.R.color.white_87)))
}
private fun openWebView() =
onView(withId(com.nononsenseapps.feeder.R.id.action_open_in_webview))
.perform(click())
}

View file

@ -0,0 +1,122 @@
package com.nononsenseapps.feeder.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.model.insertFeedItemWithBlob
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URL
@RunWith(AndroidJUnit4::class)
@LargeTest
class YoutubePlaceHolderArticleTest {
@get:Rule
var activityRule: ActivityTestRule<FeedActivity> = ActivityTestRule(FeedActivity::class.java, false, false)
@get:Rule
val testDb = TestDatabaseRule(getApplicationContext())
private val server = MockWebServer()
@After
fun stopServer() {
server.shutdown()
}
@Test
fun placeHolderIsShownForYoutubeIframes() = runBlocking {
val itemId = setup("youtube.com/embed/foo") { imgUrl ->
"""
Video is: <iframe src="$imgUrl"></iframe>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
onView(withId(R.id.story_body))
.check(
matches(
withText(
containsString(
getApplicationContext<Context>()
.getString(R.string.touch_to_play_video)
)
)
)
)
}
@Test
fun placeHolderIsNotShownForBadIframes() = runBlocking {
val itemId = setup("badsite.com/foo") { imgUrl ->
"""
Video is: <iframe src="$imgUrl"></iframe>
<p>
And that is that
""".trimIndent()
}
activityRule.launchReader(itemId)
onView(withId(R.id.story_body))
.check(
matches(
withText(
not(
containsString(
getApplicationContext<Context>()
.getString(R.string.touch_to_play_video)
)
)
)
)
)
}
private suspend fun setup(urlSuffix: String, description: (HttpUrl) -> String): Long {
server.enqueue(
MockResponse().also {
it.setResponseCode(400)
}
)
server.start()
val imgUrl = server.url(urlSuffix)
val feedId = testDb.db.feedDao().insertFeed(
Feed(
title = "foo",
url = URL("http://foo")
)
)
return testDb.insertFeedItemWithBlob(
FeedItem(
guid = "bar",
feedId = feedId,
title = "foo",
imageUrl = "$imgUrl"
),
description = description(imgUrl)
)
}
}

View file

@ -0,0 +1,79 @@
package com.nononsenseapps.feeder.ui.text
import android.content.Context
import android.graphics.Point
import android.text.style.ImageSpan
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import java.io.StringReader
import java.net.URL
@RunWith(AndroidJUnit4::class)
@MediumTest
class SpannedConverterImageTest {
private val kodein by closestKodein(getApplicationContext() as Context)
@Test
@Throws(Exception::class)
fun imgGetsPlaceHolderInserted() {
val builder = FakeBuilder2()
toSpannedWithNoImages(
kodein,
StringReader("<img src=\"https://foo.com/bar.gif\">"),
URL("http://foo.com"),
Point(100, 100),
builder,
null
)
assertEquals(1, builder.getAllSpansWithType<ImageSpan>().size)
}
@Test
@Throws(Exception::class)
fun imgWithNoSrcGetsNoPlaceHolder() {
val builder = FakeBuilder2()
toSpannedWithNoImages(
kodein,
StringReader("<img src=\"\">"),
URL("http://foo.com"),
Point(100, 100),
builder,
null
)
assertEquals(emptyList<ImageSpan>(), builder.getAllSpansWithType<ImageSpan>())
}
}
internal class FakeBuilder2 : SensibleSpannableStringBuilder() {
private val builder: StringBuilder = StringBuilder()
private val spans: ArrayList<Any?> = ArrayList()
override fun append(text: CharSequence?): SensibleSpannableStringBuilder {
builder.append(text)
return this
}
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
spans.add(what)
}
override fun getAllSpans(): List<Any?> = spans
override fun get(where: Int): Char {
return builder[where]
}
override val length: Int
get() = builder.length
override fun toString(): String {
return builder.toString()
}
}

View file

@ -0,0 +1,36 @@
package com.nononsenseapps.feeder.ui.text
import android.content.Context
import android.graphics.Point
import android.text.style.BulletSpan
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import java.io.StringReader
import java.net.URL
@RunWith(AndroidJUnit4::class)
@MediumTest
class SpannedConverterListTest {
private val kodein by closestKodein(getApplicationContext() as Context)
@Test
@Throws(Exception::class)
fun nakedLiTagIsBulletized() {
val builder = FakeBuilder2()
toSpannedWithNoImages(
kodein,
StringReader("Some <li> bullet </li> text"),
URL("http://foo.com"),
Point(100, 100),
builder,
null
)
assertEquals(1, builder.getAllSpansWithType<BulletSpan>().size)
}
}

View file

@ -0,0 +1,83 @@
package com.nononsenseapps.feeder.ui.text
import android.content.Context
import android.graphics.Point
import android.text.style.BackgroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.TypefaceSpan
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.kodein.di.android.closestKodein
import java.io.StringReader
import java.net.URL
@RunWith(AndroidJUnit4::class)
@MediumTest
class SpannedConverterPreTest {
private val kodein by closestKodein(getApplicationContext() as Context)
@Test
@Throws(Exception::class)
fun preIsMonospaced() {
val builder = FakeBuilder2()
toSpannedWithNoImages(
kodein,
StringReader("Some <pre>pre formatted</pre> text"),
URL("http://foo.com"),
Point(100, 100),
builder,
null
)
assertEquals(1, builder.getAllSpansWithType<TypefaceSpan>().size)
assertEquals(0, builder.getAllSpansWithType<RelativeSizeSpan>().size)
assertEquals(0, builder.getAllSpansWithType<BackgroundColorSpan>().size)
assertTrue(builder.toString().contains("pre formatted"))
}
@Test
@Throws(Exception::class)
fun codeIsMonospacedAndMore() {
val builder = FakeBuilder2()
toSpannedWithNoImages(
kodein,
StringReader("Some <code>code formatted</code> text"),
URL("http://foo.com"),
Point(100, 100),
builder,
null
)
assertEquals(1, builder.getAllSpansWithType<TypefaceSpan>().size)
assertEquals(1, builder.getAllSpansWithType<RelativeSizeSpan>().size)
assertEquals(1, builder.getAllSpansWithType<BackgroundColorSpan>().size)
assertTrue(builder.toString().contains("code formatted"))
}
@Test
@Throws(Exception::class)
fun preCodeIsMonospacedAndMore() {
val builder = FakeBuilder2()
toSpannedWithNoImages(
kodein,
StringReader("Some <pre><code>pre code formatted</code></pre> text"),
URL("http://foo.com"),
Point(100, 100),
builder,
null
)
assertEquals(2, builder.getAllSpansWithType<TypefaceSpan>().size)
assertEquals(1, builder.getAllSpansWithType<RelativeSizeSpan>().size)
assertEquals(1, builder.getAllSpansWithType<BackgroundColorSpan>().size)
assertTrue(builder.toString().contains("pre code formatted"))
}
}

View file

@ -0,0 +1,87 @@
package com.nononsenseapps.feeder.util
import android.content.Context
import android.content.Intent.ACTION_SENDTO
import android.content.Intent.ACTION_VIEW
import android.content.Intent.EXTRA_EMAIL
import android.content.Intent.EXTRA_SUBJECT
import android.content.Intent.EXTRA_TEXT
import android.net.Uri
import android.os.Build
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.nononsenseapps.feeder.BuildConfig
import com.nononsenseapps.feeder.R
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class BugReportKTest {
@Test
fun bodyContainsAndroidInformation() {
assertEquals(
"""
${BuildConfig.APPLICATION_ID} (flavor ${BuildConfig.BUILD_TYPE.ifBlank { "None" }})
version ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})
on Android ${Build.VERSION.RELEASE} (SDK-${Build.VERSION.SDK_INT})
on a Tablet? No
Describe your issue and how to reproduce it below:
""".trimIndent(),
emailBody(false)
)
}
@Test
fun bodyContainsAndroidInformationAsTablet() {
assertEquals(
"""
${BuildConfig.APPLICATION_ID} (flavor ${BuildConfig.BUILD_TYPE.ifBlank { "None" }})
version ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})
on Android ${Build.VERSION.RELEASE} (SDK-${Build.VERSION.SDK_INT})
on a Tablet? Yes
Describe your issue and how to reproduce it below:
""".trimIndent(),
emailBody(true)
)
}
@Test
fun subjectIsSensible() {
assertEquals(
"Bug report for Feeder",
emailSubject()
)
}
@Test
fun emailAddressIsCorrect() {
assertEquals(
"jonas.feederbugs@cowboyprogrammer.org",
emailReportAddress()
)
}
@Test
fun emailIntentIsCorrect() {
val intent = emailBugReportIntent(getApplicationContext())
assertEquals(ACTION_SENDTO, intent.action)
assertEquals(Uri.parse("mailto:${emailReportAddress()}"), intent.data)
assertEquals(emailSubject(), intent.getStringExtra(EXTRA_SUBJECT))
assertEquals(emailBody(getApplicationContext<Context>().resources.getBoolean(R.bool.isTablet)), intent.getStringExtra(EXTRA_TEXT))
assertEquals(emailReportAddress(), intent.getStringExtra(EXTRA_EMAIL))
}
@Test
fun issuesIntentIsCorrect() {
val intent = openGitlabIssues()
assertEquals(ACTION_VIEW, intent.action)
assertEquals(Uri.parse("https://gitlab.com/spacecowboy/Feeder/issues"), intent.data)
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,28 @@
<?xml version='1.0' encoding='utf-8'?>
<opml version='1.1'>
<head>
<title>Flym export</title>
<dateCreated>1498468298144</dateCreated>
</head>
<body>
<outline title='black humor'>
<outline title='SMBC' type='rss' xmlUrl='http://www.smbc-comics.com/rss.php' retrieveFullText='false'/>
<outline title='Deathbulge' type='rss' xmlUrl='http://www.deathbulge.com/rss.xml' retrieveFullText='true'/>
</outline>
<outline title='comics'>
<outline title='Gaia' type='rss' xmlUrl='http://www.sandraandwoo.com/gaia/feed/' retrieveFullText='false'/>
<outline title='Replay' type='rss' xmlUrl='http://replaycomic.com/feed/' retrieveFullText='true'/>
<outline title='Cut Time' type='rss' xmlUrl='http://www.cuttimecomic.com/rss.php' retrieveFullText='false'/>
<outline title='Commit strip' type='rss' xmlUrl='http://www.commitstrip.com/feed/' retrieveFullText='true'/>
<outline title='Sandra and Woo' type='rss' xmlUrl='http://www.sandraandwoo.com/feed/' retrieveFullText='false'/>
<outline title='Awaken' type='rss' xmlUrl='http://www.awakencomic.com/rss.php' retrieveFullText='true'/>
<outline title='Questionable Content' type='rss' xmlUrl='http://www.questionablecontent.net/QCRSS.xml' retrieveFullText='false'/>
</outline>
<outline title='Tech'>
<outline title='Arch news' type='rss' xmlUrl='https://www.archlinux.org/feeds/news/' retrieveFullText='false'/>
</outline>
<outline title='Political humour'>
<outline title='Grisebouille' type='rss' xmlUrl='https://grisebouille.net/feed/' retrieveFullText='true'/>
</outline>
</body>
</opml>

View file

@ -0,0 +1,17 @@
<?xml version='1.0' encoding='UTF-8' standalone='no' ?>
<opml version="2.0">
<head>
<title>AntennaPod Subscriptions</title>
<dateCreated>04 Jul 17 00:58:04 +0200</dateCreated>
</head>
<body>
<outline text="Alice Isn't Dead" title="Alice Isn't Dead" type="rss" xmlUrl="http://aliceisntdead.libsyn.com/rss" htmlUrl="http://www.aliceisntdead.com" />
<outline text="Invisible City" title="Invisible City" type="rss" xmlUrl="http://feeds.soundcloud.com/users/soundcloud:users:154104768/sounds.rss" htmlUrl="http://www.invisiblecitypodcast.com" />
<outline text="PodCastle" title="PodCastle" type="rss" xmlUrl="http://feeds.feedburner.com/PodCastle_Main" htmlUrl="http://podcastle.org" />
<outline text="The Art of Storytelling with Brother Wolf" title="The Art of Storytelling with Brother Wolf" type="rss" xmlUrl="http://www.artofstorytellingshow.com/podcast/storycast.xml" htmlUrl="http://www.artofstorytellingshow.com" />
<outline text="The Cleansed: A Post-Apocalyptic Saga" title="The Cleansed: A Post-Apocalyptic Saga" type="rss" xmlUrl="http://feeds.feedburner.com/TheCleansed" htmlUrl="http://thecleansed.com/" />
<outline text="The Tolkien Professor" title="The Tolkien Professor" type="rss" xmlUrl="http://media.signumuniversity.org/tolkienprof/feed" htmlUrl="http://www.tolkienprofessor.com" />
<outline text="Welcome to Night Vale" title="Welcome to Night Vale" type="rss" xmlUrl="http://nightvale.libsyn.com/rss" htmlUrl="http://welcometonightvale.com" />
<outline text="Within the Wires" title="Within the Wires" type="rss" xmlUrl="http://withinthewires.libsyn.com/rss" htmlUrl="http://withinthewires.libsyn.com/podcast" />
</body>
</opml>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<opml version="version" xmlns:rssguard="https://github.com/martinrotter/rssguard">
<head>
<title>RSS Guard</title>
<dateCreated>Thu, 27 Jul 2017 18:51:54 GMT</dateCreated>
</head>
<body>
<outline rssguard:icon="/////w==" description="" text="Amis">
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAcdJREFUOI2Fk8FLG1EQxr+ZJD24EItRY1BsCxWLoLDqxoCihwah0B56aZFc25hLxYN/hoWilyUnESQglB4UBIkXQRALTfHgRaS0XUqRLtjFardJdjwsS9PtbvKd3mPm982b4Q3Bpx8P32g1pjw5kgWhDwAgMISpHHWk2Lm3+L4xn7yD+WglXq05OoHm/KaNEkgpFuVCYmfBAgAGAKy8ir8Y+rqBFrBbkebOc31rp+Zh/K9B9Y/+rtd6/Fz7ciwtDMz5u5Wrma6nP6+qumu4/FID85GXMG0qlc2jOyqFwNcTHap3Z+E0gyjfmLSf+KU+S3+u+F/ihwGgTvU8gyjrr+Q3CYIBgIizhNfzVRCiQf1Om0pFV2cQBLuSGgcHXF1n4rZ+7zuaDZZBMIIC2kDqsCehZD7Zlrp+fvLfTNwe2GCIlMNg7x5mIuKUGSLFZnAzk4hEihHsfviG2fFBEIbTD1InPR1KyMCAi7qdMuzL4xGlMwmgNNY/teoOMXarMHq/ezt5WxkKgz2d2Rcjb83T7fa2WAHwvvLCqpXs7soBUmplAEjp4+/L3EAiYwEN2+jpycGyJnDyQsiSuOssBIMEZQIXtyaX/lnnG8YWsm5HaGmCAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="avril de perthuis" description="" encoding="UTF-8" version="RSS" text="avril de perthuis" xmlUrl="http://www.avrildeperthuis.com/feed/"/>
</outline>
<outline rssguard:icon="/////w==" description="" text="Actu Geek">
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAP9JREFUKJFjYBgAICIikpSUtH37dkdHR8KqmZmZly9fLigoaGFhISMj48QmqccihKyACU2Dk5PTjx8/3r9/f+LEiSdPnpiyiKoy86GYiMwJDw+PjIzk5OQUFxc/deqUJrNAHKeKDBP3/X+fX/37gd1JK1ascHFxgbAFGdl6ecwKObWFmNixO4mVlVVTU/PSpUsQ7vv/v179+/Ho39d3/35i16ChofHu3btXr17hDBM0Dbq6unDjidKgp6dHmgYjI6OjR4/i18DCwMBgbm7Oycn58ePH06dP37hxA1l6yvdrfxj+I4swQ1wSEhLy7t27GTNm/P+PIv2b4d9fVA20BwCO3VtV1tsBugAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Everyone's Blog Posts - Fashioning Technology" description="" encoding="UTF-8" version="ATOM" text="Everyone's Blog Posts - Fashioning Technology" xmlUrl="http://www.fashioningtech.com/profiles/blog/feed?xn_auth=no"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAlRJREFUKJEt0s2KHGUYBeDTna/lNHTCW8O0VJEfUmIWPRihGwOmdza4SfACxJ1LQfACcgnqTu9g3GWhYBYJ9CwiNaCYAjNQAw6pwQxUQQbrhRTUgflQF+a5hmc0PDUNZHAAhDCBALwmKUUokhAAl3Wtyj85Gp5CQ8qpAOACnEoD5ChKFsdypyJSQ35Nq1uwmQL+N7iQSmK0+sQfHqBukr1sqZlnZs/KsvX08MgX16rRv4dQBC4oR+dqz7n/hIqLu+/tTa+nyw9Wkjg3NV482QrDaHhs7iieqz4xhQQSAGJoPPnsywfMLEtzSzNIbVs//P7rUfcTHx2wU57sLprjsuu7vV0sbucd8/YVcDkHkL+bL5Z7CNadVpe++nS2/QXVX9Ptjwev/+7fyfh+6rPx6fNfX579HlbjJOvxsvit+PnR5G3Orl4P6MUZ6M3mE2Nkfa76mBasaqb35sniztJuZkuhPa32v91ff74eDY/hbogaIiRIaBqWJ6gbpjG5f3tz9+MNAkD6af3dD99cevCF8S1hZskVTXcs20EyZ56dYxyPO+HG/MZkMtuZ45/IK5fPXvwRGIWJcOGYiIMkB0BylQNS3ZbNrYznZrsZgM7bsSDBAGhIvQdAm1mSWpYiSwk1VVUDQEB9VNRDPVZDf0V3Uw+CivKecr0ZIOuOChKIKqvt6qNNAMHohBCgSAsmtIhsHNWZprvZ+s4alGKDlOk8DQAYqAiADPBIwCAUlfJ8s/lwbdEAeAuL0/JwO+abfgZIUYQPvZ5VaFsukLEnAhBgN5nkudr2P6qcQwhLCOJzAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="LinuxFr.org : les dépêches" description="" encoding="UTF-8" version="ATOM" text="LinuxFr.org : les dépêches" xmlUrl="http://linuxfr.org/news.atom"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAy5JREFUOI1Vk01IXFccxX/3zp1573nHqhkjIYVuothIEdKaBgkG+6FmqDGQlmgJtIU2CWRbsikhuy4DadquAxmxEgNalaQh0UqNbRYBbQMFY6JRx48449iZN5r33ny8LiLWns0fzuF/ztkcAeAXizd/f/AgmrHtMLvg+z7BUAjPdZFS7vBa65dNx45NCClbhF8o9AwNDn6qlNr9ixCCt+rr8TyPjVSKleVlAoHAjl7I52k/eXJU9MRiWSml3p3q+z6NR48yMT6OXyzyZl0dWdtmeWmJwK6gXD7vqtWVFVNrje/7AOTzeVqjUe4MD+/UfjgxQcORI5Ro/cokEEAIwdbWllKZdJqc5+H7Po7j8PHp0/T39VEsFhFCkMvlEEJw9/ZtPmxrYz2Z5MXKCkopHMdBptNpP7W+TnxxkWh7O329vbxYXWVjY4OleJx3Gxt5+/BhbNumt7ubmtpahJQkk0n+SaeRs7OzIrG2xkcdHfTcuMH88+dU7t3L4vw89YcOMfnoEUGlWE8kcF2X769coam5mWQiwcLCAuqvqSm8rS3KIxE+aGvjx6tXiZ44QXVtLYm1NQ7U1DDz5AnJZJJCoUDHqVNMT0/zy+AgZmkpMhQKETIMvrt2jT+npng2M8PU5CSO4/DryAgD/f1URCJkbJuR0VFSqRRfnTuHqTUhw3hlYFoWr0ciKKVobGpiM5vl28uXeTg2hmma/NTdTdeZM5iAUoo3IhFMyyIUDCJNw8CyLEpLS8l5HgeqqxkfGaGkpISq/fv57d493mloYG11lT2RCMFgEK01lmVhmibSsiy01uyprKT3+nUO1tUhpSQcDqO1pryigr8fP6Zq3z4KjkNYa/S2ZlkWqsSyhJQS13H4+tIlfr51i6qqKoSUZG2b91paaDl+nMWFBX6IxViKxykrK8MwTXxAfNbZaTubm2GEwPM8lFJIKclmMnx54QKZTIabsRivlZeTz+UoFAqEDAMAKaUj4nNzvd9cvNhpbJMAruvyxfnzjN2/z9zTpxim+d/Itq/neXx+9uxdAXBnaGh4eGCguVgs6u1FoYJBXNf93wJ34eX7ra1/fNLVFf0XAr9XcywwiK8AAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="Multiroom - Musique et cinéma dans toute la maison" description="Comment profiter de l'audio et de la vidéo dans toutes les pièces de sa maison" encoding="UTF-8" version="RSS" text="Multiroom - Musique et cinéma dans toute la maison" xmlUrl="http://feeds.feedburner.com/multiroom"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAfVJREFUOI2Vk09IVFEUxn9XpsGICHqgRtAUVFiaEEHUIjConZsiwo0bW7gJBoqkkSsOJUQbma2L/o5FC2dhaSUuwihItDBGJYKKGJg3JgOSFpqNX4vXe8wMYnbhLO493/nOOd8510gyAMYYAfj3jZ6QH6xMZjNf34WMMQv/ReQDdZVB1zqSZUTTQzXF/rXM9xtJxhgjXePTj/CB6oXFb1sAasiPcn6w1TQ0fS6vyBijZelgfomzQSWudaTrJAFkSbnW0d+KXmnycV15xYln40mON095j+mh09l2pPRwa0l7lkdFRG80OXA4cO5rmqLZ3vWAqSu9bgyVZykiuu9aR24MyTKm98N7qL3Ul/2pMx4gzsvvlkD9tcQC0NMbiVw8supaR+rmNUAFQH6FY1tDjJVPyN8NAA10REn3nECYcKUTYCoAVlbZxKHLM36Qb5oY2SbLQzeGcuO9ifz8XF31kZbe7bsbG4y9fcdj/jjalm1Hmn5+NMjWH72oOJOBgHHeKhW9EKTd2ai5X2rzwDer+nJdEW+Nu0jOdlBwraOsJ9gDfXlRWdxWZkl7qT0VtBYiXLWf5d/kuiLKFRapCednqG+5Zc4legDoPknxf1GBej7M3gumNdvpFNwYUif9mniyq0T1dVY4uGsiteNfQetZCWP5GDdy/gAmRlwZvbNjFgAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Next INpact" description="Actualités Informatique" encoding="UTF-8" version="RSS" text="Next INpact" xmlUrl="http://www.pcinpact.com/rss/news.xml"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAfVJREFUKJF1Uj2sMVEQneVlVyMhUVIqaJaOhEbiJ9tpViNWhVLF9VNs0AiilEhEqBQSSgqJ6DUkGioahWJFsyvhfsXIesnLN8XNzJk758zMvYzH44lGoxzHWSwWhmEopYqi3O93+GMmk2m5XAIhhFLa7/f1RL/fp/8xQoiBUnq73TKZDM/z3W6X5/lMJnM6nQAgmUwyDMMwTDKZRC5VVY3hcHiz2QSDwel06vP5crmcpmnb7Xa323U6Hby33+9fr1coFFoulwaskyRJb0mSJE3TjEYjALTbbUppPp9/PB6fdLFYvFwuLMseDgdK6eFwYFn2crkUCoVWq6V3n06nsfJH0zS73V6tVt1uN1LUajW73f5+v3XWfD5vs9lAD5BjPB4DwHg8xjASifzeaSQSQQUDxvV6vV6v/3ZEURQEAbOCIIii+J0Bn4lSqp/X6/X5fCIxSj0ej4+C1Wp1Op3NZlNXbzabgUCAZVlVVWOxWCwWU1XVbDafz2eO40CW5Ww2i8SDwQBFUqnU8XhEEJH9fr9arQghn6ErlYrD4aCUulyucrmMcw+HQ7/f7/f7R6MRIt+hZ7NZKpUCgHg8Pp/PEZxMJoSQUqnU6/W++8LPh7pouv/XIYT8rNdrr9cLALIsK4pisVgAABEAaDQa6LhcrkQisVgs/gFJY1RPdn7j4QAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="xkcd.com" description="xkcd.com: A webcomic of romance and math humor." encoding="UTF-8" version="RSS" text="xkcd.com" xmlUrl="http://xkcd.com/rss.xml"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAK1JREFUOI2lUlEWwyAICzsZNyu9GTdLPyidVOn2tvyogCFEgT8hDzl+U9cluIgta18ds7uX9ScFJCDyXNsqIOvq7quxZlZ3p6oW6ar6SXFtDoAk6e5MAGCnonTPwhEZx/p1ZoIVkuCuopg4zPrUpJwvAjMjCZgBIr1XbZOTgCQY++oBmeZGvr0cBZjmH/MjibxJwuFti/N9ivxQ+27ZVArBaRDDJANg17xjLC8mDt6mzQxhh5gtAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="Jeuxlinux - Le site des jeux pour linux" description="" encoding="UTF-8" version="RSS" text="Jeuxlinux - Le site des jeux pour linux" xmlUrl="http://www.jeuxlinux.fr/backend-breves.php3"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAixJREFUOI2VkMtLlHEUhp/z3X7fjFlaDJPiNUfBkqikoK1/QVJkuy5QhG6lIFq1yKBF0S7KKGjRKsILEaEltTBtUQshTZQMCVPTwTGby/edFopkTInv8vA+7znnFfJIVXeyhPwxEgpJikj2b6+sAUd7nnbdeva8q3EhXHYCO0SR9QQFJFQi6lK1q2z+1MmW1wcOHzoBIJrVptMXz/T1FUwSK49ji4Wi+Q4DEbLpDN9HpzlX0pS93tFR7fR0d9/sL5qiNF5COp1mbmKG7VkHR+wNbEhIUjIUJ0qoaNjD3c+v3LMjnzqdDyMfq4tjxeQI2TsW5eGd/vum0B8DlgnWvrBRwCcb1jRfaGmdagiIV8QZePsm4Rhj0EDJhgGN+48k/aLI+fz3r6r96uXWSR0HlEg0opZvjCoKqriuK/+DARzXQQFViPg+lvF9VFeb9ozZjMdzXVQVVcX4ERzjeeutF0Sj4WYBtlgAhCi+MeIY36AKrm0zPDS8Y2hwaEKDwMaCEFi1W5rJZOzB9+/KOkd72ZaIMZtaoC6RSDmeZwDFCy0GSqflxctr1fk2C+B5HgW1MVK5NDVfHcqrqp44vjGEqiDC0mySuZXUmn2jFGX5V5rKjM+Fg8e48vjSI7HlhuMZg6L8DDO0VzZn21rbehHcf1Swguo4jnVbRGYAHEUtsYScKLX1dSviWs2bFblB87NzX2jerRwv18Ufi9+2BAOoar3mggeaC+6p6r6t8r8BNjDfaXh23E8AAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="Phoronix" description="Linux Hardware Reviews &amp; News" encoding="UTF-8" version="RSS" text="Phoronix" xmlUrl="http://feeds.feedburner.com/Phoronix"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAmZJREFUOI11kc1rVFcYh5/33HPnzuTOh5lMEhOTRqE2pBERWgjoRuqiO0EQ3Pg3uBPcdu2f4NqKHxTcudOWtpKuRBAZWmrsqNhkpjEzmblzv87rRgcnY3+r93De38N5OMKBxFmue1FON82J0pxhkhMWLPWSZbpkCXxPPt0fO3QGibajnP/6CX++7jJMck6vzaJA7hR1ynI1oFEujHr24/CmG+u/UcbDJ2/56bcW/+z0OTpf5szXc4DiGQEjtHoxrd2hLk8XZQTYjVLdjjLu/PyS279soQqBb1iolw4aYj1DJ05p9xNthAWxae50q5fw+/M2d399CQjrKzUuf3eMlbkQRVEdh3jG8GY/Icmc2n4GUZxz/3GLNHOsLR/i2qV1ZioBquBUidN8AiIidFOwkYPXnYjWTh/fM1w4vUSjGtAbpGw2OwBsrDYwhglIP1NMKsLOXsQwyalM+XwxFyIibDY7XL/3jOv3nrHZbBP43vgLgKEDI4Axggg4pzin43/7P1FABKxVWKxPERZ9eoOE560ux49U2VhtcPXi+kghTvMJSMkTzJSnLNZLnFipkTnl1qMX/NFsUwt9vv9mkbMn5z/rL0DZgmROdasb03zV5YebT+kOUmphgXOnDvPt8Rm+WqqiB9uANcKX0yWMNSKzRcvqUpUr51epVwJ2ezE/PnrBjQd/IR9cD2ahXMD3RCxArWhlu5/oxtosCzNT3H/c4snfu5QCb7IJzIc+h4pWPqqMsjfMdDvKUOBdP2EwzJipBqN7zwhHygVqH8oTAIA0d7qf5OynjihTMucIfUOl4FEpePieGeu8By0CCvNt29qgAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="Tech Blog" description="" encoding="UTF-8" version="ATOM" text="Tech Blog" xmlUrl="http://www.techeblog.com/elephant/?mode=atom"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAhNJREFUOI2Vk01IVFEYhp9z77nzc/VqFsIlSYgWbWxmFaGbImsRhWQGbsJVQUILZxhCF27cjGBkLlqJLsJF0aZdi2DIwnaCaZtAIhBxwCAdu45zZ+45Lu7M4BDB9MCBl+/jfc8P3xEA61s6awpcQNMcItDkE+fEhFzf0tnE0PQ4rVaT3ip/yqxvaaQpcGm1sFv+L+AQMAWurB1bA8VCCcoKWizsmGw0HFXAK4NlEG+L1sraqKnigc/K3DBeLsPkYDI0nDBPDibxchlW5oYpHvj1XhhQCkgP9NCX7MaOmkyN9kPB5/AgXBR8pkb7saMmfclu0gM9UApOBJiC1c3deuqv/SJ4FT5MD/Lx2RB4lbBWZXVzF0wBQHhRabC8scO1J0s8uH6RR7PL0BnnxpULoaPLofPOS+ZTV1nKfWd5YwcsM7QqAZQVY3cvMZu+BcD9mwk6Rua5nXmNNA2Imfx++5hTTpSH9y6Tev6eF+++oQSwtq0X6Z3Re56vTzK+8Fn/2NnXP/P7+unCp4benudremf02rZelBogbtFuN87BmY445902AM6etht67bYFcQsNSAVgx1CA54cvG5EGVkRSUuFkayvUfkUB0BIx6x6pDMB1MEdeNexCVDL25mtdp2q6huugDJCBQhCxwInxF84/NEApIFAIqSDPUYXmP2KVowAFeQHwZVtnJbgYTaYoRAXyfV1i4hgrSeC0yILa1wAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Best Online Videos | Wimp.com" description="Best Online Videos" encoding="UTF-8" version="RSS" text="Best Online Videos | Wimp.com" xmlUrl="http://www.wimp.com/rss/"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAASJJREFUKJFj/P//PwMpgIkk1fTU8I9oDSwMDAyJzdsWHHnC8PnX0Rn+hy88rVh+mYGNieH3v5pAzWevPs+t9mQ0nPRwR8LpK08fvvjEsP/0fde81f/////w5SeDVt/////ff/7BGTDn////f/79Z7Ca8vztFwbfOduO3M7u3HHuxnOme0/eB9goMjAw8HOzMQizf/726+fvv7//MzAwMDAzMtgZiC7ZfnVeutnxy0/nnX5mqC7BpCwntO7wPQYGhvdffjK8/8XLxfb///8/sLhJdFcrnXYq1Flzyv4HgZqiDAwMTPZG8griPIwes4Q85h5YGMjAwPD/PwMrCzQw7E0VGBiZeDhYnJQFA+yUGBgYGCEx/e8/AxMjUaHEOHySBvEAAAl9eKxVa4IDAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="One Thing Well" description="A weblog about simple, useful software (on any platform)." encoding="UTF-8" version="RSS" text="One Thing Well" xmlUrl="http://onethingwell.org/rss"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAX5JREFUOI3FkbGKE1EUhr9zcyejQQgS0gXWxcLpzJzBTrYWLCxELX2AVD6B2NmKTyFoJSxi5wPMTdKJjaugsIuVLDqJk3tskrg7Gev94cK55/zn/8+5Fy4a0kzkeX7TOXdrsVi8rarqN0C/3++a2QHwK4Tw7izfNwWcc/eAZ2makqbpPycRzOwDcE7g3AR5ng+dcydmdgTsici2bmZHInIN2CvL8uvWcBOMRqPLIvLdzKoQwn5d19c3tdVqdTeEsG9mNfAly7LBjsBwOHwATEXkkqo+SpLkeO28ms1mh1mWDUTEA6HX6z1prr6Fqk5Udb6OP6vqT4DxePxKVV83+TuPWNf1off+xdr9qXPuDuA6nc7DqqoGTf7ONwIURWFmdmBmH2OMV83sW5Ikp2VZdoB4luvaBGKMj4H70+n0x3w+/+S9vx1jfNlsbl0BQERuABNVnazvZmbP27itE5jZexHxmwMkwJtWs7YkQFEUyXK5vALQ7XZPy7L88z/uxeIv/gmOEIctx9YAAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="La vache libre" description="Actu GNU/Linux, Logiciels Libres, Geek et autres vacheries inutiles mais indispensables." encoding="UTF-8" version="RSS" text="La vache libre" xmlUrl="http://la-vache-libre.org/feed/"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAALEgAACxIB0t1+/AAAArBJREFUOI2lk8tvEgsUxn/CzAADDI8BKtiCINGaWI0NNY3VhfERV93VhQsfC1f1z6kL070m4sZH3GhMuqhWhSbeG01ra2vpva0dwIEpdOgMWDct0ejOb3VOcr7vfOecHPhL7NsLxsYeeDascmQklxmyrfa4XjNzdaPlA1D87kYo6ClILuHOdGHpXY8UreTzV8yuwNjYA0/D1hPZwz23Gs3t0WxajSUzYdWyO0iik3RS5cXzuepySdcUv/R48VN50ieG1vL5K6YAsGGVIyeOHLi1ZbavHjse70unVLyyxOBAL80ti1RCIZOMqP/Oran37hd92WyU9wvrd4BVB8BILjNkNFqjN68P9aVTKgCW3WFmdqU7a0yVEUUnN66d6qs3t0dHcpkhAAeAtd0eT/eFYsMnk12CbXew7Q6hgAxA02xTq5uEAh6SB0KxVsse7wroNTN34WK/2jTbv2z4/JnDKF4BAK9H4MihGEulb1y+fFTVdTMHIADUDdPXE1FQvAJeWaI3HqQ3HuySAZwO2KhsAtATUTAM09d1AOCVJQCGBw/y33oNy/7VzR5E0QnAzm4u7N15qVRR4tEkilfg0tks08US87tFmWSEeFTm3HCaSq3F/GcNxe9udB2EQ3LhxfO56s+dBvoTaJUGWqXBzOwX3v6zBkAk6ObJ0w/VcFAudAVEUZhYLunadLH0R9sA/6/X6HyH6WKJta91zeUWJwCcAI7A6WYqEQy8frOSiu5XAslEAJfkQBRFtN3FDfQnmF/SuDv5ajXodz0sfFh9tDL/zHACnDp+w/5ari7E44r0auaLWphddbh9LrlW32JdMzA2W0xNLVZfTi2sBP2uh4uL5cmwpG58/Jhv//ZMpwczuW3Lvq3rZs7YbPl2dnZQ/O6GGpbfyW5h4m1huRj46Zn+Gj8ATCgT0RXFeKMAAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="Instantané techniques" description="Restez toujours informé grâce aux flux RSS de l'espace actualité : Instantanés Techniques." encoding="UTF-8" version="RSS" text="Instantané techniques" xmlUrl="http://www.techniques-ingenieur.fr/rss/actualite.xml"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAdNJREFUKJGVkjtoFFEUhs85987dmZ3M7DK7yRIThPhACxFRwUcRhCQIYmMKLdRC2UIQY2nhA0EQNKJgRCxELCyCEQUfiArGRlJZSAiYwhcICbuamJ2ZbHbuvXMtVmzd/ervP8X5fxy5+Gjyww+LE/wPqdM9W3rYsre7FRsAGOGXuRq1aDexGLVhN+H1RFej5O8BwlzWUtr4DlcpLsaNwBX1RHsOB4Bfkco5jPbv7Pz6/PT0wxOfHp98f//4wf7uK6f6F2K5ppi+un3YFyvnyzvma8nPKHkyunfzugJ9nK1eGB03Onr2euranRelghME7reaVLLRuyoI4/jI8K6jA2u/h7K75NmCUyVidycrYRTNfI7HpyoAZkNfaeLc0MixgUYiiUgmjctn9g2uz0uZAgARgicIETmjDkHGgGVB3tW+y8AAI1yReKh89eW9sp2xjTEEAIiI/57AaXp2fujsu0tjb4TgAMg5TcyIG7ceFDu7lNYcERmiVhIRLcbSVCOYPj/jOlorxQi0kqtz1vWn1U0b32ZtgduGxxBxbmEp8DxbsDCuyxQKfjaRsrq03JV3K7+jnmIeEBbDeoedwa0HbrZVXNtNU6LS1m2pUxrc3ttipjnvP9FZuwmfeI+yAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="The Hacker News" description="The Hacker News has been internationally recognized as a leading news source dedicated to promoting awareness for security experts and hackers" encoding="UTF-8" version="RSS" text="The Hacker News" xmlUrl="http://thehackernews.com/feeds/posts/default?alt=rss"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAfxJREFUKJF9kr/rOXEcx9+f89WHU0pnOnfLHZ2yIgwmozLIYDApi8HgDMqP2YS6YkH+AMOZKI4MXLEYXClluAxnIeVH4e47+Hzvq2/6Pqf369Xz0ev5evX+6nQ6k8lEp9PdbjfwX8EwfLlcQDweBwA4nc5ut+twOD5aKYrqdrs+nw8AANLp9KvLcdx0Ov0IcBzH8/zr/QuG4VKptN1u/X6/JEkGg+F8Pr+79Xq91Wq1WCzJZBLDMOh0Oh0Oh1qtptFoUBQdjUYIgqhus9nc7/dxHIcgiGGY6/UKUqnUK2UkErler8fjsVgs9vv91WrVbrez2ayiKLfbLRqN2u12AMAP8JLH46lWq+fzWfmjzWbDMEwgEFA90Htcnue9Xi8Mw2qHJMn7/T4YDD4DNpvN4XBEIpFcLgcAiMViNE0Hg0EIgj4DWq1WEASWZU0m02vjRqOx2+1kWf5ret/BbDYvl0tRFBVF6fV6iqKIosiyLIqi/04gCCIUCnEct1gsMAyTZflyuTyfT6PRuN/v5/N5OBwmSfJnQiaTUc9SLpebzaZatlqter2uloVC4SufzyMIst1uK5WKJEkURaEoShDE4/H4/v7e7XaCIKzXaxzHaZrGMAzQNP1KNR6PZ7MZ+KThcKj+JehwOAAAXC7X6XRKJBIfgWQyKUmS2+0GAPwGyT8CRq6xhC8AAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="Blog Hackaday" description="Fresh hacks every day" encoding="UTF-8" version="RSS" text="Blog Hackaday" xmlUrl="https://hackaday.com/blog/feed/"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAsxJREFUKJEFwUtvE1cUAOBz7r0z48d4jB2P45gWggiBSNQKj0oREi1l0VJV0E1/ASsW/AdWXXfZRReoUleVqkhUrYRUhBAQEFASkrbQNCk4TuzGcRzPeOx53XtPvw+llghAAECApFBHiqHWVtJdMW2L27OAJiJD0oQMERkjRE0IBNLrrd15/dOtZNDk1Ou+vL3569dyfx3SUMNYI0MgJGKERIhJkg7erYXLi/ZwxzILEI94uG/phGdz8bDz3593UI4BiAAYQApyOHz70uj/mwZBdv5TyFa9zUeUbmfcU5ivsagVrPwYbD0DIgAUmozgnyX/wfc66mdmzlYa1+KD1dHqImKx9MEXDLS/9cJR+3GwbZNC5Ax1srfymPnd/MmLU59c1+Ggfe87ijz7+BXTnZejvtdck4zlJt6LkwhAo4q8zqvlnAlm/ShXw93732T33kTT5ybPXQ3H46wzEey9PmhuTl+60W+9dWcvCKmY5dS4oeLm7/7zRYg6odsoTEy1735ryF43Uz/y2c2s+zGFnrfxzJ1ZEDLoek9+yASt2G+bJPnUaefUQufFbVsGgGCrzqi7kz8y01t9aGZMYIxZQkRbb/TuNlKBH7uSbXwV7keZ4ZglBDKNlDC5m3ReHaw/r7z/IaAQ2p48dPaqGAyKpcnELiWKmWSmqmjqfqrzor5gMP1u6Zdq9aS0yoCAUqq03fY3/rZkqPrt4c5y5cTpcTwK/d1s3oFyLWz+QVbRODxXalzKlGuCEEzX9Z/ehb/uWVGPI25Fw0NnvszVL+Z0r7f6W2pyuzat82Wj7AIyxoGkJY5e/khXpxLh4MRsZe5yxnDMznpn6WfOeOFwY8zy1fkLDAUgYKoVJySl0sALNzfA82I/SFsr0UHLOXFGleqiXqnMnSeeA0YITHCNGuTA9+N+ANrQyqReF4VdnP9cOpPoFGShPBjLoqORGCD8D6dLe5ZspyAcAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="gHacks Technology News" description="The independent technology news blog" encoding="UTF-8" version="RSS" text="gHacks Technology News" xmlUrl="https://www.ghacks.net/feed/"/>
</outline>
<outline rssguard:icon="/////w==" description="" text="Graphs">
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAUFJREFUOI19kj0uRkEYhZ935uYmfhMU1qFCLSyALViCFSgsRViCQhQSHYWORqK1APLNzHsUd/h898c0J+/M5Mz5GVs/fdubvV4dGCHzz1KM8evu7KK/38xeLg/Lx/M5CAQY4xgagCEBhIxUb0nIbATr+XCFxdFsAietNT2nAjNSFu6GEIah4c0JAjNSRrs75hsbIP0+7dk5vr1+SJ+fDYCyx7i2+jhUUNy0uSXf3jakuYZZMrn2LTSAsLbBxNdQgYG8GBK4dwqE4Y5KQeVv223phagatakGr0qgyq9u5AenWqjejUWsmVitxYCGEBqqLwCLXWCkBC51ZNZlUL/WfHOi4JN33ajoyPPi7y4pLcxxafl+tN2cy4pSwUv+eWkK+xn0Epl7ncJxAuUSu8xqCRMIxFELoW2fEB7atvwjMGI8fQNsLrhRBakWywAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Chart Porn" description="A collection of interesting charts, tables, maps, and interactive data toys -- with a focus on economics and graphic design. " encoding="UTF-8" version="RSS" text="Chart Porn" xmlUrl="http://feeds2.feedburner.com/ChartPorn"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAmVJREFUKJFlUltIU3Ec/p/bNnc5czs73qblBZ2RWqQlFGRP9hSDHivoMoIYQW8FvXd5CBaWK0gGFbhpkW9CmD0EtovMRCt1bS7Itum207azc842t/+/hyPN2Pfye/i+D76P34chhEANAj5/oVA4fWaklgKoBjzPj5w8NXTkaDqdrmX3DF+WlqY9U6FQCCHkcrloncFA1zscjxFCkUjk7fQbn9cnKzE5kmdy8vo1m4llrdbzjFIBY0FOAqYDh8U6zZTbvbMdf+Rw3LDbAQCkHOzY4CDDMKyJ7SG/a/A2Y6sxtFMipCxB8l1moyRJJ4aHZSX2r/Stm3adRDGdJCoK+TJdwESdUDE0l75mKuUk+XxiomqQRNHjdkfWvmFCGim0ahxfDod3S5XeQx2mMsocVFB5VUNj14VLF2maJs6OjtouX3n56nWnWtlialZrFCsbPz4Flrfi2xQgjlsaOzHi18/UwzHn+9nZ9o4OXBSETDYDIMRpvVJPk0wrolQIQgwBkQBRQEltKqinAAK5XE7ICxhCKJVMuSZecMlkA9tkyG7FCSGwuLULoaWvh6FF2Mbm1yp6Q/1Vm625paVa+u6d25nNSLdZQbWTRJLFqSJWEngtiHN8UWx84nTKMlw+0WjU457yr4cljKjjzcCkVOVRnmAqabN/LjrzbmZ1ZUVW7v0hGFjcSSQKUmEj2Q9JlFjfhJDXNnULYi4S/53hOJ/X2z8wUN3S54WFp2NjAZ+/XC47nU69RqvX0A/u3a9AuBQMPhsf/zg//9+W9uMPxw0PDvVZemOxWC1bLb0fH+bmJFE8Z7XWUn8BIkWI1tvd9l8AAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="Information Is Beautiful" description="Ideas, issues, knowledge, data - visualized!" encoding="UTF-8" version="RSS" text="Information Is Beautiful" xmlUrl="http://feeds.feedburner.com/InformationIsBeautiful"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAq5JREFUOI29UmtI03EUvb//nC4ImZvmNFmpaL7NNstHgY9KnShpDxulphRGBJqWFn3IsqzUkkyFLB+laFoEkQl+0AhKI8uZqalDt2xBobTWRDe33//2IR0W9rHOt3s553LOvRfgXwARGUTkLKs5iMisxCV/NsrvNUc9aKjNYClr7S8NbqfUzBlRKGQMwyzsTs+oz00/0E0IwRUHnL9ZHddyp6aGmkwuAACEEAMAEES0AQDgcLma/ZmHswqzj3csaSy2rjU0RbfW3q6mZpMTEKCL1nlLYiBAqckkaquvrSqtu7v9NweNnV1hVcUXs53F4teSsPAhjVotVLzq3fVtZjoRAAhfKHwqCQ1/KHZzn3nT88L3s1odcrTgTMUh2c6XMGFAz6j4hOsqI3otj6NC5MUm7ymKSUq+9AlxFSJa4k4Y0DM6IbFs0oAbrHIzU5NCIyKfudqQ0SXC2ByuLTqRt4NhGLMV12Yha29Kvr80eAAAHgMAuPPIeOGNyu6cjIPJEBErq1TN4/ol8XOlOihw85ZeD1+/BS+/gGkvv4BpDx9fVhq+tUOFKLK4mEdxZFx8BQMAYPy1aUaJaHsqM+3c3OxsCCByKUvtKUvtAYDotNq4tJjYC10jyk2WOIQgwxcKp+rKSwInjOCWkyLP/vFdF7bSwwAAfNFojuSkyutHAQSNNVV+fIFAw2nve/f1VumVfe7ePkqWUv3Y+8FoSqnd4h/ME0LMAMAFAOBwrKZt+fw+J0fnySdtLbKKptYmAgDQOfhBUnK2QG6/RqRWjgwl6XW6SGuezbg0bFspy7JMf2/PyQWjwc1BJLrv4CgantXrHfKLrzbHBHi/tZzmI6Ldo8aWoOEBhQtfINAfyzvd78qDKQAAlQHEVWWXJTqtdrXvxiBNcqpcsY4Q7d+i/l/8BIeWKUJGLP/MAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="Visual Capitalist" description="Rich Visual Content For The Modern Investor" encoding="UTF-8" version="RSS" text="Visual Capitalist" xmlUrl="http://www.visualcapitalist.com/feed/"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAALEgAACxIB0t1+/AAAArBJREFUOI2lk8tvEgsUxn/CzAADDI8BKtiCINGaWI0NNY3VhfERV93VhQsfC1f1z6kL070m4sZH3GhMuqhWhSbeG01ra2vpva0dwIEpdOgMWDct0ejOb3VOcr7vfOecHPhL7NsLxsYeeDascmQklxmyrfa4XjNzdaPlA1D87kYo6ClILuHOdGHpXY8UreTzV8yuwNjYA0/D1hPZwz23Gs3t0WxajSUzYdWyO0iik3RS5cXzuepySdcUv/R48VN50ieG1vL5K6YAsGGVIyeOHLi1ZbavHjse70unVLyyxOBAL80ti1RCIZOMqP/Oran37hd92WyU9wvrd4BVB8BILjNkNFqjN68P9aVTKgCW3WFmdqU7a0yVEUUnN66d6qs3t0dHcpkhAAeAtd0eT/eFYsMnk12CbXew7Q6hgAxA02xTq5uEAh6SB0KxVsse7wroNTN34WK/2jTbv2z4/JnDKF4BAK9H4MihGEulb1y+fFTVdTMHIADUDdPXE1FQvAJeWaI3HqQ3HuySAZwO2KhsAtATUTAM09d1AOCVJQCGBw/y33oNy/7VzR5E0QnAzm4u7N15qVRR4tEkilfg0tks08US87tFmWSEeFTm3HCaSq3F/GcNxe9udB2EQ3LhxfO56s+dBvoTaJUGWqXBzOwX3v6zBkAk6ObJ0w/VcFAudAVEUZhYLunadLH0R9sA/6/X6HyH6WKJta91zeUWJwCcAI7A6WYqEQy8frOSiu5XAslEAJfkQBRFtN3FDfQnmF/SuDv5ajXodz0sfFh9tDL/zHACnDp+w/5ari7E44r0auaLWphddbh9LrlW32JdMzA2W0xNLVZfTi2sBP2uh4uL5cmwpG58/Jhv//ZMpwczuW3Lvq3rZs7YbPl2dnZQ/O6GGpbfyW5h4m1huRj46Zn+Gj8ATCgT0RXFeKMAAAAASUVORK5CYIL/////AAAAEAAAABAAAAAAAAAAAQ==" title="Quote Snack" description="Big thoughts; human-sized canvas" encoding="UTF-8" version="RSS" text="Quote Snack" xmlUrl="http://feeds.feedburner.com/QuoteSnack"/>
</outline>
<outline rssguard:icon="/////w==" description="" text="Religion">
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAABAAAAAQAgGAAAAqmlx3gAAAAlwSFlzAAAJOgAACToB8GSSSgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAnUSURBVHic7Zt7jB1VHcc/Z+a+7z7u7t12d4vdLqW0SyGtFluwGoh/aEwkhiYGWjHERLr8Axo1DShxw8M3BmMM/4CAApoC6jYElaYNsbHCwlLaolvotsXaardL2+V223v3Pmbm+Me9c3dm7szce/duu1v0m5zM45yZc76/83vNmRn4P/63IarUKVXaXAqQgFHaVsCNnApEgFBpX7lgQ7s4MAAdyAPZ0n4ZTgGEgKbHHnts7YYNG34Uj8eXBwKB2MUZ54WBpmmZdDo9Ojg4eG9/f/8wcJ6iMIDiDJsIAC0PP/zwjZs3b/5DJBK5DAhKKbmUixAiGIlEulatWnVrIpEY3rFjx0mgQFEzyhoggGag8+jRoy92dnb26bpNUyogxPxyDVK6mngZqqoyPj7+bm9v7xeAceAcIE37VoEw0J1IJHr9yAsh5h15qD4uXddJJBK9QDdFrioU1R6Kji4KxMPhcMRLAG4dzLUwnDMvhPDUhnA4HAHiFLmeBbsAQqVSgZkQn23BeJEy+7HWu52zwOSpwLQAzJivms7DejOvm/uRrGaTswlrX85+XerM0C5gWgA1wSTsJD5XZmCSc05KPcL3FYCVmBv5ufAJfqpukrcKoZpArAIQgGKJn543ni+q79W3c2vul45t6X1dJgDzxwzc/FS96g+VeX4FCy/C1vq58AF+/VYZs+1kXQ861YQxl5jp2KwmoFDyAeCuYtZ9r1h7MZ2g23lrGHeGdIsPKE+8NQ+Y1QFerESoAdjyAOHYVrauk9BcRoMqztDGVXFU2HzCfLT1euHCwT8MOu3GeTO3HGEu4ZYGW9cDnOedaHi5az4kPo3AaQLC76Z+HvhiP/xUiwY+dQIfE6hJn73S4UtEG2wDrxDAh9kHlOCZCQpnZb0DuNiYYd82ng0LoIGBNIQG+nT1AeUTXibgt/TU4IAahlu48zEBE/WtCJ16axvHdz1LpG0hkUQ3sY4lNC9ZTUvvmnlBfqZwLoh4mkBIP88ifT/qBEyNSSazOiemdPKBduKLV9O17mYWXnszQlG9bjHrmA0f4NQA38QooEAsohKPAK3mpVmy2dcYH9zF4a330nb1Z1mx6Qeo0daZDK5mNDDzitdBORHyLDquJRxQ6OmMsKpH0HziT7x+31oO/uYejHz2gr3ysgqizus8o4CvZKWUSAOkIX1LazzANYslwdHn2H3PRzk59Pyc5wom3LjVnAkm1txCZN0toOcwspPoqWNk9jxD4cgrru2TzQGSzRqHX9jC+WMjXHnrg7PqLBsMg2XUHAWkGiKLADWM0pxAae2lZemnUfQs2unDpF/5Mbl//rXiuiu6Qozt/RV7/3OQ1Xc/A2rd67AXFBWLorXYnGEYaJpGLpcjqysYyT5aNj1N8s4dqAuvQerSVrpag7RNDDH8vc+Brs0qgRn6AFcB+H4Oc3rfdvY/egejWwf4984nyKfGys8Euq6TyxfQWy+n/asv0nbbM0iEzTe0RFW69CO8/YvbZ8UnNPAEalv4USwnrdsKKOfHaDq+k+DbzzL55wfZ853r2H3Pet55agva+Q+A4ivobL4APevpuGsXIpK0RYumkEr0xKuMbh2wkWgkCrgJpgYBlLcVYdAwDPcOhUI0qNAWU+lOBOnrDrEyPk7o4AsM3XsdI09+CyOfLQtCb1rEgq//heBlH7NpQrJJZeq1X3Ny93PVBloT3ATjJTTDKH8U4h0GvXuiwralLmmLqKzs0AmOvMDftqwjdegNhBAYhkFeiZK47QlEpN12zWWtCsd+/xCykGuYfKOoWQBSUsoD3Et7LEBfc5qRn23i6PZfAmAYBoVgM8n+3yFRbO27Quc49NwDZSKzZQb1CsUpAE+BLFi/kaUPvc3Cu/9Iy033E77ikxVJkECyol3y/uADHNvxZNlJGokltH/5cVvbaEAh/dY2tMzZugbshTqIe6bC5Ru5lQIqGRFF61hB5BNfoX3zVrrve4Ng18oKs+hNqJwc/CGpg68DRU0ILLuBYLc9RC5UMxz57f0zigr12L6f1tS9IGIYBvl8nmw2SyHawYKvvUTiiz+pMIklTRoHHr0TPTuFlBJNClpu+q6tTVARZP7+MrpWaFj969CA2leEnAmQmzCymkFk3Ua6v73LNrsYkm4xwaGtD5ZNQem5lvCyT9natcg0p4dfqnXwNaHKuH2jgK8GCCGQhk7qwG4mDw6VGxcKBfRED4kND4AhyyWmCjJ7tlGYnADAQBBdd6utTVwVnN23o6HkqE7H5/8s4HUzIQSn9+5k36N3Ey1MYhhQiCdZu+UpWpZ9HMMwiF6/kfRrz5M//o/ydR1keG/wpyy//ftIKQn2rEE6vsLLvfdmI5mdzQc4t9W02GkCnh3o2Qyjj3+Dq2NZrmwLsSIZYkXoHPsf2QyljjQRpP32n9u8fUhA7tCb0wNIdBO+6kZbGzV1gskjexsiX+t5J1fXVNhNamf2vEwin0KR06ltQEJz5hSpd4empb1wGaHL19pSYGV8lNzEWPl+4eU32BdUBORPHK6LtNfses24Zd8zFQYQXrYYSywgKERF7A8HFMLR5nInuq4TWNRnj/loTOx7ZXrgkWa7BgC5D8ZmFAGcYdBJ3EHOKgCcAih/IWLmzdZO48vXEV3zeQyLBzc0Sftn7iDSs9LWNtDdZ/P0qgQtNQ4UI4doStrqFQP0s6dshGop1jGa+27PMtZzJk+TdE2rE1JKpBpk8Zfu51+FAlN7tyOFoPn6DXTddBeaYxaUruVMFaZnwZAS9YP3p2cmnrTVSwni7OkZO8Jq6u8HUx1agKuA9ePj449YL7aahKqqxCJhRD4DQkEGo2Sy2XJnZltFyxPITdo6yudyiORHkFIiClmC+fO2+kIuC8nFNQ28WiLktS+EoLOz85vAq8A7wGTVl6NSTr8J1jSNc2m9dCyR+bTr2yFDCaBF2+11UQnmV+hqCN2vvg64+QSvOpOj9aCmRVGrEEwBVfkiu2pdLfX1wE8QDrg6QbO1TKVSrg/pXt63kQSmEVSLDG4ocStzhWkB6EAGyI+MjJzxCoV+ZOvx3rNR6h2fEIKRkZEzFH+YypQ4lwWgAWng3MDAwFA6ndZq/SDa/GTVrSiKMivFrw+3MbmRT6fT2sDAwBDFf4XSJc7lv8YMSn+LnDp1Stm5cydLly5taW1tDcViMdWPkFudqqqe+2YJBAK2Y2u98/5e+9XqhBCkUqnC8PDwmf7+/lcPHDiwG9jP9E9TNocQAhYByymGxD6K4dH1N5pLCHlgEniXYugbBU6Uzld4/RjQQfHPqg6gneIPRpcypoAJ4DQwVtpmzEo3Qw8ArRSFEeTD8etsgSLps5Rs34TfKoSgKIwPgwA0cP95+r/0tRCzIqhlhQAAAABJRU5ErkJgggAAAGwALwB1AHMAcgAvAHMAaABhAHIAZQAvAHIAcwBzAGcAdQBhAHIAZAAvAGkAYwBvAG4AcwAvAG0AaQBuAGkALQBrAGYAYQBlAG4AegBhAC8AZgBvAGwAZABlAHIALQBmAGUAZQBkAC4AcABuAGcAAABAAAAAQAAAAAAAAAAB" title="Mosquee de Paris" description="" encoding="UTF-8" version="RSS" text="Mosquee de Paris" xmlUrl="http://www.mosqueedeparis.net/index.php?format=feed&amp;amp;type=atom"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAd1JREFUOI2l0jtolgcYxfHf836JVBRDQqCiYAVFYhHp4mBDBhUyxEvVRRAX80WcBAcHB6GKgzhIQURI8l1EcBEEtxYsdcnWohAvg3gLoiQOgqlRNPo+Dn6gEh3Esz3wnPMfzuE7FZ8emULTeqUedAklSlSkecJTpX9jn/E5AVmzVRgQxoW/VTxx32u/exch86L5pq0Q+oU24XzsNRkt8yFhI3ZF1f9ZtwErW/TZFmNGeBCDruWwZdptFq5G1uxWOGGhHi90S02MKdWUpnQKVMzokLZIfcLJGHQ7m/oiG57holkHtLkjnIhBI1mzSaFT6ZXwA15G1Z/Z9IvSJaUdsc94IXUqTWi3Uyhi0Eg21IVfhTHvjGn3F37MhksWuIGzCqegwLjQK01Lr3LUWqk/qo7HXpMKP3ljZ1Sdk6a9cFj6Bz05rL1Q2INedOE/hS24mnXb8qhCh1vCQDb8hmlMCdtwM/ab/dBCXT8u4A90SQ+xGJOoCIukx8JS6aFwWBqIIRMfdzBqtYojUpvwVumuwm3MSKuwpFVrt3QshkzMWSJk03JpjbRZ4VirunXCFaV7MeT6V6f8WVDdGalDeO6Rg3HU2y/9tc0xptCwHT/jdFRd/hrky+QUWbPym0zfo/f0Da6AR25K8gAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Les trois sagesses" description="MDG" encoding="UTF-8" version="RSS1" text="Les trois sagesses" xmlUrl="http://www.les-trois-sagesses.org/rss-articles.xml"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAJAAAAEAgGAAAAxEhVQwAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAWtJREFUKJFdjyFvFFEUhb9735vd6RY6inYFDRTMBkdCXSGEFskfKEHgQIKDX0CowyEQJAgECkvSEprgwGBqlhXdkgCBkDZ0092ZdxCzoZN+6orvnNxjLK0+x3QN9AmrHtH/sLvxfvjSxB3BvqFXjqVZRrFHld1GcR2ApG5sz1iIWSH8rPMt8Plen8fLf+DQLwPg3p2MRx8fXl0wkX5GFpSvvznH15FBR5cAkAoZvY2t4Vuk5ATZzgEH40rfUToPgLGYxVarlXduYVY6cBrXHoPNLoOtOYC/msw+WJm38dHhD6HgQImFHhdvigs37gN0yNaebg53stbMvGFVBAKIGlsEMLNnMcuWUlXWOwDjmAJAKFRTASBOxRpxCsDkRSPYlAQon955U3Kk4yZUAMQsa0tqSNBuhHIA89AsIoL0//eU5up1nJT8DKp+I7b5tf/6xZfxlaNJSUoJd6cqSz+RqXnyrl+E2L4Ldh00+AcU3obE1E2zkgAAAABJRU5ErkJggv////8AAAAJAAAAEAAAAAAAAAAB" title="Theotokos.fr : flux rss" description="Les flux rss de Theotokos" encoding="UTF-8" version="RSS" text="Theotokos.fr : flux rss" xmlUrl="http://www.theotokos.fr/flux.rss"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAADwgCAAAAYhHYeAAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAUBJREFUKJHdkr1Lw1AUxe/Ne3kmMbFJA35gC4pULCJFBCdBELoL4ujf5ejm6mg3KYKLW6kWKgUHpZRErCE2TZP3rkPp4tbFwTOfw+H8OEhEMI+0udx/EuAAMH7uK64xpSjNjVo5vn8RRWvU7puV5aQXuOcHvxuSu65Z9oKbFiDSKM17gdhdJ8Hy7zFTBIrkcJS9htMATikFVw96yXPr1fCyaRbM6GlgnFQMwaLWu3e0Fd12NIPr+yXneFsDgK9GWwK69WrS7I77Md9Z8c72IMnYaoFcK3v7JFs4F4e6qQMA0iQPrx8tz1SWQES2thQ1no1NH01dUzSRanHDjzsDGETOaU0vF5GIMpKIiIAMkABwti8jCQA6MgKgGVAOAMPwQ4gFKaVSUkrl+z7nHADiYaQxDYjSNJVS2bbtOA7+g2v8ABe1i+XEAEm3AAAAAElFTkSuQmCC/////wAAABAAAAAPAAAAAAAAAAE=" title="magazine-zelie" description="magazine-zelie" encoding="UTF-8" version="RSS" text="magazine-zelie" xmlUrl="http://www.magazine-zelie.com/feed.xml"/>
</outline>
<outline rssguard:icon="/////w==" description="" text="Mainstream update">
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAaFJREFUOI11kr1PFFEUxX+zf4MWW0xh5CNZYmOIsRmYAhJICGazJiRsAbGwUAoKLNyFYiArJjZWWFoYE6WxoIAABbxHtYXGxgIKCioTC/sNHIvhPd/OZm5yc8/MOfeeezPD2PK6xpbXBTDLviiEIZYhFsDQ3DORbuSadCPHcVpXnNa1Y5ChLYA4rQtgx6DHM3kCmF0zYFC5U3vE9Ml3HvywrE13YDLT1cVvAF5MEN1cw801NHmjw78QDgSo/PnV5Sh9iO6Kbi/Dnn7uc+j2Mrq9jBnT4vL5GW6gj+GnK7LcF0CD6sCKDapqUNU7gz6ZW+c0u61bity9VyffIt91b2pgUNKYY35+lVcT5LrXX8X2QkSTETUZEYCrIQ75ZO295z/YjwKojNpzRu05gK8hDvnZJ6ueP0u2AYjo2P5120m+YseKdhLRsZr6mXC8iz9x0/7/CtSWWqottRRi9wzAFyvGF2X3AqPQ1B5YuQzfOfzyrf958iHji55zRgrSRRH38cFQooI4CppCXKqt0B/OqSgubuX5sg2KTaW6sg3KzhgY/g9r7NeGFcam/gAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Stunt Rally" description="" encoding="UTF-8" version="RSS" text="Stunt Rally" xmlUrl="http://sourceforge.net/projects/stuntrally/rss"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAaFJREFUOI11kr1PFFEUxX+zf4MWW0xh5CNZYmOIsRmYAhJICGazJiRsAbGwUAoKLNyFYiArJjZWWFoYE6WxoIAABbxHtYXGxgIKCioTC/sNHIvhPd/OZm5yc8/MOfeeezPD2PK6xpbXBTDLviiEIZYhFsDQ3DORbuSadCPHcVpXnNa1Y5ChLYA4rQtgx6DHM3kCmF0zYFC5U3vE9Ml3HvywrE13YDLT1cVvAF5MEN1cw801NHmjw78QDgSo/PnV5Sh9iO6Kbi/Dnn7uc+j2Mrq9jBnT4vL5GW6gj+GnK7LcF0CD6sCKDapqUNU7gz6ZW+c0u61bity9VyffIt91b2pgUNKYY35+lVcT5LrXX8X2QkSTETUZEYCrIQ75ZO295z/YjwKojNpzRu05gK8hDvnZJ6ueP0u2AYjo2P5120m+YseKdhLRsZr6mXC8iz9x0/7/CtSWWqottRRi9wzAFyvGF2X3AqPQ1B5YuQzfOfzyrf958iHji55zRgrSRRH38cFQooI4CppCXKqt0B/OqSgubuX5sg2KTaW6sg3KzhgY/g9r7NeGFcam/gAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Racer" description="" encoding="UTF-8" version="RSS" text="Racer" xmlUrl="http://sourceforge.net/projects/racer/rss"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAADwgGAAAA7XNPLwAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAf5JREFUKJGlkktoE1EUhv/JnQTTOg01VQjWRwRBoa6qggZKC+2i2IUP6kpwXcFFN7YLF9JFoWsL7aYYtKCmRoqSLoqSRc0klVFQq1TUTNuZPjRJ08KYZCYzc1wNpGkWVs/unvP/3zn33gP8Z3D/ajyYopO6jUt7NtYnqZMX6QWbeGd42i5P7MnsTdIdRNLkCn8g/szFRQDBv++coBvclGxzk1+Ine9SALTUVhJxQpJC7jf0tE6kcHuceF+Kurjnis49+UZ8x7UsgJAj56v9/hSu5BQ5CgBlAMnm4KFyJhMi0/Dwj4Y1Mx69CSDh6F3VgEJP0zy/9Fl1zroqd9tFrcEtvTLM2IM+ALFK/S5AMZdbFX68v8XyGb0yb57tdPtECoBox9fX3AO/SKfy6ytvbcsSqmv7jwTvdyvon7rOWTUnaJIokDftGduyBPfspMV+b1NlXVPk27FmRFolqtsFaJTIt6ljxt5YDvJz01Y5PHTXu5F+BuxgoKDKVxcMzB6eJz9zksfjtC9LeGmtyRfYgkg0NjBKpcI9tryYcJ0+12sJB3yVEHtr86jhFdp5AOiNEJv24KG5Kncw5Svs8cEoaVsDAMzi949qQMv2/3otPab1JYafKwYyap7W0mqpVPgEAGgQaYgNR03WN0LsRMscgMaqp2EAegC0ATgGoN65/h8kQ+Tg3vr+UQAAAABJRU5ErkJggv////8AAAAQAAAADwAAAAAAAAAB" title="Xfce Blog" description="The little mouse told me..." encoding="UTF-8" version="RSS" text="Xfce Blog" xmlUrl="http://blog.xfce.org/feed/"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAdpJREFUOI2Nk0FrE0EUx//zBqyHSi/Zmd2ZTdJsk0NhEewe8gXqQUGQBq/9FO3FggfPOQuCF6EIQs/ag18gWhSKxUtrtRuK29hTW7FC5nlIVta0afMuwzz+v/+893gjMBLNZvN29+DgiXPuTr/frwGAlHKfiD6FlcrTTqezXdSL4qUchm/+nJ/fGzUtxo2pqbdpt3v/goGv9Vc4V4MQYOZLYSEEwAwQ7f/IsggACADKxmzCuRpJuQOiVQDpkDiGEMdDPgXRKkm5A+dqZWM2AQBJkiz4SrH2PA6t3biqfACoWLuhPY99pThJkgU6TNO1vGQphLzOgJlpeOIwTdeEVuobmKsAkPV64mp8ENrzeNjid2LnqgDAwO9J4KKWnavS4A4I4OakBgUtE0m5N6hGoBFFS9fBjShaEmLQKUm5R+TcFjAYysnp6as4jufGwXEcz52cnb3Mh07ObdFiq7UihMCtmZnHYH738+hoNwyCL/V6/UEOzs/OPjS+/7mXZbvs3HSeX2y1VgAAxpj1QClut9sla+1rGwTvR1/PdyXfAWPM+n8C4/sftVK/5huNu4+Wl+04A18pNkHw4dIey2H4wleKA60vfIZAKdZKccXa5+Nm9C9UqfRskhwA/AUt96W76TfxAgAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Release qupzilla" description="" encoding="UTF-8" version="ATOM" text="Release qupzilla" xmlUrl="https://github.com/QupZilla/qupzilla/releases.atom"/>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAdpJREFUOI2Nk0FrE0EUx//zBqyHSi/Zmd2ZTdJsk0NhEewe8gXqQUGQBq/9FO3FggfPOQuCF6EIQs/ag18gWhSKxUtrtRuK29hTW7FC5nlIVta0afMuwzz+v/+893gjMBLNZvN29+DgiXPuTr/frwGAlHKfiD6FlcrTTqezXdSL4qUchm/+nJ/fGzUtxo2pqbdpt3v/goGv9Vc4V4MQYOZLYSEEwAwQ7f/IsggACADKxmzCuRpJuQOiVQDpkDiGEMdDPgXRKkm5A+dqZWM2AQBJkiz4SrH2PA6t3biqfACoWLuhPY99pThJkgU6TNO1vGQphLzOgJlpeOIwTdeEVuobmKsAkPV64mp8ENrzeNjid2LnqgDAwO9J4KKWnavS4A4I4OakBgUtE0m5N6hGoBFFS9fBjShaEmLQKUm5R+TcFjAYysnp6as4jufGwXEcz52cnb3Mh07ObdFiq7UihMCtmZnHYH738+hoNwyCL/V6/UEOzs/OPjS+/7mXZbvs3HSeX2y1VgAAxpj1QClut9sla+1rGwTvR1/PdyXfAWPM+n8C4/sftVK/5huNu4+Wl+04A18pNkHw4dIey2H4wleKA60vfIZAKdZKccXa5+Nm9C9UqfRskhwA/AUt96W76TfxAgAAAABJRU5ErkJggv////8AAAAQAAAAEAAAAAAAAAAB" title="Release pogo" description="" encoding="UTF-8" version="ATOM" text="Release pogo" xmlUrl="https://github.com/jendrikseipp/pogo/releases.atom"/>
</outline>
<outline rssguard:icon="AAAAIgBRAFAAaQB4AG0AYQBwAEkAYwBvAG4ARQBuAGcAaQBuAGUAAAABAAAAAYlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAeVJREFUOI3dkc1LlGEUxX/3nbEyB5OCpLEgKBohWoQt0tqE4KYgiaiggow+UCkJc9WqAhdB3xRtjJB2baw2Ci6igYJQLJiFQgQlhh8tairLfO9zWrwzBP0DQQfO7t7fuc9z4D/QWRcnXbS5ep+6Zoqu+3lX7oJr22XXwqKr55FLX4ryLdvlvTflJ87Jn71QjCmNJZy6ldDRDJkl0LYTBl7Bxjpo74crB4xQWYUNDkC2FjXuBUsTzf8kKh8yVYSaDrAS8MkYrK6GvuNQs1xgQLYW3n6ArRsgn4dPc0CXi1MuDrs46Np31yUlzxj/6FqMXe4l33mgGBQk+fSM/HS3EsAR19cfrndzrrZ7yfBi7IrLiyEoSIqplB86Jh95o1CYkFORADKdrhvDSfLfDsGTxPX1iqlTDPKh54pZKicro8vFd2hYA6MFyG2CifewvxGKv2DoPIRgEBnc7oMKg84eCMsQkADmwSJQABbgcTe4w5l+mJqGwjWor7Xkgxua0Ng4UAkY6XILCoDBulWwezMIaL0elVtGJbOjiWjkJSFVDWT+1FjW5CyMTkIqFSHL4FaF2wrUehQbzmO3rkIE1rILsxRRUnDiPTlDD43BgjExE5C+YRcvAWloaYa1WXhdSJJmP5dv+sf6DSo3J8YzNd/pAAAAAElFTkSuQmCC/////wAAABAAAAAQAAAAAAAAAAE=" title="Actualités" description="Actualités de la Mairie du 6ème" encoding="UTF-8" version="RSS" text="Actualités" xmlUrl="http://www.mairie6.lyon.fr/cs/Satellite?Thematique=&amp;TypeContenu=Actualite&amp;pagename=RSSFeed&amp;site=Mairie6"/>
</body>
</opml>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<opml version="version" xmlns:rssguard="https://github.com/martinrotter/rssguard">
<head>
<title>RSS Guard</title>
<dateCreated>Thu, 27 Jul 2017 18:51:54 GMT</dateCreated>
</head>
<body>
<outline description="" text="Amis">
<outline title="avril de perthuis" description="" encoding="UTF-8" version="RSS" text="avril de perthuis" xmlUrl="http://www.avrildeperthuis.com/feed/"/>
</outline>
<outline description="" text="Actu Geek">
<outline title="Everyone's Blog Posts - Fashioning Technology" description="" encoding="UTF-8" version="ATOM" text="Everyone's Blog Posts - Fashioning Technology" xmlUrl="http://www.fashioningtech.com/profiles/blog/feed?xn_auth=no"/>
<outline title="LinuxFr.org : les dépêches" description="" encoding="UTF-8" version="ATOM" text="LinuxFr.org : les dépêches" xmlUrl="http://linuxfr.org/news.atom"/>
<outline title="Multiroom - Musique et cinéma dans toute la maison" description="Comment profiter de l'audio et de la vidéo dans toutes les pièces de sa maison" encoding="UTF-8" version="RSS" text="Multiroom - Musique et cinéma dans toute la maison" xmlUrl="http://feeds.feedburner.com/multiroom"/>
<outline title="Next INpact" description="Actualités Informatique" encoding="UTF-8" version="RSS" text="Next INpact" xmlUrl="http://www.pcinpact.com/rss/news.xml"/>
<outline title="xkcd.com" description="xkcd.com: A webcomic of romance and math humor." encoding="UTF-8" version="RSS" text="xkcd.com" xmlUrl="http://xkcd.com/rss.xml"/>
<outline title="Jeuxlinux - Le site des jeux pour linux" description="" encoding="UTF-8" version="RSS" text="Jeuxlinux - Le site des jeux pour linux" xmlUrl="http://www.jeuxlinux.fr/backend-breves.php3"/>
<outline title="Phoronix" description="Linux Hardware Reviews &amp; News" encoding="UTF-8" version="RSS" text="Phoronix" xmlUrl="http://feeds.feedburner.com/Phoronix"/>
<outline title="Tech Blog" description="" encoding="UTF-8" version="ATOM" text="Tech Blog" xmlUrl="http://www.techeblog.com/elephant/?mode=atom"/>
<outline title="Best Online Videos | Wimp.com" description="Best Online Videos" encoding="UTF-8" version="RSS" text="Best Online Videos | Wimp.com" xmlUrl="http://www.wimp.com/rss/"/>
<outline title="One Thing Well" description="A weblog about simple, useful software (on any platform)." encoding="UTF-8" version="RSS" text="One Thing Well" xmlUrl="http://onethingwell.org/rss"/>
<outline title="La vache libre" description="Actu GNU/Linux, Logiciels Libres, Geek et autres vacheries inutiles mais indispensables." encoding="UTF-8" version="RSS" text="La vache libre" xmlUrl="http://la-vache-libre.org/feed/"/>
<outline title="Instantané techniques" description="Restez toujours informé grâce aux flux RSS de l'espace actualité : Instantanés Techniques." encoding="UTF-8" version="RSS" text="Instantané techniques" xmlUrl="http://www.techniques-ingenieur.fr/rss/actualite.xml"/>
<outline title="The Hacker News" description="The Hacker News has been internationally recognized as a leading news source dedicated to promoting awareness for security experts and hackers" encoding="UTF-8" version="RSS" text="The Hacker News" xmlUrl="http://thehackernews.com/feeds/posts/default?alt=rss"/>
<outline title="Blog Hackaday" description="Fresh hacks every day" encoding="UTF-8" version="RSS" text="Blog Hackaday" xmlUrl="https://hackaday.com/blog/feed/"/>
<outline title="gHacks Technology News" description="The independent technology news blog" encoding="UTF-8" version="RSS" text="gHacks Technology News" xmlUrl="https://www.ghacks.net/feed/"/>
</outline>
<outline description="" text="Graphs">
<outline title="Chart Porn" description="A collection of interesting charts, tables, maps, and interactive data toys -- with a focus on economics and graphic design. " encoding="UTF-8" version="RSS" text="Chart Porn" xmlUrl="http://feeds2.feedburner.com/ChartPorn"/>
<outline title="Information Is Beautiful" description="Ideas, issues, knowledge, data - visualized!" encoding="UTF-8" version="RSS" text="Information Is Beautiful" xmlUrl="http://feeds.feedburner.com/InformationIsBeautiful"/>
<outline title="Visual Capitalist" description="Rich Visual Content For The Modern Investor" encoding="UTF-8" version="RSS" text="Visual Capitalist" xmlUrl="http://www.visualcapitalist.com/feed/"/>
<outline title="Quote Snack" description="Big thoughts; human-sized canvas" encoding="UTF-8" version="RSS" text="Quote Snack" xmlUrl="http://feeds.feedburner.com/QuoteSnack"/>
</outline>
<outline description="" text="Religion">
<outline title="Mosquee de Paris" description="" encoding="UTF-8" version="RSS" text="Mosquee de Paris" xmlUrl="http://www.mosqueedeparis.net/index.php?format=feed&amp;amp;type=atom"/>
<outline title="Les trois sagesses" description="MDG" encoding="UTF-8" version="RSS1" text="Les trois sagesses" xmlUrl="http://www.les-trois-sagesses.org/rss-articles.xml"/>
<outline title="Theotokos.fr : flux rss" description="Les flux rss de Theotokos" encoding="UTF-8" version="RSS" text="Theotokos.fr : flux rss" xmlUrl="http://www.theotokos.fr/flux.rss"/>
<outline title="magazine-zelie" description="magazine-zelie" encoding="UTF-8" version="RSS" text="magazine-zelie" xmlUrl="http://www.magazine-zelie.com/feed.xml"/>
</outline>
<outline description="" text="Mainstream update">
<outline title="Stunt Rally" description="" encoding="UTF-8" version="RSS" text="Stunt Rally" xmlUrl="http://sourceforge.net/projects/stuntrally/rss"/>
<outline title="Racer" description="" encoding="UTF-8" version="RSS" text="Racer" xmlUrl="http://sourceforge.net/projects/racer/rss"/>
<outline title="Xfce Blog" description="The little mouse told me..." encoding="UTF-8" version="RSS" text="Xfce Blog" xmlUrl="http://blog.xfce.org/feed/"/>
<outline title="Release qupzilla" description="" encoding="UTF-8" version="ATOM" text="Release qupzilla" xmlUrl="https://github.com/QupZilla/qupzilla/releases.atom"/>
<outline title="Release pogo" description="" encoding="UTF-8" version="ATOM" text="Release pogo" xmlUrl="https://github.com/jendrikseipp/pogo/releases.atom"/>
</outline>
<outline title="Actualités" description="Actualités de la Mairie du 6ème" encoding="UTF-8" version="RSS" text="Actualités" xmlUrl="http://www.mairie6.lyon.fr/cs/Satellite?Thematique=&amp;TypeContenu=Actualite&amp;pagename=RSSFeed&amp;site=Mairie6"/>
</body>
</opml>

View file

@ -0,0 +1,898 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:blogChannel="http://backend.userland.com/blogChannelModule" version="2.0"><channel><title>NixOS News</title><link>https://nixos.org</link><description>News for NixOS, the purely functional Linux distribution.</description><image><title>NixOS</title><url>https://nixos.org/logo/nixos-logo-only-hires.png</url><link>https://nixos.org/</link></image><item><title>
NixOS 18.09 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://github.com/NixOS/nixos-artwork/blob/master/releases/18.09-jellyfish/jellyfish.png">
<img class="inline" src="logo/nixos-logo-18.09-jellyfish-lores.png" alt="18.09 Jellyfish logo" with="100" height="87"/>
</a>
NixOS 18.09 “Jellyfish” has been released, the tenth stable release branch.
See the <a href="/nixos/manual/release-notes.html#sec-release-18.09">release notes</a>
for details. You can get NixOS 18.09 ISOs and VirtualBox appliances
from the <a href="nixos/download.html">download page</a>.
For information on how to upgrade from older release branches
to 18.09, check out the
<a href="/nixos/manual/index.html#sec-upgrading">manual section on upgrading</a>.
</description><pubDate>Sat Oct 06 2018 00:00:00 GMT</pubDate></item><item><title>
Fastly supports NixOS
</title><link>https://nixos.org/news.html</link><description>
We are happy to announce that we have moved our binary cache to <a href="https://fastly.com">Fastly</a>. Fastly
is a big supporter of open source projects and now NixOS is one of them! Fastly provides us with CDN capability,
which previously was running on AWS CloudFront. Big thanks go to Fastly, in particular Tom Denniston and Elaine
Greenberg, our friends at <a href="https://www.infor.com">Infor</a> and <a href="https://packet.net">Packet.net</a>
and Graham Christensen for making this possible.
</description><pubDate>Thu Oct 04 2018 00:00:00 GMT</pubDate></item><item><title>
Nix 2.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="/nix/download.html">Nix 2.1</a>
has been released. See the <a href="/nix/manual#ssec-relnotes-2.1">release
notes</a> for a list of changes and new features.
</description><pubDate>Sun Sep 02 2018 00:00:00 GMT</pubDate></item><item><title>
NixOS Discourse forum
</title><link>https://nixos.org/news.html</link><description>
The <tt>nix-devel</tt> mailing list is now replaced by our discourse forum instance which is also usable by email:
<a href="https://discourse.nixos.org"><tt>discourse.nixos.org</tt></a>.
</description><pubDate>Tue Aug 14 2018 00:00:00 GMT</pubDate></item><item><title>
NixCon 2018
</title><link>https://nixos.org/news.html</link><description>
We're happy to announce that <strong>NixCon 2018</strong>, the
third Nix Conference, will take place <strong>October 25-27 2018 in London</strong>
For more information, see the
<a href="http://nixcon2018.org/">NixCon 2018 website</a>.
And please consider
<a href="https://nixcon2018.org/#call-for-paper">submitting a talk</a>!
</description><pubDate>Mon May 21 2018 00:00:00 GMT</pubDate></item><item><title>
NixOS 18.03 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://github.com/NixOS/nixos-artwork/blob/master/releases/18.03-impala/impala.png">
<img class="inline" src="logo/nixos-logo-18.03-impala-lores.png" alt="18.03 Impala logo"/>
</a>
NixOS 18.03 “Impala” has been released, the ninth stable release branch.
See the <a href="/nixos/manual/release-notes.html#sec-release-18.03">release notes</a>
for details. You can get NixOS 18.03 ISOs and VirtualBox appliances
from the <a href="nixos/download.html">download page</a>.
For information on how to upgrade from older release branches
to 18.03, check out the
<a href="/nixos/manual/index.html#sec-upgrading">manual section on upgrading</a>.
</description><pubDate>Wed Apr 04 2018 00:00:00 GMT</pubDate></item><item><title>
Nix 2.0 released
</title><link>https://nixos.org/news.html</link><description>
<a href="/nix/download.html">Nix 2.0</a>
has been released. See the <a href="/nix/manual#ssec-relnotes-2.0">release
notes</a> for a list of changes and new features.
</description><pubDate>Thu Feb 22 2018 00:00:00 GMT</pubDate></item><item><title>
NixOS 17.09 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 17.09 “Hummingbird” has been released, the eigth stable release
branch. See the <a href="/nixos/manual/release-notes.html#sec-release-17.09">release notes</a>
for details. You can get NixOS 17.09 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. For information on how to upgrade from older release
branches to 17.09, check out the <a href="/nixos/manual/index.html#sec-upgrading">manual section on
upgrading</a>.
</description><pubDate>Mon Oct 02 2017 00:00:00 GMT</pubDate></item><item><title>
Nix-dev mailing list moved
</title><link>https://nixos.org/news.html</link><description>
The <tt>nix-dev</tt> mailing list has moved to
<a href="https://groups.google.com/forum/#!forum/nix-devel"><tt>nix-devel</tt></a>
on Google Groups.
</description><pubDate>Wed Jul 12 2017 00:00:00 GMT</pubDate></item><item><title>
NixCon 2017
</title><link>https://nixos.org/news.html</link><description>
We're happy to announce that <strong>NixCon 2017</strong>, the
second Nix Conference, will take place <strong>October 2831 2017 in Munich</strong>
For more information, see the
<a href="http://nixcon2017.org/">NixCon 2017 website</a>.
And please consider
<a href="https://schedule.nixcon2017.org/en/nixcon2017/cfp/">submitting a talk</a>!
</description><pubDate>Sun Jun 18 2017 00:00:00 GMT</pubDate></item><item><title>
NixOS 17.03 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 17.03 “Gorilla” has been released, the seventh stable release
branch. See the <a href="/nixos/manual/release-notes.html#sec-release-17.03">release notes</a>
for details. You can get NixOS 17.03 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. For information on how to upgrade from older release
branches to 17.03, check out the <a href="/nixos/manual/index.html#sec-upgrading">manual section on
upgrading</a>.
</description><pubDate>Fri Mar 31 2017 00:00:00 GMT</pubDate></item><item><title>
NixOS 16.09 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 16.09 “Flounder” has been released, the sixth stable release
branch. See the <a href="/nixos/manual/release-notes.html#sec-release-16.09">release notes</a>
for details. You can get NixOS 16.09 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. For information on how to upgrade from older release
branches to 16.09, check out the <a href="/nixos/manual/index.html#sec-upgrading">manual section on
upgrading</a>.
</description><pubDate>Mon Oct 03 2016 00:00:00 GMT</pubDate></item><item><title>
NixOps 1.4 released
</title><link>https://nixos.org/news.html</link><description>
<a href="/releases/nixops/nixops-1.4/nixops-1.4.tar.bz2">NixOps
1.4</a> has been released. This release contains contains many
nice new features. See the <a href="https://nixos.org/nixops/manual#ssec-relnotes-1.4">manual</a>
for details.
</description><pubDate>Wed Jul 20 2016 00:00:00 GMT</pubDate></item><item><title>
NixOS 16.03 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 16.03 “Emu” has been released, the fifth stable release
branch. See the <a href="/nixos/manual/release-notes.html#sec-release-16.03">release notes</a>
for details. You can get NixOS 16.03 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. For information on how to upgrade from older release
branches to 16.03, check out the <a href="/nixos/manual/index.html#sec-upgrading">manual section on
upgrading</a>.
</description><pubDate>Sun May 01 2016 00:00:00 GMT</pubDate></item><item><title>
Nix 1.11 released
</title><link>https://nixos.org/news.html</link><description>
<a href="/nix/download.html">Nix 1.11</a>
has been released. See the <a href="/nix/manual#ssec-relnotes-1.11">release
notes</a> for a list of changes and new features.
</description><pubDate>Fri Feb 19 2016 00:00:00 GMT</pubDate></item><item><title>
NixOS 15.09 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 15.09 “Dingo” has been released, the fourth stable release
branch. See the <a href="/nixos/manual/release-notes.html#sec-release-15.09">release notes</a>
for details. You can get NixOS 15.09 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. For information on how to upgrade from older release
branches to 15.09, check out the <a href="/nixos/manual/index.html#sec-upgrading">manual section on
upgrading</a>.
</description><pubDate>Fri Oct 30 2015 00:00:00 GMT</pubDate></item><item><title>
Nix 1.10 released
</title><link>https://nixos.org/news.html</link><description>
<a href="/nix/download.html">Nix 1.10</a>
has been released. See the <a href="/nix/manual#ssec-relnotes-1.10">release
notes</a> for a list of changes and new features.
</description><pubDate>Sat Oct 03 2015 00:00:00 GMT</pubDate></item><item><title>
NixCon 2015
</title><link>https://nixos.org/news.html</link><description>
<a href="http://conf.nixos.org"><img class="inline" style="width: 10em;" src="https://d2z6c3c3r6k4bx.cloudfront.net/uploads/event/logo/1005856/banner.png" alt="NixCon logo"/></a>
We're happy to announce that <strong>NixCon 2015</strong>, the
first Nix Conference, will take place on <strong>November
14—15th 2015 in Berlin</strong>. For more information, see the
<a href="http://conf.nixos.org">NixCon website</a>. And please
consider <a href="http://conf.nixos.org/submit-a-talk.html">submitting a
talk</a>!
</description><pubDate>Thu Sep 03 2015 00:00:00 GMT</pubDate></item><item><title>
NixOS Foundation
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/nixos/foundation.html">The NixOS Foundation</a>
was started to improve our ability to maintain and extend the infrastructure
used by the Nix related projects. If you would like to support us, please go
<a href="https://nixos.org/nixos/foundation.html">here</a> and donate some money!
</description><pubDate>Sun Aug 09 2015 00:00:00 GMT</pubDate></item><item><title>
Nix 1.9 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.9">Nix 1.9</a>
has been released. See the <a href="https://nixos.org/releases/nix/nix-1.9/manual/#ssec-relnotes-1.9">release
notes</a> for a list of changes and new features.
</description><pubDate>Sun Jul 12 2015 00:00:00 GMT</pubDate></item><item><title>
NixOS 14.12 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 14.12 “Caterpillar” has been released, the third stable
release branch. It brings Linux 3.14, systemd 217, Glibc 2.20,
KDE 4.14.1, and much more. See the <a href="/nixos/manual/sec-release-14.12.html">release notes</a>
for details. You can get NixOS 14.12 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. For information on how to upgrade from older release
branches to 14.12, check out the <a href="/nixos/manual/sec-upgrading.html">manual section on
upgrading</a>.
</description><pubDate>Fri Jan 30 2015 00:00:00 GMT</pubDate></item><item><title>
Nix 1.8 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.8">Nix 1.8</a>
has been released. See the <a href="https://nixos.org/releases/nix/nix-1.8/manual/#ssec-relnotes-1.8">release
notes</a> for a list of changes and new features.
</description><pubDate>Wed Jan 14 2015 00:00:00 GMT</pubDate></item><item><title>
NixOS sprint in Ljubljana
</title><link>https://nixos.org/news.html</link><description>
Were having a NixOS sprint at the <a href="https://www.kiberpipa.org/en/">Kiberpipa hackerspace</a>
in Ljubljana, Slovenia, on <strong>August
23—27</strong>. Joining is free! For more information and to
register, please go to the <a href="http://www.kiberpipa.org/nixos-sprint-ljubljana-2014/">sprint
page</a>.
</description><pubDate>Sat Aug 30 2014 00:00:00 GMT</pubDate></item><item><title>
NixOS 14.04 released
</title><link>https://nixos.org/news.html</link><description>
NixOS 14.04 “Baboon” has been released, the second stable
release branch. It brings Linux 3.12, systemd 212, GCC 4.8,
Glibc 2.19, KDE 4.12, light-weight NixOS containers, and much
more. See the <a href="/nixos/manual/#sec-release-14.04">release
notes</a> for details. You can get NixOS 14.04 ISOs and
VirtualBox appliances from the <a href="nixos/download.html">download page</a>. For information on
how to upgrade a 13.10 system to 14.04, check out the <a href="/nixos/manual/#sec-upgrading">manual
section on upgrading</a>.
</description><pubDate>Fri May 30 2014 00:00:00 GMT</pubDate></item><item><title>
NixOps 1.2 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nixops/nixops-1.2">NixOps
1.2</a> has been released. This release contains contains many nice new features. See the <a href="https://nixos.org/nixops/manual#ssec-relnotes-1.2">manual</a>
for details.
</description><pubDate>Fri May 30 2014 00:00:00 GMT</pubDate></item><item><title>
Nix 1.7 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.7">Nix 1.7</a>
has been released. See the <a href="https://nixos.org/releases/nix/nix-1.7/manual/#ssec-relnotes-1.7">release
notes</a> for a list of new features.
</description><pubDate>Sun May 11 2014 00:00:00 GMT</pubDate></item><item><title>
Heartbleed vulnerability in OpenSSL
</title><link>https://nixos.org/news.html</link><description>
A <a href="http://heartbleed.com/">serious security
vulnerability</a> has been discovered in OpenSSL. All stable
NixOS releases prior to version
<strong>13.10.35708.15a465c</strong> are vulnerable. (You can
see your current version by running <tt>nixos-version</tt>.) To
upgrade to the latest NixOS version, run <tt>nixos-rebuild
switch --upgrade</tt>. You can verify whether you are safe by
running
<pre class="code">
$ nix-store -qR /run/current-system | grep openssl
</pre>
If this shows any OpenSSL version prior to 1.0.1g, you may be
vulnerable.
</description><pubDate>Fri May 09 2014 00:00:00 GMT</pubDate></item><item><title>
FOSDEM talks
</title><link>https://nixos.org/news.html</link><description>
<a href="https://www.domenkozar.com/">Domen Kožar</a> gave <a href="https://fosdem.org/2014/schedule/event/nixos_declarative_configuration_linux_distribution/">a
talk at FOSDEM about NixOS</a> (<a href="https://video.fosdem.org/2014/H1309_Van_Rijn/Saturday/NixOS_declarative_configuration_Linux_distribution.webm">video</a>).
Also, Ludovic Courtès gave <a href="https://fosdem.org/2014/schedule/event/gnuguix/">a talk on
Guix</a>, the Nix- and Guile-based package manager.
</description><pubDate>Sun Mar 02 2014 00:00:00 GMT</pubDate></item><item><title>
Stdenv updates branch merged into master
</title><link>https://nixos.org/news.html</link><description>
The stdenv-updates branch <a href="https://github.com/NixOS/nixpkgs/commit/668310a2b578d57a59dad7481db651ab3a258256">has
been merged</a> into the master branch of Nixpkgs. The main
change are that brings is that Nixpkgs/NixOS are now based on
GCC 4.8 and Glibc 2.18, in addition to many smaller updates.
</description><pubDate>Fri Feb 21 2014 00:00:00 GMT</pubDate></item><item><title>
NixOS 13.10 released
</title><link>https://nixos.org/news.html</link><description>
We have released NixOS 13.10, the first stable branch of NixOS.
Its goal is to provide a safe branch for production environments
that need bug fixes and security updates, but not the
potentially destabilising changes that sometimes occur on the
unstable branch. You can get NixOS 13.10 ISOs and VirtualBox
appliances from the <a href="nixos/download.html">download
page</a>. See the <a href="https://nixos.org/nix-dev/2013-October/011941.html">announcement</a>
for more information. For information on how to switch an
existing NixOS machine from the unstable channel to 13.10, check
out the <a href="https://nixos.org/nixos/manual/#sec-upgrading">manual
section on upgrading</a>.
</description><pubDate>Sun Dec 01 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.6.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.6.1">Nix
1.6.1</a> has been released. This is primarily a bug fix
release but has some minor new features. See the <a href="https://nixos.org/nix/manual/#ssec-relnotes-1.6.1">release
notes</a> for details.
</description><pubDate>Thu Nov 28 2013 00:00:00 GMT</pubDate></item><item><title>
NixOS sources merged into Nixpkgs
</title><link>https://nixos.org/news.html</link><description>
The NixOS Git tree has been merged into the Nixpkgs tree in
order to simplify development. The sources now live in the <a href="https://github.com/NixOS/nixpkgs/tree/master/nixos"><tt>nixos</tt>
subdirectory of the Nixpkgs repository on GitHub</a>. See the
<a href="https://nixos.org/nix-dev/2013-October/011873.html">announcement</a>
for more information.
</description><pubDate>Sun Nov 10 2013 00:00:00 GMT</pubDate></item><item><title>
NixOps 1.1.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nixops/nixops-1.1.1">NixOps
1.1.1</a> has been released. This release consists mostly of minor bugfixes. See the <a href="https://hydra.nixos.org/build/6347332/download/1/manual/manual.html#ssec-relnotes-1.1.1">manual</a>
for details.
</description><pubDate>Sat Nov 02 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.6 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.6">Nix 1.6</a>
has been released. See the <a href="https://hydra.nixos.org/build/6039366/download/3/release-notes">release
notes</a> for details.
</description><pubDate>Thu Oct 10 2013 00:00:00 GMT</pubDate></item><item><title>
NixOps 1.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nixops/nixops-1.1">NixOps
1.1</a> has been released. This release brings a backend for Hetzner,
a German data center provider, support for EC2 spot instances and some
minor bugfixes. See the <a href="https://hydra.nixos.org/build/6036396/download/1/manual/manual.html#ssec-relnotes-1.1">manual</a>
for details.
</description><pubDate>Wed Oct 09 2013 00:00:00 GMT</pubDate></item><item><title>
NixOS sprint in Slovenia
</title><link>https://nixos.org/news.html</link><description>
A sprint focused on NixOS and <a href="http://kotti.pylonsproject.org/">Kotti</a> will be held <a href="http://www.coactivate.org/projects/zidanca-sprint-2013/project-home">22-26
July 2013 in Lokve, Slovenia</a>. It is organised by <a href="http://www.termitnjak.com/">Termitnjak</a> and sponsored
by <a href="http://www.logicblox.com/">LogicBlox</a>.
</description><pubDate>Thu Aug 15 2013 00:00:00 GMT</pubDate></item><item><title>
NixOps 1.0.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nixops/nixops-1.0.1">NixOps
1.0.1</a> has been released, a minor bug fix release. See the <a href="https://hydra.nixos.org/build/5508053/download/1/manual/manual.html#ssec-relnotes-1.0.1">manual</a>
for details.
</description><pubDate>Sun Aug 11 2013 00:00:00 GMT</pubDate></item><item><title>
NixOS presentation at EuroPython
</title><link>https://nixos.org/news.html</link><description>
Domen Kožar gave a presentation at <a href="https://ep2013.europython.eu/conference/talks/nixos-operating-system-declarative-configuration-distribution">EuroPython
2013</a>: <a href="https://www.youtube.com/watch?v=DtOBROowzDg">“NixOS
Operating System: Declarative Configuration Distribution”</a>.
</description><pubDate>Mon Aug 05 2013 00:00:00 GMT</pubDate></item><item><title>
NixOps 1.0 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nixops/nixops-1.0">NixOps
1.0</a> has been released, the inaugural release of the NixOS
cloud deployment tool. See the <a href="https://nixos.org/nix-dev/2013-June/011363.html">announcement</a>
and the <a href="https://hydra.nixos.org/build/5426864/download/1/manual/manual.html">manual</a>
for details.
</description><pubDate>Thu Jul 25 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.5.3 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.5.3">Nix 1.5.3</a>
has been released. This is primarily a bug fix release. See the <a href="https://hydra.nixos.org/build/5350096/download/3/release-notes">release
notes</a> for details.
</description><pubDate>Wed Jul 17 2013 00:00:00 GMT</pubDate></item><item><title>
PhD thesis: A Reference Architecture for Distributed Software Deployment
</title><link>https://nixos.org/news.html</link><description>
Today <a href="http://www.st.ewi.tudelft.nl/~sander/">Sander van
der Burg</a> successfully defended his PhD thesis entitled <a href="http://www.st.ewi.tudelft.nl/~sander/index.php/phdthesis"><em>A
Reference Architecture for Distributed Software
Deployment</em></a>! It describes (among other things) <a href="https://nixos.org/disnix/">Disnix</a>, a system for
deployment of service-oriented architectures.
</description><pubDate>Wed Jul 03 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.5.2 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.5.2">Nix 1.5.2</a>
has been released. This is a bug fix release.
</description><pubDate>Thu Jun 13 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.5.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.5.1">Nix 1.5.1</a>
has been released. It fixes a regression introduced in Nix 1.4. See the <a href="https://hydra.nixos.org/build/4253990/download/3/release-notes">release
notes</a> for details.
</description><pubDate>Thu Mar 28 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.4 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.4">Nix 1.4</a>
has been released. This is primarily a bug fix release that
addresses a security problem in multi-user mode. See the <a href="https://hydra.nixos.org/build/4228031/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/4228031/download/1/manual#chap-installation">manual</a>.
</description><pubDate>Tue Mar 26 2013 00:00:00 GMT</pubDate></item><item><title>
NixOS switched to systemd
</title><link>https://nixos.org/news.html</link><description>
NixOS has switched from Upstart to <a href="https://www.freedesktop.org/wiki/Software/systemd">systemd</a>!
Systemd brings many advantages such as better dependency
management, socket-based activation of services, per-service
logging, cgroup-based process management, and much more. (Read
the <a href="https://nixos.org/nix-dev/2013-January/010482.html">announcement</a>.)
</description><pubDate>Thu Feb 21 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.3 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.3">Nix 1.3</a>
has been released. This is primarily a bug fix release. See
the <a href="https://hydra.nixos.org/build/3668901/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/3668901/download/1/manual#chap-installation">manual</a>.
</description><pubDate>Tue Feb 05 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.2 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.2">Nix 1.2</a>
has been released. See the <a href="https://hydra.nixos.org/build/3455295/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/3455295/download/1/manual">manual</a>.
</description><pubDate>Sun Jan 06 2013 00:00:00 GMT</pubDate></item><item><title>
Nix 1.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-1.1">Nix 1.1</a>
has been released. See the <a href="https://hydra.nixos.org/build/2860022/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/2860022/download/1/manual">manual</a>.
</description><pubDate>Sat Aug 18 2012 00:00:00 GMT</pubDate></item><item><title>
Binary Nix tarballs available
</title><link>https://nixos.org/news.html</link><description>
Our continuous build system, Hydra, now produces <a href="https://hydra.nixos.org/view/nix/trunk/latest">binary
tarball distributions</a> of Nix for Mac OS X (Darwin), FreeBSD
and Linux. The tarballs contain all dependencies of Nix, making
it a lot easier to install Nix on those platforms. To install,
download a binary tarball, unpack it in the root directory, then
run <tt>nix-finish-install</tt>. See the <a href="https://hydra.nixos.org/view/nix/trunk/latest/tarball/download-by-type/doc/manual#chap-installation">manual</a>
for more information.
</description><pubDate>Sun Jun 24 2012 00:00:00 GMT</pubDate></item><item><title>
Nix 1.0 released
</title><link>https://nixos.org/news.html</link><description>
After almost two years of development, <a href="https://hydra.nixos.org/release/nix/nix-1.0">Nix 1.0</a>
has been released. See the <a href="https://hydra.nixos.org/build/2609700/download/3/release-notes">release
notes</a> for an overview of the most important improvements.
For installation information, see the <a href="https://hydra.nixos.org/build/2609700/download/1/manual">manual</a>.
</description><pubDate>Mon Jun 11 2012 00:00:00 GMT</pubDate></item><item><title>PatchELF 0.6 released</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/patchelf/patchelf-0.6">PatchELF
0.6</a> has been released. Apart from some bug fixes, it adds
support for executables produced by the Gold linker. See the <a href="https://hydra.nixos.org/build/1524660/download/1/README">README</a>
for details.
</description><pubDate>Wed Dec 07 2011 00:00:00 GMT</pubDate></item><item><title>Hydra talk at Inria</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/">
<img class="inline" src="images/hydra.png" alt="Hydra logo"/></a>
Ludovic Courtès gave a talk on <a href="https://nixos.org/hydra">Hydra</a> at <a href="https://www.inria.fr/centre/bordeaux">Inria</a> (which has
its own Hydra instance for building Inria software) entitled <a href="http://sed.bordeaux.inria.fr/seminars/hydra-ci_20111103.pdf">“Hydra:
continuous integration for demanding people”</a>.
</description><pubDate>Sat Dec 03 2011 00:00:00 GMT</pubDate></item><item><title>Moving to GitHub</title><link>https://nixos.org/news.html</link><description>
The NixOS project is (slowly) migrating from Subversion to Git!
The master repositories will be hosted in the <a href="https://github.com/NixOS/">NixOS organization</a> on <a href="https://github.com/">GitHub</a>. For the moment, just a
few subprojects have been migrated, such as <a href="https://github.com/NixOS/hydra">Hydra</a> and <a href="https://github.com/NixOS/charon">Charon</a>. Thanks to
Tianyi Cui for donating the NixOS GitHub organization.
</description><pubDate>Mon Nov 28 2011 00:00:00 GMT</pubDate></item><item><title>
Nix-dev mailing list moved
</title><link>https://nixos.org/news.html</link><description>
The <tt>nix-dev</tt> mailing list has moved. The address is now
<tt>nix-dev@lists.science.uu.nl</tt> (<a href="http://lists.science.uu.nl/mailman/listinfo/nix-dev">web
interface</a>).
</description><pubDate>Fri Oct 14 2011 00:00:00 GMT</pubDate></item><item><title>
FOSDEM talk about NixOS
</title><link>https://nixos.org/news.html</link><description>
<a href="https://fosdem.org/2011"><img class="inline" src="logo/fosdem-logo.png" alt="Fosdem logo"/></a>
<a href="http://www.st.ewi.tudelft.nl/~sander/">Sander van der
Burg</a> gave a talk about NixOS at the <a href="https://fosdem.org/2011/schedule/track/crossdistro_devroom">CrossDistro
track</a> of <a href="http://fosdem.org/2011/">FOSDEM</a> (<a href="http://blip.tv/file/4726612">video</a>, <a href="http://www.st.ewi.tudelft.nl/~sander/pdf/talks/vanderburg11-fosdem.pdf">slides</a>).
</description><pubDate>Sat Mar 05 2011 00:00:00 GMT</pubDate></item><item><title>
ISSRE paper on NixOS-based system testing
</title><link>https://nixos.org/news.html</link><description>
The paper <a href="docs/papers.html#issre10">“Automating System
Tests Using Declarative Virtual Machines”</a> (by Sander van der
Burg and Eelco Dolstra) has been accepted for presentation at
the <a href="http://www.issre2010.org/">21st IEEE International
Symposium on Software Reliability Engineering (ISSRE 2010)</a>.
It describes how system tests with complex requirements on the
environment (such as remote machines, network topologies, system
services or root privileges) can be written succinctly using <a href="https://svn.nixos.org/websvn/nix/nixos/trunk/tests/bittorrent.nix">declarative
specifications</a> of the machines needed by the test
environment. From these specifications we can automatically
instantiate (networks of) virtual machines. This is what we use
for <a href="https://hydra.nixos.org/jobset/nixos/trunk/jobstatus">automated
regression testing of NixOS itself</a>. A <a href="https://nixos.org/~eelco/pubs/decvms-issre2010-submitted.pdf">draft
of the paper</a> is available.
</description><pubDate>Sat Sep 18 2010 00:00:00 GMT</pubDate></item><item><title>
Xfce in NixOS
</title><link>https://nixos.org/news.html</link><description>
<a href="http://www.xfce.org/"><img class="inline" src="logo/xfce-small.png" alt="Xfce screenshot"/></a>
NixOS now supports <a href="http://www.xfce.org/">Xfce</a>, a
modern, light-weight desktop environment. It can be enabled by
setting the NixOS configuration value
<tt>services.xserver.desktopManager.xfce.enable</tt> to
<tt>true</tt>. (<a href="nixos/screenshots/nixos-xfce.png">Screenshot</a>)
</description><pubDate>Sat Sep 18 2010 00:00:00 GMT</pubDate></item><item><title>
Nix 0.16 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-0.16">Nix
0.16</a> has been released, featuring a much faster evaluator
and support for configurable parallelism inside builders. See
the <a href="https://hydra.nixos.org/build/565033/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/565033/download/1/manual">manual</a>.
</description><pubDate>Fri Sep 17 2010 00:00:00 GMT</pubDate></item><item><title>
NixOS talk at LSM
</title><link>https://nixos.org/news.html</link><description>
Ludovic Courtès gave a talk about Nix and NixOS at the <a href="http://2010.rmll.info/spip.php">Libre Software Meeting</a>
in Bordeaux, entitled <a href="http://2010.rmll.info/NixOS-The-Only-Functional-GNU-Linux-Distribution.html?lang=en"><em>“NixOS:
The Only Functional GNU/Linux Distribution”</em></a> (<a href="http://2010.rmll.info/IMG/pdf/LSM2010-OS-NixOS.pdf">slides</a>).
</description><pubDate>Mon Aug 09 2010 00:00:00 GMT</pubDate></item><item><title>
Nix 0.15 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-0.15">Nix
0.15</a> has been released. This is a bug fix release. See the
<a href="https://hydra.nixos.org/build/326788/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/326788/download/1/manual">manual</a>.
</description><pubDate>Sat Apr 17 2010 00:00:00 GMT</pubDate></item><item><title>
Nix 0.14 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-0.14">Nix
0.14</a> has been released. This is primarily a bug fix
release. See the <a href="https://hydra.nixos.org/build/281118/download/3/release-notes">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/281118/download/1/manual">manual</a>.
</description><pubDate>Thu Mar 04 2010 00:00:00 GMT</pubDate></item><item><title>
Nix logo
</title><link>https://nixos.org/news.html</link><description>
<a href="logo/nixos-hires.png">
<img class="inline" src="logo/nixos-lores.png" alt="Nix logo"/></a> Long overdue, the Nix project finally has a logo!
The logo was originally created by <a href="http://arbitrary.name/">Simon Frankau</a> for the <a href="https://www.haskell.org/haskellwiki/Haskell_logos/New_logo_ideas">Haskell
logo competition</a>, who kindly gave us permission to use it
for the Nix project. (The snowflake motif is even more
appropriate for Nix, because <em>nix</em> is Latin for
<em>snow</em>.) Any further modifications are entirely our
fault.
</description><pubDate>Fri Dec 25 2009 00:00:00 GMT</pubDate></item><item><title>
Nix 0.13 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/release/nix/nix-0.13">Nix
0.13</a> has been released. This is mostly a bug fix release,
although it also adds some new language features. See the <a href="https://hydra.nixos.org/build/118589/download/3/release-notes/">release
notes</a> for details. For installation information, see the <a href="https://hydra.nixos.org/build/118589/download/1/manual/">manual</a>.
</description><pubDate>Sat Dec 05 2009 00:00:00 GMT</pubDate></item><item><title>
LWN.net article on NixOS
</title><link>https://nixos.org/news.html</link><description>
<a href="https://lwn.net/">LWN.net</a> has an <a href="https://lwn.net/Articles/337677/">article about NixOS</a>
written by Koen Vervloesem.
</description><pubDate>Sun Jul 26 2009 00:00:00 GMT</pubDate></item><item><title>
Nixpkgs 0.12 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.12/">Nixpkgs
0.12</a> has been released. See the <a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.12/release-notes/">release
notes</a> for details. Meanwhile, the Nixpkgs trunk has been
<a href="https://svn.nixos.org/websvn/nix/nixpkgs/trunk/?rev=15324&amp;sc=1">updated</a>
to GCC 4.3.3, Glibc 2.9 and X.org 7.4.
</description><pubDate>Sun May 24 2009 00:00:00 GMT</pubDate></item><item><title>
OpenOffice.org 3 in Nixpkgs
</title><link>https://nixos.org/news.html</link><description>
<a href="nixos/screenshots/nixos-openoffice-3.png"><img class="inline screenshot" src="nixos/screenshots/nixos-openoffice-3-small.png" alt="OpenOffice.org 3.0.1 screenshot"/></a>
Lluís Batlle has updated OpenOffice.org in Nixpkgs to 3.0.1
(<a href="nixos/screenshots/nixos-openoffice-3.png">screenshot</a>).
</description><pubDate>Thu May 21 2009 00:00:00 GMT</pubDate></item><item><title>
KDE 4.2 in Nixpkgs/NixOS
</title><link>https://nixos.org/news.html</link><description>
<a href="nixos/screenshots/nixos-kde42-1.png"><img class="inline screenshot" src="nixos/screenshots/nixos-kde42-1-small.png" alt="KDE 4.2 screenshot"/></a>
We now have a fairly complete set of KDE 4.2 packages in Nixpkgs
and NixOS. Previously we had KDE 3.5, but it was rather
incomplete: just <tt>kdelibs</tt> and <tt>kdebase</tt>.
Now we have all that <a href="nixos/screenshots/nixos-kde42-2.png">desktop
goodness</a>, such as <tt>kdemultimedia</tt>,
<tt>kdenetwork</tt> and <tt>kdegames</tt>. You can
enable KDE 4 in NixOS by setting the
<tt>services.xserver.sessionType</tt> option to
<tt>kde4</tt>. Thanks go to Yury G. Kudryashov, Andrew
Morsillo and Sander van der Burg for doing the hard work on
adding KDE 4 to Nixpkgs. (<a href="nixos/screenshots/nixos-kde42-1.png">Screenshot 1</a>,
<a href="nixos/screenshots/nixos-kde42-2.png">screenshot
2</a>.)
</description><pubDate>Thu May 07 2009 00:00:00 GMT</pubDate></item><item><title>
Hydra
</title><link>https://nixos.org/news.html</link><description>
<a href="https://hydra.nixos.org/">
<img class="inline" src="images/hydra.png" alt="Hydra logo"/></a>
<a href="https://hydra.nixos.org/releases/nix/unstable">Nix</a>
and <a href="https://hydra.nixos.org/project/nixos/jobstatus">NixOS</a>
releases are now built in <a href="https://hydra.nixos.org/">Hydra</a>, the new Nix-based
continuous build system. Hydra replaces our <a href="http://buildfarm.st.ewi.tudelft.nl/status">old Nix-based
build farm</a>, which will be phased out soon. There are
several advantages over the old build farm: the build tasks for
a project are scheduled and published separately, so that for
instance a (fast) tarball build doesnt have to wait for a
(slow) Cygwin build; build results are stored in a database,
which will enable all sorts of interesting queries; better error
reporting; a better web interface; and much more. We have
written a <a href="https://nixos.org/~eelco/pubs/hydra-scp-submitted.pdf">draft
paper about Hydra</a>. There are some <a href="https://hydra.nixos.org/job/hydra/trunk/build/latest/download-by-type/doc/manual">instructions
available</a> about how to set up your own Hydra server.
</description><pubDate>Thu Feb 05 2009 00:00:00 GMT</pubDate></item><item><title>
Linux.com article about Nix
</title><link>https://nixos.org/news.html</link><description>
There is an article on <a href="https://www.linux.com/">Linux.com</a> about Nix: <a href="https://www.linux.com/feature/155922">“Nix fixes dependency
hell on all Linux distributions”</a>.
</description><pubDate>Thu Jan 22 2009 00:00:00 GMT</pubDate></item><item><title>
Nix 0.12 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nix/nix-0.12/">Nix
0.12</a> has been released. The most important change is that
Nix no longer needs Berkeley DB to store metadata, but there are
many other improvements. See the <a href="https://nixos.org/releases/nix/nix-0.12/release-notes/">release
notes</a> for details.
</description><pubDate>Sun Dec 21 2008 00:00:00 GMT</pubDate></item><item><title>
DisNix paper accepted at HotSWUp
</title><link>https://nixos.org/news.html</link><description>
<p>
The paper “Atomic Upgrading of Distributed Systems” (by Sander
van der Burg, Eelco Dolstra and Merijn de Jonge) has been
accepted for presentation at the <a href="http://www.hotswup.org/">First ACM Workshop on Hot
Topics in Software Upgrades (HotSWUp)</a>. A <a href="https://nixos.org/~eelco/pubs/atomic-hotswup2008-submitted.pdf">draft
of the paper</a> is available. It describes Sanders masters
thesis research on DisNix, an extension to Nix that allows
deployment and upgrading of distributed systems from a single
declarative description. We will continue this research in
the <a href="http://swerl.tudelft.nl/bin/view/Main/PDS">Jacquard PDS
project</a>, which has now started. (We still have an opening
for a PhD student or a postdoc; please <a href="mailto:visser@acm.org">contact us</a> if youre
interested.)
</p>
</description><pubDate>Thu Oct 09 2008 00:00:00 GMT</pubDate></item><item><title>
NixOS paper accepted at ICFP!
</title><link>https://nixos.org/news.html</link><description>
<p>
The paper “NixOS: A Purely Functional Linux Distribution” (by
Eelco Dolstra and Andres Löh) has been <a href="http://www.icfpconference.org/icfp2008/accepted/accepted.html">accepted</a>
for presentation at the <a href="http://www.icfpconference.org/icfp2008/">2008
International Conference on Functional Programming</a> (ICFP).
It describes NixOS in much greater detail than last years
HotOS paper, and argues why the purely functional style and
features such as laziness are important for system
configuration management. It also provides some measurements
on the actual purity of Nix build actions. A <a href="https://nixos.org/~eelco/pubs/nixos-icfp2008-submitted.pdf">draft
of the paper</a> is available.
</p>
</description><pubDate>Wed Jul 16 2008 00:00:00 GMT</pubDate></item><item><title>
Website back up
</title><link>https://nixos.org/news.html</link><description>
<p>
The Nix website was down for a few days due to cooling
problems in the server room causing the machine to overheat.
These should be resolved now. Apologies for the
inconvenience.
</p>
</description><pubDate>Fri Jun 06 2008 00:00:00 GMT</pubDate></item><item><title>
Website / SVN repositories moved
</title><link>https://nixos.org/news.html</link><description>
<p>
The Nix website has moved to <a href="https://nixos.org/"><tt>nixos.org</tt></a> (hosted at <a href="http://www.tudelft.nl/">TU Delft</a>). The Subversion
repositories have moved to <a href="http://svn.nixos.org/"><tt>svn.nixos.org</tt></a>. See
<a href="http://mail.cs.uu.nl/pipermail/nix-dev/2008-April/000740.html">this
mailing list posting</a> for information about moving existing
SVN working copies.
</p>
</description><pubDate>Sun May 25 2008 00:00:00 GMT</pubDate></item><item><title>
LDTA 2008 paper
</title><link>https://nixos.org/news.html</link><description>
<p>
Eelco Dolstra presented the paper <a href="https://nixos.org/~eelco/pubs/laziness-ldta2008-final.pdf">“Maximal
Laziness — An Efficient Interpretation Technique for Purely
Functional DSLs”</a> at <a href="http://ldta2008.inf.elte.hu/">8th Workshop on Language
Description, Tools and Applications (LDTA 2008)</a>. Its about
caching of evaluation results in the Nix expression evaluator as
a technique to make a simple term-rewriting evaluator efficient.
Slides are <a href="https://nixos.org/~eelco/talks/ldta-apr-2008.pdf">here</a>.
</p>
</description><pubDate>Mon May 05 2008 00:00:00 GMT</pubDate></item><item><title>
Jacquard grant proposal accepted!
</title><link>https://nixos.org/news.html</link><description>
<p>
The <a href="http://www.jacquard.nl/">Jacquard program</a> of
NWO and EZ has granted funding for the Nix-related project “Pull
Deployment of Services” (PDS), which is about improving the
deployment of software and services in complex heterogenous
environments. The grant consists of 368 K€ for a PhD student (4
years) and a postdoc (3 years). If youre interested in these
positions, please have a look at <a href="http://swerl.tudelft.nl/bin/view/Main/PDS">this page</a>,
and dont hesitate to contact <a href="http://swerl.tudelft.nl/bin/view/EelcoVisser">Eelco
Visser</a> or <a href="https://nixos.org/~eelco/">Eelco Dolstra</a>.
</p>
</description><pubDate>Fri Mar 14 2008 00:00:00 GMT</pubDate></item><item><title>
New NixOS ISOs
</title><link>https://nixos.org/news.html</link><description>
<p>
<a href="nixos/screenshots/nixos-installer-help.png"><img class="inline screenshot" src="nixos/screenshots/nixos-installer-help-small.png" alt="NixOS installer online help"/></a>
New NixOS installation CD images for <tt>i686</tt> and
<tt>x86_64</tt> are <a href="https://nixos.org/releases/nixos-0.1pre10083/">available</a>,
which is a good thing as the previous ones were already a few
months old. The new images are Nix 0.11-based, contain <a href="http://www.memtest.org/">Memtest86+</a> as a
convenience, should support more SATA drives, and show online
help (the <a href="https://nixos.org/releases/nixos-unstable-latest/manual/">NixOS
manual</a>) on virtual console 7.
</p>
</description><pubDate>Wed Feb 06 2008 00:00:00 GMT</pubDate></item><item><title>
Nix 0.11 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nix/nix-0.11/">Nix
0.11</a> has been released. This is a major new release
representing over a year of development. The most important
improvement is secure multi-user support. It also features many
usability enhancements and language extensions, many of them
prompted by NixOS, the purely functional Linux distribution
based on Nix. See the <a href="https://nixos.org/releases/nix/nix-0.11/release-notes/">release
notes</a> for details.
</description><pubDate>Thu Jan 31 2008 00:00:00 GMT</pubDate></item><item><title>
Nixpkgs 0.11 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.11/">Nixpkgs
0.11</a> has been released. See the <a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.11/release-notes/">release
notes</a> for details.
</description><pubDate>Fri Oct 12 2007 00:00:00 GMT</pubDate></item><item><title>
OpenOffice in Nixpkgs
</title><link>https://nixos.org/news.html</link><description>
<p>
<a href="nixos/screenshots/nixos-openoffice.png"><img class="inline screenshot" src="nixos/screenshots/nixos-openoffice-small.png" alt="OpenOffice screenshot"/></a>
<a href="https://www.openoffice.org/">OpenOffice</a> is now in
Nixpkgs (<a href="nixos/screenshots/nixos-openoffice.png">screenshot of
OpenOffice 2.2.1 running under NixOS</a>, and <a href="http://www.denbreejen.net/public/nixos/nixos-oo-scrs.png">another
screenshot</a>). Despite being a rather gigantic package (it
takes two hours to compile on an Intel Core 2 6700), OpenOffice
had only two “impurities” (references to paths outside of the
Nix store) in its <a href="https://svn.nixos.org/viewvc/nix/nixpkgs/trunk/pkgs/applications/office/openoffice/">build
process</a> that had to be resolved — a reference to
<tt>/bin/bash</tt> and one to <tt>/usr/lib/libjpeg.so</tt>.</p>
<p>Armijn Hemel, Wouter den
Breejen and Eelco Dolstra contributed to the Nix expression for
OpenOffice.</p>
</description><pubDate>Wed Oct 10 2007 00:00:00 GMT</pubDate></item><item><title>
NixOS progress report
</title><link>https://nixos.org/news.html</link><description>
<p>
<a href="nixos/screenshots/nixos-games.png"><img class="inline screenshot" src="nixos/screenshots/nixos-games-small.png" alt="NixOS screenshot"/></a>
<a href="https://www.winehq.org/">Wine</a> now runs on NixOS!
Finally we can run all those <a href="nixos/screenshots/nixos-games.png">legacy
applications</a>... Thanks to Michael Raskin for adding Wine
and a NPTL-enabled Glibc (which Wine seems to need). This is a
nice application of purely functional package composition, by
the way: Wine didnt work with the standard Glibc in Nixpkgs, so
we just <a href="https://svn.nixos.org/viewvc/nix/nixpkgs/trunk/pkgs/top-level/all-packages.nix?r1=9165&amp;r2=9164&amp;pathrev=9165">pass
it another Glibc at build time</a>.</p>
<p>In other news, Nix 0.11
and Nixpkgs 0.11 will be released soon.</p>
</description><pubDate>Sat Sep 22 2007 00:00:00 GMT</pubDate></item><item><title>
Commits mailing list
</title><link>https://nixos.org/news.html</link><description>
<p>
There is now a <a href="http://mail.cs.uu.nl/mailman/listinfo/nix-commits">mailing
list</a> (<tt>nix-commits@cs.uu.nl</tt>) that you can
subscribe to if you want to receive automatic commit
notifications from the Nix Subversion repository.
</p>
</description><pubDate>Fri Sep 14 2007 00:00:00 GMT</pubDate></item><item><title>
HotOS paper on NixOS
</title><link>https://nixos.org/news.html</link><description>
<p>
Eelco Dolstra presented the paper <a href="docs/papers.html#hotos07"><em>Purely Functional System
Configuration Management</em></a> at the <a href="https://www.usenix.org/events/hotos07/">11th Workshop on
Hot Topics in Operating Systems (HotOS XI)</a>. It gives an
overview of the ideas behind <a href="nixos">NixOS</a>. The
<a href="http://people.cs.uu.nl/eelco/talks/hotos-may-2007.pdf">slides</a>
are also available.
</p>
</description><pubDate>Fri Jun 08 2007 00:00:00 GMT</pubDate></item><item><title>
NixOS progress report
</title><link>https://nixos.org/news.html</link><description>
<p>
<a href="https://www.kde.org"><img class="inline screenshot" src="nixos/klogo-official-crystal-128x128.png" alt="KDE logo"/></a>
We now have <a href="https://www.kde.org/">KDE</a> running on
NixOS (<a href="nixos/screenshots/nixos-kde.png">obligatory
screenshot</a>). Just <tt>kdebase</tt> for now (Martin
Bravenboer already added <tt>kdelibs</tt> a long time ago so
that we could run the wonderful <a href="http://kcachegrind.sourceforge.net/cgi-bin/show.cgi">KCachegrind</a>),
but it contains all the important stuff (Konqueror, KDesktop,
Kicker, Konsole, Control Center, etc.).</p>
<p>In related news, we can
safely say that, rumours to the contrary notwithstanding, NixOS
is not an <a href="http://www.osnews.com/comment.php?news_id=17601">April
Fools Joke</a>.</p>
</description><pubDate>Wed May 02 2007 00:00:00 GMT</pubDate></item><item><title>
NixOS progress report
</title><link>https://nixos.org/news.html</link><description>
<a href="nixos/screenshots/nixos-compiz-cube.png"><img class="inline screenshot" src="nixos/screenshots/nixos-compiz-cube-small.png" alt="NixOS screenshot"/></a>
NixOS is now <em>almost</em> usable as a desktop OS ;-). We
have an X server, a bunch of Gnome packages, basic wireless
support, and of course all the applications in Nixpkgs that we
had all along running on other Linux distributions. Here are a
few screenshots:
<ul>
<li><a href="nixos/screenshots/nixos-compiz-cube.png">X server
with Compiz window manager</a>.</li>
<li><a href="nixos/screenshots/nixos-terminals.png">Emacs and
a few terminals</a> showing off the (near) absence of
<tt>/lib</tt>, <tt>/bin</tt> etc.; everything is in the Nix
store.</li>
<li><a href="nixos/screenshots/nixos-apps.png">Some
applications</a>.</li>
</ul>
</description><pubDate>Thu Apr 05 2007 00:00:00 GMT</pubDate></item><item><title>
NixOS manual
</title><link>https://nixos.org/news.html</link><description>
There is now some <a href="https://nixos.org/releases/nixos/unstable/manual/">basic
documentation for NixOS</a>.
</description><pubDate>Mon Mar 19 2007 00:00:00 GMT</pubDate></item><item><title>
NixOS for x86_64
</title><link>https://nixos.org/news.html</link><description>
NixOS now works on x86_64 machines. A 64-bit ISO is <a href="nixos/#download">available</a>.
</description><pubDate>Fri Feb 23 2007 00:00:00 GMT</pubDate></item><item><title>
New build farm hardware at TUD
</title><link>https://nixos.org/news.html</link><description>
<p><a href="https://www.flickr.com/photos/eelcovisser/367433201/"><img class="inline screenshot" src="https://farm1.static.flickr.com/185/367433201_9ee5ad0986_m.jpg" alt="New build farm"/></a>To quote Eelco Visser: new
hardware for buildfarm at Delft University of Technology has
arrived.</p>
<p>Heres what we have: 5 Intel Core 2 Duo DualCore machines
with 1GB RAM, 2 Mac minis with 1,83-GHz Intel Core
Duo-processor, another Core 2 Duo a UPS to deal with spikes in
power supply, a console with integrated monitor and keyboard
switches, a rack with room for a couple more machines.</p>
<p>Heres what were going to do with the goodies. The five
Intel machines and the two MacMinis (also Intel) are going to
be used to crank at building hundreds of software
packages. Using virtualisation we should be able to run builds
on multiple operating system distributions. <a href="http://blog.eelcovisser.net/index.php?/archives/36-Bootfarm.html">Read
more…</a></p>
</description><pubDate>Fri Feb 23 2007 00:00:00 GMT</pubDate></item><item><title>
Nixpkgs 0.10 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.10/">Nixpkgs
0.10</a> has been released. See the <a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.10/release-notes/">release
notes</a> for details.
</description><pubDate>Sun Nov 12 2006 00:00:00 GMT</pubDate></item><item><title>
Nix 0.10.1 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nix/nix-0.10.1/">Nix
0.10.1</a> has been released. It fixes two obscure bugs that
shouldnt affect most users.
</description><pubDate>Sat Nov 11 2006 00:00:00 GMT</pubDate></item><item><title>
Nix 0.10 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nix/nix-0.10/">Nix
0.10</a> has been released. This release has many
improvements and bug fixes; see the <a href="https://nixos.org/releases/nix/nix-0.10/release-notes/">release
notes</a> for details.
</description><pubDate>Mon Nov 06 2006 00:00:00 GMT</pubDate></item><item><title>
Nixpkgs 0.9 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nixpkgs/nixpkgs-0.9/">Nixpkgs
0.9</a> has been <a href="http://mail.cs.uu.nl/pipermail/nix-dev/2006-January/000121.html">released</a>.
</description><pubDate>Fri Mar 03 2006 00:00:00 GMT</pubDate></item><item><title>
PhD thesis defended
</title><link>https://nixos.org/news.html</link><description>
<a href="http://www.cs.uu.nl/~eelco">Eelco Dolstra</a>
defended his <a href="docs/papers.html#dolstra06thesis">PhD
thesis</a> on the purely functional deployment model.
</description><pubDate>Sat Feb 18 2006 00:00:00 GMT</pubDate></item><item><title>
Nix 0.9.2 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nix/nix-0.9.2/">Nix
0.9.2</a> has been released released. This is a bug fix
release that addresses some problems on Mac OS X.
</description><pubDate>Fri Oct 21 2005 00:00:00 GMT</pubDate></item><item><title>
Nix 0.9 released
</title><link>https://nixos.org/news.html</link><description>
<a href="https://nixos.org/releases/nix/nix-0.9/">Nix 0.9</a>
has been released. This is a new major release that provides
quite a few performance improvements and bug fixes, as well as a
number of new features. Read the <a href="https://nixos.org/releases/nix/nix-0.9/release-notes/">release
notes</a> for details.
</description><pubDate>Sun Oct 16 2005 00:00:00 GMT</pubDate></item><item><title>
Secure sharing paper accepted for ASE 2005
</title><link>https://nixos.org/news.html</link><description>
The paper “Secure Sharing Between Untrusted Users in a
Transparent Source/Binary Deployment Model” has been accepted at
<a href="http://www.ase-conference.org/">ASE 2005</a>. This
paper describes how a Nix store can be securely shared by
multiple users who may not trust each other; i.e., how do we
prevent one user from installing a Trojan horse that is
subsequently executed by some other user?
</description><pubDate>Sun Aug 28 2005 00:00:00 GMT</pubDate></item><item><title>
Service deployment paper accepted for SCM-12
</title><link>https://nixos.org/news.html</link><description>
The paper “Service Configuration Management” (accepted at the
<a href="https://users.soe.ucsc.edu/~ejw/scm12/">12th
International Workshop on Software Configuration
Management</a>) describes how we can rather easily deploy
“services” (e.g., complete webserver configurations such as our
<a href="http://svn.nixos.org/">Subversion server</a>) through
Nix by treating the non-component parts (such as configuration
files, control scripts and static data) as components that are
built by Nix expressions. The result is that all advantages
that Nix offers to software deployment also extend to service
deployment, such as the ability to easily have multiple
configuration side by side, to roll back configurations, and to
identify the precise dependencies of a configuration.
</description><pubDate>Mon Aug 22 2005 00:00:00 GMT</pubDate></item><item><title>
Patching paper accepted for CBSE 2005
</title><link>https://nixos.org/news.html</link><description>
The paper “Efficient Upgrading in a Purely Functional Component
Deployment Model” has been accepted at <a href="http://www.sei.cmu.edu/pacc/CBSE2005/">CBSE 2005</a>.
It describes how we can deploy updates to Nix packages
efficiently, even if “fundamental” packages like Glibc are
updated (which cause a rebuild of all dependent packages), by
deploying binary patches between components in the Nix store.
Includes techniques such as patch chaining and computing deltas
between archive files.
</description><pubDate>Thu Mar 17 2005 00:00:00 GMT</pubDate></item><item><title>
Paper “Imposing a Memory Management Discipline on Software
Deployment” accepted for presentation at ICSE 2004!
</title><link>https://nixos.org/news.html</link><description>
The first Nix paper.
</description><pubDate>Fri Jan 16 2004 00:00:00 GMT</pubDate></item></channel></rss>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">FeederD</string>
</resources>

View file

@ -0,0 +1,139 @@
<?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="com.nononsenseapps.feeder"
android:installLocation="internalOnly">
<!-- Import export feeds -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- For syncing -->
<uses-permission android:name="android.permission.INTERNET" /> <!-- To limit syncing to only WiFi -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".FeederApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppThemeDay"
android:usesCleartextTraffic="true">
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
<activity
android:name=".ui.FeedActivity"
android:label="@string/app_name">
<nav-graph android:value="@navigation/nav_graph" />
<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.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".ui.EditFeedActivity"
android:label="@string/title_activity_edit_feed"
android:parentActivityName=".ui.FeedActivity"
android:theme="@style/EditFeedThemeDay"
android:windowSoftInputMode="adjustResize|stateVisible">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="com.nononsenseapps.feeder.ui.FeedActivity" />
<!-- URLs with feed mimetype can be opened -->
<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="http" />
<data android:scheme="https" />
<data android:host="*" />
<data android:mimeType="text/xml" />
<data android:mimeType="application/rss+xml" />
<data android:mimeType="application/atom+xml" />
<data android:mimeType="application/xml" />
<data android:mimeType="application/json" />
</intent-filter>
<!-- URLs ending with '.xml' or '.rss' can be opened directly-->
<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="http" />
<data android:scheme="https" />
<data android:host="*" />
<data android:pathPattern=".*\\.xml" />
<data android:pathPattern=".*\\.rss" />
<data android:pathPattern=".*\\.atom" />
<data android:pathPattern=".*\\.json" />
</intent-filter>
<!-- FeedBurner URLs can be opened directly-->
<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="http" />
<data android:scheme="https" />
<data android:host="feeds.feedburner.com" />
<data android:host="feedproxy.google.com" />
<data android:host="feeds2.feedburner.com" />
<data android:host="feedsproxy.google.com" />
</intent-filter>
<!-- Any other URL can be shared with the app -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*" />
</intent-filter>
<!-- Also possible to share pure text -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!-- Receiver for notification cancellations and such -->
<receiver android:name=".model.RssNotificationBroadcastReceiver" />
<activity
android:name=".ui.OpenLinkInDefaultActivity"
android:label="@string/open_link_in_browser"
android:launchMode="singleInstance"
android:taskAffinity="${applicationId}.OpenLinkTask" />
<service
android:name=".model.FeederService"
android:exported="true" />
</application>
</manifest>

View file

@ -0,0 +1,9 @@
package com.nononsenseapps.feeder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
class ApplicationCoroutineScope : CoroutineScope {
override val coroutineContext = Dispatchers.Default + SupervisorJob()
}

View file

@ -0,0 +1,147 @@
package com.nononsenseapps.feeder
import android.app.Application
import android.content.ContentResolver
import android.content.SharedPreferences
import android.os.Build.VERSION.SDK_INT
import android.widget.Toast
import androidx.core.app.NotificationManagerCompat
import androidx.multidex.MultiDexApplication
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.jakewharton.threetenabp.AndroidThreeTen
import com.nononsenseapps.feeder.db.room.AppDatabase
import com.nononsenseapps.feeder.db.room.FeedDao
import com.nononsenseapps.feeder.db.room.FeedItemDao
import com.nononsenseapps.feeder.di.networkModule
import com.nononsenseapps.feeder.di.stateModule
import com.nononsenseapps.feeder.di.viewModelModule
import com.nononsenseapps.feeder.model.UserAgentInterceptor
import com.nononsenseapps.feeder.util.AsyncImageLoader
import com.nononsenseapps.feeder.util.Prefs
import com.nononsenseapps.feeder.util.ToastMaker
import com.nononsenseapps.jsonfeed.cachingHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import okhttp3.Cache
import okhttp3.CacheControl
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.singleton
import java.io.File
import java.security.Security
import java.util.concurrent.TimeUnit
@FlowPreview
@ExperimentalCoroutinesApi
@Suppress("unused")
class FeederApplication : MultiDexApplication(), KodeinAware {
private val applicationCoroutineScope = ApplicationCoroutineScope()
override val kodein by Kodein.lazy {
// import(androidXModule(this@FeederApplication))
bind<Application>() with singleton { this@FeederApplication }
bind<AppDatabase>() with singleton { AppDatabase.getInstance(this@FeederApplication) }
bind<FeedDao>() with singleton { instance<AppDatabase>().feedDao() }
bind<FeedItemDao>() with singleton { instance<AppDatabase>().feedItemDao() }
import(viewModelModule)
bind<WorkManager>() with singleton { WorkManager.getInstance(this@FeederApplication) }
bind<ContentResolver>() with singleton { contentResolver }
bind<ToastMaker>() with singleton {
object : ToastMaker {
override suspend fun makeToast(text: String) = withContext(Dispatchers.Main) {
Toast.makeText(this@FeederApplication, text, Toast.LENGTH_SHORT).show()
}
}
}
bind<NotificationManagerCompat>() with singleton { NotificationManagerCompat.from(this@FeederApplication) }
bind<SharedPreferences>() with singleton { PreferenceManager.getDefaultSharedPreferences(this@FeederApplication) }
bind<Prefs>() with singleton { Prefs(kodein) }
bind<OkHttpClient>() with singleton {
cachingHttpClient(
cacheDirectory = (externalCacheDir ?: filesDir).resolve("http")
).newBuilder()
.addNetworkInterceptor(UserAgentInterceptor)
.build()
}
bind<ImageLoader>() with singleton {
val prefs = instance<Prefs>()
val okHttpClient = instance<OkHttpClient>()
.newBuilder()
// Use separate image cache or images will quickly evict feed caches
.cache(Cache((externalCacheDir ?: filesDir).resolve("img"), 20L * 1024L * 1024L))
.addInterceptor { chain ->
chain.proceed(
when (prefs.shouldLoadImages()) {
true -> chain.request()
false -> {
// Forces only cached responses to be used - if no cache then 504 is thrown
chain.request().newBuilder()
.cacheControl(
CacheControl.Builder()
.onlyIfCached()
.maxStale(Int.MAX_VALUE, TimeUnit.SECONDS)
.maxAge(Int.MAX_VALUE, TimeUnit.SECONDS)
.build()
)
.build()
}
}
)
}
.build()
ImageLoader.Builder(instance())
.okHttpClient(okHttpClient = okHttpClient)
.componentRegistry {
add(SvgDecoder(applicationContext))
if (SDK_INT >= 28) {
add(ImageDecoderDecoder())
} else {
add(GifDecoder())
}
}
.build()
}
bind<AsyncImageLoader>() with singleton { AsyncImageLoader(kodein) }
bind<ApplicationCoroutineScope>() with instance(applicationCoroutineScope)
import(networkModule)
import(stateModule)
}
init {
// Install Conscrypt to handle TLSv1.3 pre Android10
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
override fun onCreate() {
super.onCreate()
AndroidThreeTen.init(this)
staticFilesDir = filesDir
}
override fun onTerminate() {
applicationCoroutineScope.cancel("Application is being terminated")
super.onTerminate()
}
companion object {
// Needed for database migration
lateinit var staticFilesDir: File
}
}

View file

@ -0,0 +1,25 @@
package com.nononsenseapps.feeder.base
import android.annotation.SuppressLint
import android.view.MenuInflater
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
/**
* A fragment which is also Kodein aware.
*/
@SuppressLint("Registered")
open class KodeinAwareActivity : AppCompatActivity(), KodeinAware {
private val parentKodein: Kodein by closestKodein()
override val kodein: Kodein by Kodein.lazy {
extend(parentKodein)
bind<MenuInflater>() with provider { menuInflater }
bind<FragmentActivity>() with instance(this@KodeinAwareActivity)
}
}

View file

@ -0,0 +1,13 @@
package com.nononsenseapps.feeder.base
import androidx.fragment.app.DialogFragment
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.closestKodein
/**
* A dialog fragment which is also Kodein aware.
*/
open class KodeinAwareDialogFragment : DialogFragment(), KodeinAware {
override val kodein: Kodein by closestKodein()
}

View file

@ -0,0 +1,13 @@
package com.nononsenseapps.feeder.base
import androidx.fragment.app.Fragment
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.closestKodein
/**
* A fragment which is also Kodein aware.
*/
open class KodeinAwareFragment : Fragment(), KodeinAware {
override val kodein: Kodein by closestKodein()
}

View file

@ -0,0 +1,10 @@
package com.nononsenseapps.feeder.base
import android.app.IntentService
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.closestKodein
abstract class KodeinAwareIntentService(name: String) : IntentService(name), KodeinAware {
override val kodein: Kodein by closestKodein()
}

View file

@ -0,0 +1,63 @@
package com.nononsenseapps.feeder.base
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.bindings.Factory
import org.kodein.di.bindings.Provider
import org.kodein.di.direct
import org.kodein.di.generic.bind
import org.kodein.di.generic.factory
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import java.lang.reflect.InvocationTargetException
/**
* A view model which is also kodein aware. Construct any deriving class by using the getViewModel()
* extension function.
*/
open class KodeinAwareViewModel(override val kodein: Kodein) : AndroidViewModel(kodein.direct.instance()), KodeinAware
class KodeinAwareViewModelFactory(override val kodein: Kodein) :
ViewModelProvider.AndroidViewModelFactory(kodein.direct.instance()), KodeinAware {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return if (KodeinAwareViewModel::class.java.isAssignableFrom(modelClass)) {
try {
modelClass.getConstructor(Kodein::class.java).newInstance(kodein)
} catch (e: NoSuchMethodException) {
throw RuntimeException("No such constructor $modelClass", e)
} catch (e: IllegalAccessException) {
throw RuntimeException("Cannot create an instance of $modelClass", e)
} catch (e: InstantiationException) {
throw RuntimeException("Cannot create an instance of $modelClass", e)
} catch (e: InvocationTargetException) {
throw RuntimeException("Cannot create an instance of $modelClass", e)
}
} else {
super.create(modelClass)
}
}
}
inline fun <C, reified T : KodeinAwareViewModel> Kodein.BindBuilder.WithContext<C>.activityViewModelProvider():
Provider<C, T> {
return provider {
ViewModelProvider(instance<FragmentActivity>(), instance<KodeinAwareViewModelFactory>()).get(T::class.java)
}
}
inline fun <C, reified T : KodeinAwareViewModel> Kodein.BindBuilder.WithContext<C>.fragmentViewModelFactory():
Factory<C, Fragment, T> {
return factory { fragment: Fragment ->
ViewModelProvider(fragment, instance<KodeinAwareViewModelFactory>()).get(T::class.java)
}
}
inline fun <reified T : KodeinAwareViewModel> Kodein.Builder.bindWithKodeinAwareViewModelFactory() {
bind<T>() with activityViewModelProvider()
bind<T>() with fragmentViewModelFactory()
}

View file

@ -0,0 +1,30 @@
package com.nononsenseapps.feeder.blob
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
fun blobFile(itemId: Long, filesDir: File): File =
File(filesDir, "$itemId.txt.gz")
@Throws(IOException::class)
fun blobInputStream(itemId: Long, filesDir: File): InputStream =
GZIPInputStream(blobFile(itemId = itemId, filesDir = filesDir).inputStream())
@Throws(IOException::class)
fun blobOutputStream(itemId: Long, filesDir: File): OutputStream =
GZIPOutputStream(blobFile(itemId = itemId, filesDir = filesDir).outputStream())
fun blobFullFile(itemId: Long, filesDir: File): File =
File(filesDir, "$itemId.full.html.gz")
@Throws(IOException::class)
fun blobFullInputStream(itemId: Long, filesDir: File): InputStream =
GZIPInputStream(blobFullFile(itemId = itemId, filesDir = filesDir).inputStream())
@Throws(IOException::class)
fun blobFullOutputStream(itemId: Long, filesDir: File): OutputStream =
GZIPOutputStream(blobFullFile(itemId = itemId, filesDir = filesDir).outputStream())

View file

@ -0,0 +1,31 @@
package com.nononsenseapps.feeder.db
const val FEEDS_TABLE_NAME = "feeds"
const val FEED_ITEMS_TABLE_NAME = "feed_items"
const val COL_ID = "id"
const val COL_TITLE = "title"
const val COL_CUSTOM_TITLE = "custom_title"
const val COL_URL = "url"
const val COL_TAG = "tag"
const val COL_NOTIFY = "notify"
const val COL_GUID = "guid"
const val COL_PLAINTITLE = "plain_title"
const val COL_PLAINSNIPPET = "plain_snippet"
const val COL_IMAGEURL = "image_url"
const val COL_ENCLOSURELINK = "enclosure_link"
const val COL_LINK = "link"
const val COL_AUTHOR = "author"
const val COL_PUBDATE = "pub_date"
const val COL_UNREAD = "unread"
const val COL_NOTIFIED = "notified"
const val COL_FEEDID = "feed_id"
const val COL_FEEDTITLE = "feed_title"
const val COL_FEEDCUSTOMTITLE = "feed_customtitle"
const val COL_FEEDURL = "feed_url"
const val COL_LASTSYNC = "last_sync"
const val COL_RESPONSEHASH = "response_hash"
const val COL_FIRSTSYNCEDTIME = "first_synced_time"
const val COL_PRIMARYSORTTIME = "primary_sort_time"
const val COL_FULLTEXT_BY_DEFAULT = "fulltext_by_default"
const val COL_OPEN_ARTICLES_WITH = "open_articles_with"

View file

@ -0,0 +1,16 @@
@file:Suppress("RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
package com.nononsenseapps.feeder.db
import android.net.Uri
const val AUTHORITY = "com.nononsenseapps.feeder.provider"
const val SCHEME = "content://"
// URIs
// Feed
@JvmField
val URI_FEEDS: Uri = Uri.withAppendedPath(Uri.parse(SCHEME + AUTHORITY), "feeds")
// Feed item
@JvmField
val URI_FEEDITEMS: Uri = Uri.withAppendedPath(Uri.parse(SCHEME + AUTHORITY), "feed_items")

View file

@ -0,0 +1,354 @@
package com.nononsenseapps.feeder.db.room
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.nononsenseapps.feeder.FeederApplication
import com.nononsenseapps.feeder.blob.blobOutputStream
import com.nononsenseapps.feeder.util.contentValues
import com.nononsenseapps.feeder.util.forEach
import com.nononsenseapps.feeder.util.setInt
import com.nononsenseapps.feeder.util.setLong
import com.nononsenseapps.feeder.util.setString
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
const val DATABASE_NAME = "rssDatabase"
const val ID_UNSET: Long = 0
const val ID_ALL_FEEDS: Long = -10
/**
* Database versions
* 4: Was using the RSS Server
* 5: Added feed url field to feed_item
* 6: Added feed icon field to feeds
* 7: Migration to Room
*/
@FlowPreview
@Database(entities = [Feed::class, FeedItem::class], version = 14)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun feedDao(): FeedDao
abstract fun feedItemDao(): FeedItemDao
@FlowPreview
@ExperimentalCoroutinesApi
companion object {
// For Singleton instantiation
@Volatile
private var instance: AppDatabase? = null
/**
* Use this in tests only
*/
internal fun setInstance(db: AppDatabase) {
instance = db
}
fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
.addMigrations(*allMigrations)
.build()
}
}
}
@FlowPreview
@ExperimentalCoroutinesApi
val allMigrations = arrayOf(
MIGRATION_5_7,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14
)
/*
* 6 represents legacy database
* 7 represents new Room database
*/
@FlowPreview
@ExperimentalCoroutinesApi
@Suppress("ClassName")
object MIGRATION_13_14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE feeds ADD COLUMN open_articles_with TEXT NOT NULL DEFAULT ''
""".trimIndent()
)
}
}
@FlowPreview
@ExperimentalCoroutinesApi
@Suppress("ClassName")
object MIGRATION_12_13 : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE feeds ADD COLUMN fulltext_by_default INTEGER NOT NULL DEFAULT 0
""".trimIndent()
)
}
}
@FlowPreview
@ExperimentalCoroutinesApi
@Suppress("ClassName")
object MIGRATION_11_12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE feed_items ADD COLUMN primary_sort_time INTEGER NOT NULL DEFAULT 0
""".trimIndent()
)
}
}
@FlowPreview
@ExperimentalCoroutinesApi
@Suppress("ClassName")
object MIGRATION_10_11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE feed_items ADD COLUMN first_synced_time INTEGER NOT NULL DEFAULT 0
""".trimIndent()
)
}
}
@FlowPreview
@ExperimentalCoroutinesApi
@Suppress("ClassName")
object MIGRATION_9_10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `feed_items_new` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent()
)
database.execSQL(
"""
INSERT INTO `feed_items_new` (`id`, `guid`, `title`, `plain_title`, `plain_snippet`, `image_url`, `enclosure_link`, `author`, `pub_date`, `link`, `unread`, `notified`, `feed_id`)
SELECT `id`, `guid`, `title`, `plain_title`, `plain_snippet`, `image_url`, `enclosure_link`, `author`, `pub_date`, `link`, `unread`, `notified`, `feed_id` FROM `feed_items`
""".trimIndent()
)
// Iterate over all items using the minimum query. Also restrict the text field to
// 1 MB which should be safe enough considering the window size is 2MB large.
database.query(
"""
SELECT id, substr(description,0,1000000) FROM feed_items
""".trimIndent()
)?.use { cursor ->
cursor.forEach {
val feedItemId = cursor.getLong(0)
val description = cursor.getString(1)
blobOutputStream(feedItemId, FeederApplication.staticFilesDir).bufferedWriter().use {
it.write(description)
}
}
}
database.execSQL(
"""
DROP TABLE feed_items
""".trimIndent()
)
database.execSQL(
"""
ALTER TABLE feed_items_new RENAME TO feed_items
""".trimIndent()
)
database.execSQL(
"""
CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `feed_items` (`guid`, `feed_id`)
""".trimIndent()
)
database.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `feed_items` (`feed_id`)
""".trimIndent()
)
// And reset response hash on all feeds to trigger parsing of results next sync so items
// are written disk (in case migration substring was too short)
database.execSQL(
"""
UPDATE `feeds` SET `response_hash` = 0
""".trimIndent()
)
}
}
object MIGRATION_8_9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE feeds ADD COLUMN response_hash INTEGER NOT NULL DEFAULT 0
""".trimIndent()
)
}
}
object MIGRATION_7_8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE feeds ADD COLUMN last_sync INTEGER NOT NULL DEFAULT 0
""".trimIndent()
)
}
}
object MIGRATION_6_7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
legacyMigration(database, 6)
}
}
object MIGRATION_5_7 : Migration(5, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
legacyMigration(database, 5)
}
}
private fun legacyMigration(database: SupportSQLiteDatabase, version: Int) {
// Create new tables and indices
// Feeds
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `feeds` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT)
""".trimIndent()
)
database.execSQL(
"""
CREATE UNIQUE INDEX `index_Feed_url` ON `feeds` (`url`)
""".trimIndent()
)
database.execSQL(
"""
CREATE UNIQUE INDEX `index_Feed_id_url_title` ON `feeds` (`id`, `url`, `title`)
""".trimIndent()
)
// Items
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `feed_items` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent()
)
database.execSQL(
"""
CREATE UNIQUE INDEX `index_feed_item_guid_feed_id` ON `feed_items` (`guid`, `feed_id`)
""".trimIndent()
)
database.execSQL(
"""
CREATE INDEX `index_feed_item_feed_id` ON `feed_items` (`feed_id`)
""".trimIndent()
)
// Migrate to new tables
database.query(
"""
SELECT _id, title, url, tag, customtitle, notify ${if (version == 6) ", imageUrl" else ""}
FROM Feed
""".trimIndent()
)?.use { cursor ->
cursor.forEach { _ ->
val oldFeedId = cursor.getLong(0)
val newFeedId = database.insert(
"feeds",
SQLiteDatabase.CONFLICT_FAIL,
contentValues {
setString("title" to cursor.getString(1))
setString("custom_title" to cursor.getString(4))
setString("url" to cursor.getString(2))
setString("tag" to cursor.getString(3))
setInt("notify" to cursor.getInt(5))
if (version == 6) {
setString("image_url" to cursor.getString(6))
}
}
)
database.query(
"""
SELECT title, description, plainTitle, plainSnippet, imageUrl, link, author,
pubdate, unread, feed, enclosureLink, notified, guid
FROM FeedItem
WHERE feed = $oldFeedId
""".trimIndent()
)?.use { cursor ->
database.inTransaction {
cursor.forEach { _ ->
database.insert(
"feed_items",
SQLiteDatabase.CONFLICT_FAIL,
contentValues {
setString("guid" to cursor.getString(12))
setString("title" to cursor.getString(0))
setString("description" to cursor.getString(1))
setString("plain_title" to cursor.getString(2))
setString("plain_snippet" to cursor.getString(3))
setString("image_url" to cursor.getString(4))
setString("enclosure_link" to cursor.getString(10))
setString("author" to cursor.getString(6))
setString("pub_date" to cursor.getString(7))
setString("link" to cursor.getString(5))
setInt("unread" to cursor.getInt(8))
setInt("notified" to cursor.getInt(11))
setLong("feed_id" to newFeedId)
}
)
}
}
}
}
}
// Remove all legacy content
database.execSQL("DROP TRIGGER IF EXISTS trigger_tag_updater")
database.execSQL("DROP VIEW IF EXISTS WithUnreadCount")
database.execSQL("DROP VIEW IF EXISTS TagsWithUnreadCount")
database.execSQL("DROP TABLE IF EXISTS Feed")
database.execSQL("DROP TABLE IF EXISTS FeedItem")
}
fun SupportSQLiteDatabase.inTransaction(init: (SupportSQLiteDatabase) -> Unit) {
beginTransaction()
try {
init(this)
setTransactionSuccessful()
} finally {
endTransaction()
}
}

View file

@ -0,0 +1,46 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.TypeConverter
import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
import org.threeten.bp.Instant
import org.threeten.bp.ZonedDateTime
import java.net.URL
class Converters {
@TypeConverter
fun dateTimeFromString(value: String?): ZonedDateTime? {
var dt: ZonedDateTime? = null
if (value != null) {
try {
dt = ZonedDateTime.parse(value)
} catch (t: Throwable) {
}
}
return dt
}
@TypeConverter
fun stringFromDateTime(value: ZonedDateTime?): String? =
value?.toString()
@TypeConverter
fun stringFromURL(value: URL?): String? =
value?.toString()
@TypeConverter
fun urlFromString(value: String?): URL? =
value?.let { sloppyLinkToStrictURLNoThrows(it) }
@TypeConverter
fun instantFromLong(value: Long?): Instant? =
try {
value?.let { Instant.ofEpochMilli(it) }
} catch (t: Throwable) {
null
}
@TypeConverter
fun longFromInstant(value: Instant?): Long? =
value?.toEpochMilli()
}

View file

@ -0,0 +1,51 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
import com.nononsenseapps.feeder.db.COL_FULLTEXT_BY_DEFAULT
import com.nononsenseapps.feeder.db.COL_ID
import com.nononsenseapps.feeder.db.COL_IMAGEURL
import com.nononsenseapps.feeder.db.COL_LASTSYNC
import com.nononsenseapps.feeder.db.COL_NOTIFY
import com.nononsenseapps.feeder.db.COL_RESPONSEHASH
import com.nononsenseapps.feeder.db.COL_TAG
import com.nononsenseapps.feeder.db.COL_TITLE
import com.nononsenseapps.feeder.db.COL_URL
import com.nononsenseapps.feeder.db.COL_OPEN_ARTICLES_WITH
import com.nononsenseapps.feeder.db.FEEDS_TABLE_NAME
import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
import org.threeten.bp.Instant
import java.net.URL
const val OPEN_ARTICLE_WITH_APPLICATION_DEFAULT = ""
@Entity(
tableName = FEEDS_TABLE_NAME,
indices = [
Index(value = [COL_URL], unique = true),
Index(value = [COL_ID, COL_URL, COL_TITLE], unique = true)
]
)
data class Feed @Ignore constructor(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = COL_ID) var id: Long = ID_UNSET,
@ColumnInfo(name = COL_TITLE) var title: String = "",
@ColumnInfo(name = COL_CUSTOM_TITLE) var customTitle: String = "",
@ColumnInfo(name = COL_URL) var url: URL = sloppyLinkToStrictURL(""),
@ColumnInfo(name = COL_TAG) var tag: String = "",
@ColumnInfo(name = COL_NOTIFY) var notify: Boolean = false,
@ColumnInfo(name = COL_IMAGEURL) var imageUrl: URL? = null,
@ColumnInfo(name = COL_LASTSYNC, typeAffinity = ColumnInfo.INTEGER) var lastSync: Instant = Instant.EPOCH,
@ColumnInfo(name = COL_RESPONSEHASH) var responseHash: Int = 0,
@ColumnInfo(name = COL_FULLTEXT_BY_DEFAULT) var fullTextByDefault: Boolean = false,
@ColumnInfo(name = COL_OPEN_ARTICLES_WITH) var openArticlesWith: String = ""
) {
constructor() : this(id = ID_UNSET)
val displayTitle: String
get() = (if (customTitle.isBlank()) title else customTitle)
}

View file

@ -0,0 +1,134 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
import com.nononsenseapps.feeder.db.COL_ID
import com.nononsenseapps.feeder.db.COL_TAG
import com.nononsenseapps.feeder.db.COL_TITLE
import com.nononsenseapps.feeder.model.FeedUnreadCount
import kotlinx.coroutines.flow.Flow
import java.net.URL
@Dao
interface FeedDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFeed(feed: Feed): Long
@Update
suspend fun updateFeed(feed: Feed)
@Delete
suspend fun deleteFeed(feed: Feed)
@Query("DELETE FROM feeds WHERE id IS :feedId")
suspend fun deleteFeedWithId(feedId: Long)
@Query(
"""
DELETE FROM feeds WHERE id IN (:ids)
"""
)
suspend fun deleteFeeds(ids: List<Long>)
@Query("SELECT * FROM feeds WHERE id IS :feedId")
fun loadLiveFeed(feedId: Long): Flow<Feed?>
@Query("SELECT DISTINCT tag FROM feeds ORDER BY tag COLLATE NOCASE")
suspend fun loadTags(): List<String>
@Query("SELECT * FROM feeds WHERE id IS :feedId")
suspend fun loadFeed(feedId: Long): Feed?
@Query(
"""
SELECT * FROM feeds
WHERE id is :feedId
AND last_sync < :staleTime
"""
)
suspend fun loadFeedIfStale(feedId: Long, staleTime: Long): Feed?
@Query("SELECT * FROM feeds WHERE tag IS :tag")
suspend fun loadFeeds(tag: String): List<Feed>
@Query("SELECT * FROM feeds WHERE tag IS :tag AND last_sync < :staleTime")
suspend fun loadFeedsIfStale(tag: String, staleTime: Long): List<Feed>
@Query("SELECT notify FROM feeds WHERE tag IS :tag")
fun loadLiveFeedsNotify(tag: String): Flow<List<Boolean>>
@Query("SELECT notify FROM feeds WHERE id IS :feedId")
fun loadLiveFeedsNotify(feedId: Long): Flow<List<Boolean>>
@Query("SELECT notify FROM feeds")
fun loadLiveFeedsNotify(): Flow<List<Boolean>>
@Query("SELECT * FROM feeds")
suspend fun loadFeeds(): List<Feed>
@Query("SELECT * FROM feeds WHERE last_sync < :staleTime")
suspend fun loadFeedsIfStale(staleTime: Long): List<Feed>
@Query("SELECT * FROM feeds WHERE url IS :url")
suspend fun loadFeedWithUrl(url: URL): Feed?
@Query("SELECT id FROM feeds WHERE notify IS 1")
suspend fun loadFeedIdsToNotify(): List<Long>
@Query(
"""
SELECT id, title, url, tag, custom_title, notify, image_url, unread_count
FROM feeds
LEFT JOIN (SELECT COUNT(1) AS unread_count, feed_id
FROM feed_items
WHERE unread IS 1
GROUP BY feed_id
)
ON feeds.id = feed_id
"""
)
fun loadLiveFeedsWithUnreadCounts(): Flow<List<FeedUnreadCount>>
@Query("UPDATE feeds SET notify = :notify WHERE id IS :id")
suspend fun setNotify(id: Long, notify: Boolean)
@Query("UPDATE feeds SET notify = :notify WHERE tag IS :tag")
suspend fun setNotify(tag: String, notify: Boolean)
@Query("UPDATE feeds SET notify = :notify")
suspend fun setAllNotify(notify: Boolean)
@Query("SELECT $COL_ID, $COL_TITLE, $COL_CUSTOM_TITLE FROM feeds WHERE id IS :feedId")
suspend fun getFeedTitle(feedId: Long): List<FeedTitle>
@Query(
"""
SELECT $COL_ID, $COL_TITLE, $COL_CUSTOM_TITLE
FROM feeds
WHERE $COL_TAG IS :feedTag
"""
)
suspend fun getFeedTitlesWithTag(feedTag: String): List<FeedTitle>
@Query("SELECT $COL_ID, $COL_TITLE, $COL_CUSTOM_TITLE FROM feeds")
suspend fun getAllFeedTitles(): List<FeedTitle>
}
/**
* Inserts or updates feed depending on if ID is valid. Returns ID.
*/
suspend fun FeedDao.upsertFeed(feed: Feed): Long = when (feed.id > ID_UNSET) {
true -> {
updateFeed(feed)
feed.id
}
false -> {
insertFeed(feed)
}
}

View file

@ -0,0 +1,158 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.CASCADE
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import com.nononsenseapps.feeder.db.COL_AUTHOR
import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK
import com.nononsenseapps.feeder.db.COL_FEEDID
import com.nononsenseapps.feeder.db.COL_FIRSTSYNCEDTIME
import com.nononsenseapps.feeder.db.COL_GUID
import com.nononsenseapps.feeder.db.COL_ID
import com.nononsenseapps.feeder.db.COL_IMAGEURL
import com.nononsenseapps.feeder.db.COL_LINK
import com.nononsenseapps.feeder.db.COL_NOTIFIED
import com.nononsenseapps.feeder.db.COL_PLAINSNIPPET
import com.nononsenseapps.feeder.db.COL_PLAINTITLE
import com.nononsenseapps.feeder.db.COL_PRIMARYSORTTIME
import com.nononsenseapps.feeder.db.COL_PUBDATE
import com.nononsenseapps.feeder.db.COL_TITLE
import com.nononsenseapps.feeder.db.COL_UNREAD
import com.nononsenseapps.feeder.db.FEED_ITEMS_TABLE_NAME
import com.nononsenseapps.feeder.ui.text.HtmlToPlainTextConverter
import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute
import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
import com.nononsenseapps.jsonfeed.Item
import kotlinx.coroutines.FlowPreview
import org.threeten.bp.Instant
import org.threeten.bp.ZoneOffset
import org.threeten.bp.ZonedDateTime
import java.net.URI
import java.net.URL
const val MAX_TITLE_LENGTH = 200
const val MAX_SNIPPET_LENGTH = 200
@FlowPreview
@Entity(
tableName = FEED_ITEMS_TABLE_NAME,
indices = [
Index(value = [COL_GUID, COL_FEEDID], unique = true),
Index(value = [COL_FEEDID])
],
foreignKeys = [
ForeignKey(
entity = Feed::class,
parentColumns = [COL_ID],
childColumns = [COL_FEEDID],
onDelete = CASCADE
)
]
)
data class FeedItem @Ignore constructor(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = COL_ID) override var id: Long = ID_UNSET,
@ColumnInfo(name = COL_GUID) var guid: String = "",
@Deprecated("This is never different from plainTitle", replaceWith = ReplaceWith("plainTitle"))
@ColumnInfo(name = COL_TITLE) var title: String = "",
@ColumnInfo(name = COL_PLAINTITLE) var plainTitle: String = "",
@ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "",
@ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null,
@ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null,
@ColumnInfo(name = COL_AUTHOR) var author: String? = null,
@ColumnInfo(name = COL_PUBDATE, typeAffinity = ColumnInfo.TEXT) var pubDate: ZonedDateTime? = null,
@ColumnInfo(name = COL_LINK) override var link: String? = null,
@ColumnInfo(name = COL_UNREAD) var unread: Boolean = true,
@ColumnInfo(name = COL_NOTIFIED) var notified: Boolean = false,
@ColumnInfo(name = COL_FEEDID) var feedId: Long? = null,
@ColumnInfo(name = COL_FIRSTSYNCEDTIME, typeAffinity = ColumnInfo.INTEGER) var firstSyncedTime: Instant = Instant.EPOCH,
@ColumnInfo(name = COL_PRIMARYSORTTIME, typeAffinity = ColumnInfo.INTEGER) var primarySortTime: Instant = Instant.EPOCH
) : FeedItemForFetching {
constructor() : this(id = ID_UNSET)
fun updateFromParsedEntry(entry: Item, entryGuid: String, feed: com.nononsenseapps.jsonfeed.Feed) {
val converter = HtmlToPlainTextConverter()
// Be careful about nulls.
val text = entry.content_html ?: entry.content_text ?: ""
val summary: String? = (
entry.summary ?: entry.content_text
?: converter.convert(text)
).take(MAX_SNIPPET_LENGTH)
// Make double sure no base64 images are used as thumbnails
val safeImage = when {
entry.image?.startsWith("data") == true -> null
else -> entry.image
}
val absoluteImage = when {
feed.feed_url != null && safeImage != null -> {
relativeLinkIntoAbsolute(sloppyLinkToStrictURL(feed.feed_url!!), safeImage)
}
else -> safeImage
}
this.guid = entryGuid
entry.title?.let { this.plainTitle = it.take(MAX_TITLE_LENGTH) }
@Suppress("DEPRECATION")
this.title = this.plainTitle
summary?.let { this.plainSnippet = it }
this.imageUrl = absoluteImage
this.enclosureLink = entry.attachments?.firstOrNull()?.url
this.author = entry.author?.name ?: feed.author?.name
this.link = entry.url
this.pubDate =
try {
// Allow an actual pubdate to be updated
ZonedDateTime.parse(entry.date_published)
} catch (t: Throwable) {
// If a pubdate is missing, then don't update if one is already set
this.pubDate ?: ZonedDateTime.now(ZoneOffset.UTC)
}
primarySortTime = minOf(firstSyncedTime, pubDate?.toInstant() ?: firstSyncedTime)
}
val pubDateString: String?
get() = pubDate?.toString()
val enclosureFilename: String?
get() {
enclosureLink?.let { enclosureLink ->
var fname: String? = null
try {
fname = URI(enclosureLink).path.split("/").last()
} catch (e: Exception) {
}
return if (fname == null || fname.isEmpty()) {
null
} else {
fname
}
}
return null
}
val domain: String?
get() {
val l: String? = enclosureLink ?: link
if (l != null) {
try {
return URL(l).host.replace("www.", "")
} catch (e: Throwable) {
}
}
return null
}
}
interface FeedItemForFetching {
val id: Long
val link: String?
}

View file

@ -0,0 +1,335 @@
package com.nononsenseapps.feeder.db.room
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.nononsenseapps.feeder.db.COL_URL
import com.nononsenseapps.feeder.db.FEEDS_TABLE_NAME
import com.nononsenseapps.feeder.model.PreviewItem
import com.nononsenseapps.feeder.model.previewColumns
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import java.net.URL
@FlowPreview
@Dao
interface FeedItemDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertFeedItem(item: FeedItem): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertFeedItems(items: List<FeedItem>): List<Long>
@Update
suspend fun updateFeedItem(item: FeedItem): Int
@Update
suspend fun updateFeedItems(items: List<FeedItem>): Int
@Delete
suspend fun deleteFeedItem(item: FeedItem)
@Query(
"""
DELETE FROM feed_items WHERE id IN (:ids)
"""
)
suspend fun deleteFeedItems(ids: List<Long>)
@Query(
"""
SELECT id FROM feed_items
WHERE feed_id IS :feedId
ORDER BY primary_sort_time DESC, pub_date DESC
LIMIT -1 OFFSET :keepCount
"""
)
suspend fun getItemsToBeCleanedFromFeed(feedId: Long, keepCount: Int): List<Long>
@Query("SELECT * FROM feed_items WHERE guid IS :guid AND feed_id IS :feedId")
suspend fun loadFeedItem(guid: String, feedId: Long?): FeedItem?
@Query("SELECT * FROM feed_items WHERE id IS :id")
suspend fun loadFeedItem(id: Long): FeedItem?
@Query(
"""
SELECT $feedItemColumnsWithFeed
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_items.id IS :id
"""
)
suspend fun loadFeedItemWithFeed(id: Long): FeedItemWithFeed?
@Query(
"""
SELECT $FEEDS_TABLE_NAME.$COL_URL
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_items.id IS :id
"""
)
suspend fun loadFeedUrlOfFeedItem(id: Long): URL?
@Query(
"""
SELECT *
FROM feed_items
WHERE feed_items.feed_id = :feedId
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
suspend fun loadFeedItemsInFeedDesc(feedId: Long): List<FeedItem>
@Query(
"""
SELECT *
FROM feed_items
WHERE feed_items.feed_id = :feedId
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
suspend fun loadFeedItemsInFeedAsc(feedId: Long): List<FeedItem>
@Query(
"""
SELECT $feedItemColumnsWithFeed
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_items.id IS :id
"""
)
fun loadLiveFeedItem(id: Long): Flow<FeedItemWithFeed?>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_id IS :feedId
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
fun loadLivePreviewsDesc(feedId: Long): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_id IS :feedId
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
fun loadLivePreviewsAsc(feedId: Long): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE tag IS :tag
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
fun loadLivePreviewsDesc(tag: String): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE tag IS :tag
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
fun loadLivePreviewsAsc(tag: String): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
fun loadLivePreviewsDesc(): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
fun loadLivePreviewsAsc(): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_id IS :feedId AND unread IS :unread
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
fun loadLiveUnreadPreviewsDesc(feedId: Long?, unread: Boolean = true): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_id IS :feedId AND unread IS :unread
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
fun loadLiveUnreadPreviewsAsc(feedId: Long?, unread: Boolean = true): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE tag IS :tag AND unread IS :unread
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
fun loadLiveUnreadPreviewsDesc(tag: String, unread: Boolean = true): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE tag IS :tag AND unread IS :unread
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
fun loadLiveUnreadPreviewsAsc(tag: String, unread: Boolean = true): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE unread IS :unread
ORDER BY primary_sort_time DESC, pub_date DESC
"""
)
fun loadLiveUnreadPreviewsDesc(unread: Boolean = true): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $previewColumns
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE unread IS :unread
ORDER BY primary_sort_time ASC, pub_date ASC
"""
)
fun loadLiveUnreadPreviewsAsc(unread: Boolean = true): DataSource.Factory<Int, PreviewItem>
@Query(
"""
SELECT $feedItemColumnsWithFeed
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE feed_id IN (:feedIds) AND notified IS 0 AND unread IS 1
"""
)
suspend fun loadItemsToNotify(feedIds: List<Long>): List<FeedItemWithFeed>
@Query("UPDATE feed_items SET unread = 0")
suspend fun markAllAsRead()
@Query("UPDATE feed_items SET unread = 0 WHERE feed_id IS :feedId")
suspend fun markAllAsRead(feedId: Long?)
@Query(
"""
UPDATE feed_items
SET unread = 0
WHERE id IN (
SELECT feed_items.id
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE tag IS :tag
)"""
)
suspend fun markAllAsRead(tag: String)
@Query("UPDATE feed_items SET unread = :unread WHERE id IS :id")
suspend fun markAsRead(id: Long, unread: Boolean = false)
@Query("UPDATE feed_items SET unread = :unread WHERE id IN (:ids)")
suspend fun markAsRead(ids: List<Long>, unread: Boolean = false)
@Query("UPDATE feed_items SET notified = :notified WHERE id IN (:ids)")
suspend fun markAsNotified(ids: List<Long>, notified: Boolean = true)
@Query("UPDATE feed_items SET notified = :notified WHERE id IS :id")
suspend fun markAsNotified(id: Long, notified: Boolean = true)
@Query(
"""
UPDATE feed_items
SET notified = :notified
WHERE id IN (
SELECT feed_items.id
FROM feed_items
LEFT JOIN feeds ON feed_items.feed_id = feeds.id
WHERE tag IS :tag
)"""
)
suspend fun markTagAsNotified(tag: String, notified: Boolean = true)
@Query("UPDATE feed_items SET notified = :notified")
suspend fun markAllAsNotified(notified: Boolean = true)
@Query("UPDATE feed_items SET unread = 0, notified = 1 WHERE id IS :id")
suspend fun markAsReadAndNotified(id: Long)
}
@FlowPreview
suspend fun FeedItemDao.upsertFeedItem(item: FeedItem): Long = when (item.id > ID_UNSET) {
true -> {
updateFeedItem(item)
item.id
}
false -> insertFeedItem(item)
}
@FlowPreview
suspend fun FeedItemDao.upsertFeedItems(
itemsWithText: List<Pair<FeedItem, String>>,
block: suspend (FeedItem, String) -> Unit
) {
val updatedItems = itemsWithText.filter { (item, _) ->
item.id > ID_UNSET
}
updateFeedItems(updatedItems.map { (item, _) -> item })
val insertedItems = itemsWithText.filter { (item, _) ->
item.id <= ID_UNSET
}
val insertedIds = insertFeedItems(insertedItems.map { (item, _) -> item })
updatedItems.forEach { (item, text) ->
block(item, text)
}
insertedIds.zip(insertedItems).forEach { (itemId, itemToText) ->
val (item, text) = itemToText
item.id = itemId
block(item, text)
}
}

View file

@ -0,0 +1,124 @@
package com.nononsenseapps.feeder.db.room
import android.os.Bundle
import androidx.room.ColumnInfo
import androidx.room.Ignore
import com.nononsenseapps.feeder.db.COL_AUTHOR
import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK
import com.nononsenseapps.feeder.db.COL_FEEDCUSTOMTITLE
import com.nononsenseapps.feeder.db.COL_FEEDID
import com.nononsenseapps.feeder.db.COL_FEEDTITLE
import com.nononsenseapps.feeder.db.COL_FEEDURL
import com.nononsenseapps.feeder.db.COL_FULLTEXT_BY_DEFAULT
import com.nononsenseapps.feeder.db.COL_GUID
import com.nononsenseapps.feeder.db.COL_ID
import com.nononsenseapps.feeder.db.COL_IMAGEURL
import com.nononsenseapps.feeder.db.COL_LINK
import com.nononsenseapps.feeder.db.COL_PLAINSNIPPET
import com.nononsenseapps.feeder.db.COL_PLAINTITLE
import com.nononsenseapps.feeder.db.COL_PUBDATE
import com.nononsenseapps.feeder.db.COL_TAG
import com.nononsenseapps.feeder.db.COL_TITLE
import com.nononsenseapps.feeder.db.COL_UNREAD
import com.nononsenseapps.feeder.db.COL_URL
import com.nononsenseapps.feeder.db.FEEDS_TABLE_NAME
import com.nononsenseapps.feeder.db.FEED_ITEMS_TABLE_NAME
import com.nononsenseapps.feeder.ui.ARG_AUTHOR
import com.nononsenseapps.feeder.ui.ARG_DATE
import com.nononsenseapps.feeder.ui.ARG_ENCLOSURE
import com.nononsenseapps.feeder.ui.ARG_FEED_TITLE
import com.nononsenseapps.feeder.ui.ARG_FEED_URL
import com.nononsenseapps.feeder.ui.ARG_ID
import com.nononsenseapps.feeder.ui.ARG_IMAGEURL
import com.nononsenseapps.feeder.ui.ARG_LINK
import com.nononsenseapps.feeder.ui.ARG_TITLE
import com.nononsenseapps.feeder.util.setLong
import com.nononsenseapps.feeder.util.setString
import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
import org.threeten.bp.ZonedDateTime
import java.net.URI
import java.net.URL
const val feedItemColumnsWithFeed = "$FEED_ITEMS_TABLE_NAME.$COL_ID AS $COL_ID, $COL_GUID, $FEED_ITEMS_TABLE_NAME.$COL_TITLE AS $COL_TITLE, " +
"$COL_PLAINTITLE, $COL_PLAINSNIPPET, $FEED_ITEMS_TABLE_NAME.$COL_IMAGEURL, $COL_ENCLOSURELINK, " +
"$COL_AUTHOR, $COL_PUBDATE, $COL_LINK, $COL_UNREAD, $FEEDS_TABLE_NAME.$COL_TAG AS $COL_TAG, $FEEDS_TABLE_NAME.$COL_ID AS $COL_FEEDID, " +
"$FEEDS_TABLE_NAME.$COL_TITLE AS $COL_FEEDTITLE, " +
"$FEEDS_TABLE_NAME.$COL_CUSTOM_TITLE AS $COL_FEEDCUSTOMTITLE, " +
"$FEEDS_TABLE_NAME.$COL_URL AS $COL_FEEDURL, " +
"$FEEDS_TABLE_NAME.$COL_FULLTEXT_BY_DEFAULT AS $COL_FULLTEXT_BY_DEFAULT"
data class FeedItemWithFeed @Ignore constructor(
override var id: Long = ID_UNSET,
var guid: String = "",
@Deprecated("This is never different from plainTitle", replaceWith = ReplaceWith("plainTitle"))
var title: String = "",
@ColumnInfo(name = COL_PLAINTITLE) var plainTitle: String = "",
@ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "",
@ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null,
@ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null,
var author: String? = null,
@ColumnInfo(name = COL_PUBDATE) var pubDate: ZonedDateTime? = null,
override var link: String? = null,
var tag: String = "",
var unread: Boolean = true,
@ColumnInfo(name = COL_FEEDID) var feedId: Long? = null,
@ColumnInfo(name = COL_FEEDTITLE) var feedTitle: String = "",
@ColumnInfo(name = COL_FEEDCUSTOMTITLE) var feedCustomTitle: String = "",
@ColumnInfo(name = COL_FEEDURL) var feedUrl: URL = sloppyLinkToStrictURLNoThrows(""),
@ColumnInfo(name = COL_FULLTEXT_BY_DEFAULT) var fullTextByDefault: Boolean = false
) : FeedItemForFetching {
constructor() : this(id = ID_UNSET)
val feedDisplayTitle: String
get() = if (feedCustomTitle.isBlank()) feedTitle else feedCustomTitle
val enclosureFilename: String?
get() {
enclosureLink?.let { enclosureLink ->
var fname: String? = null
try {
fname = URI(enclosureLink).path.split("/").last()
} catch (e: Exception) {
}
return if (fname == null || fname.isEmpty()) {
null
} else {
fname
}
}
return null
}
val pubDateString: String?
get() = pubDate?.toString()
val domain: String?
get() {
val l: String? = enclosureLink ?: link
if (l != null) {
try {
return URL(l).host.replace("www.", "")
} catch (e: Throwable) {
}
}
return null
}
fun storeInBundle(bundle: Bundle): Bundle {
bundle.storeFeedItem()
return bundle
}
private fun Bundle.storeFeedItem() {
setLong(ARG_ID to id)
setString(ARG_TITLE to plainTitle)
setString(ARG_LINK to link)
setString(ARG_ENCLOSURE to enclosureLink)
setString(ARG_IMAGEURL to imageUrl)
setString(ARG_FEED_TITLE to feedDisplayTitle)
setString(ARG_AUTHOR to author)
setString(ARG_DATE to pubDateString)
setString(ARG_FEED_URL to feedUrl.toString())
}
}

View file

@ -0,0 +1,18 @@
package com.nononsenseapps.feeder.db.room
import androidx.room.ColumnInfo
import androidx.room.Ignore
import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
import com.nononsenseapps.feeder.db.COL_ID
import com.nononsenseapps.feeder.db.COL_TITLE
data class FeedTitle @Ignore constructor(
@ColumnInfo(name = COL_ID) var id: Long = ID_UNSET,
@ColumnInfo(name = COL_TITLE) var title: String = "",
@ColumnInfo(name = COL_CUSTOM_TITLE) var customTitle: String = ""
) {
constructor() : this(id = ID_UNSET)
val displayTitle: String
get() = (if (customTitle.isBlank()) title else customTitle)
}

View file

@ -0,0 +1,24 @@
package com.nononsenseapps.feeder.di
import com.nononsenseapps.feeder.model.FeedParser
import com.nononsenseapps.feeder.ui.CustomTabsWarmer
import com.nononsenseapps.jsonfeed.Feed
import com.nononsenseapps.jsonfeed.JsonFeedParser
import com.nononsenseapps.jsonfeed.feedAdapter
import com.squareup.moshi.JsonAdapter
import kotlinx.coroutines.FlowPreview
import okhttp3.OkHttpClient
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
@FlowPreview
val networkModule = Kodein.Module(name = "network") {
// Parsers can carry state so safer to use providers
bind<JsonAdapter<Feed>>() with provider { feedAdapter() }
bind<JsonFeedParser>() with provider { JsonFeedParser(instance<OkHttpClient>(), instance()) }
bind<FeedParser>() with provider { FeedParser(kodein) }
bind<CustomTabsWarmer>() with singleton { CustomTabsWarmer(kodein) }
}

View file

@ -0,0 +1,16 @@
package com.nononsenseapps.feeder.di
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.singleton
@ExperimentalCoroutinesApi
val stateModule = Kodein.Module(name = "state objects") {
bind<ConflatedBroadcastChannel<Boolean>>(tag = CURRENTLY_SYNCING_STATE) with singleton {
ConflatedBroadcastChannel(value = false)
}
}
const val CURRENTLY_SYNCING_STATE = "CurrentlySyncingState"

View file

@ -0,0 +1,31 @@
package com.nononsenseapps.feeder.di
import com.nononsenseapps.feeder.base.KodeinAwareViewModelFactory
import com.nononsenseapps.feeder.base.activityViewModelProvider
import com.nononsenseapps.feeder.base.bindWithKodeinAwareViewModelFactory
import com.nononsenseapps.feeder.model.EphemeralState
import com.nononsenseapps.feeder.model.FeedItemViewModel
import com.nononsenseapps.feeder.model.FeedItemsViewModel
import com.nononsenseapps.feeder.model.FeedListViewModel
import com.nononsenseapps.feeder.model.FeedViewModel
import com.nononsenseapps.feeder.model.SettingsViewModel
import com.nononsenseapps.feeder.model.TextToSpeechViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.singleton
@FlowPreview
@ExperimentalCoroutinesApi
val viewModelModule = Kodein.Module(name = "view models") {
bind<KodeinAwareViewModelFactory>() with singleton { KodeinAwareViewModelFactory(kodein) }
bindWithKodeinAwareViewModelFactory<FeedItemsViewModel>()
bindWithKodeinAwareViewModelFactory<FeedListViewModel>()
bindWithKodeinAwareViewModelFactory<SettingsViewModel>()
bindWithKodeinAwareViewModelFactory<FeedItemViewModel>()
bindWithKodeinAwareViewModelFactory<FeedViewModel>()
bindWithKodeinAwareViewModelFactory<TextToSpeechViewModel>()
bind<EphemeralState>() with activityViewModelProvider()
}

View file

@ -0,0 +1,26 @@
package com.nononsenseapps.feeder.model
import com.nononsenseapps.feeder.base.KodeinAwareViewModel
import com.nononsenseapps.feeder.db.room.ID_UNSET
import org.kodein.di.Kodein
/**
* Should only be created with the activity as its lifecycle
*/
class EphemeralState(kodein: Kodein) : KodeinAwareViewModel(kodein) {
var lastOpenFeedId: Long = ID_UNSET
set(value) {
if (value != lastOpenFeedId) {
firstVisibleListItem = null
}
field = value
}
var lastOpenFeedTag: String = ""
set(value) {
if (value != lastOpenFeedTag) {
firstVisibleListItem = null
}
field = value
}
var firstVisibleListItem: Int? = null
}

View file

@ -0,0 +1,258 @@
package com.nononsenseapps.feeder.model
import android.app.Activity
import android.app.Application
import android.graphics.Point
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ImageSpan
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.LiveDataScope
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import com.nononsenseapps.feeder.R
import com.nononsenseapps.feeder.base.KodeinAwareViewModel
import com.nononsenseapps.feeder.blob.blobFullFile
import com.nononsenseapps.feeder.blob.blobFullInputStream
import com.nononsenseapps.feeder.blob.blobInputStream
import com.nononsenseapps.feeder.db.room.FeedItemDao
import com.nononsenseapps.feeder.db.room.FeedItemWithFeed
import com.nononsenseapps.feeder.ui.text.UrlClickListener
import com.nononsenseapps.feeder.ui.text.toSpannedWithImages
import com.nononsenseapps.feeder.ui.text.toSpannedWithNoImages
import com.nononsenseapps.feeder.util.TabletUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.kodein.di.Kodein
import org.kodein.di.generic.instance
import java.io.InputStream
import java.net.URL
import kotlin.math.roundToInt
@FlowPreview
class FeedItemViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
private val dao: FeedItemDao by instance()
val context: Application by instance()
private val okHttpClient: OkHttpClient by instance()
private lateinit var liveItem: LiveData<FeedItemWithFeed?>
private lateinit var feedItem: FeedItemWithFeed
private lateinit var liveDefaultText: LiveData<Spanned>
private var currentDefaultTextOptions: TextOptions? = null
private lateinit var liveFullText: LiveData<Spanned>
private var currentFullTextOptions: TextOptions? = null
private var fragmentUrlClickListener: UrlClickListener? = null
fun getLiveItem(id: Long): LiveData<FeedItemWithFeed?> {
if (!this::liveItem.isInitialized) {
liveItem = dao.loadLiveFeedItem(id).asLiveData()
}
return liveItem
}
suspend fun getItem(id: Long): FeedItemWithFeed {
if (!this::feedItem.isInitialized || feedItem.id != id) {
feedItem = dao.loadFeedItemWithFeed(id) ?: error("no such item $id")
}
return feedItem
}
suspend fun getLiveTextMaybeFull(
options: TextOptions,
urlClickListener: UrlClickListener?
): LiveData<Spanned> =
when (getItem(options.itemId).fullTextByDefault) {
true -> getLiveFullText(options, urlClickListener)
false -> getLiveDefaultText(options, urlClickListener)
}
suspend fun getLiveDefaultText(
options: TextOptions,
urlClickListener: UrlClickListener?
): LiveData<Spanned> {
// Always update urlClickListener
fragmentUrlClickListener = urlClickListener
if (this::liveDefaultText.isInitialized && currentDefaultTextOptions == options) {
Log.d("FeederItemViewModel", "Requested default text for old options: $options")
return liveDefaultText
}
Log.d("FeederItemViewModel", "Requested default text for new options: $options")
liveDefaultText = liveData(context = viewModelScope.coroutineContext) {
loadTextFrom(options) {
blobInputStream(
itemId = options.itemId,
filesDir = context.filesDir
)
}
}
currentDefaultTextOptions = options
return liveDefaultText
}
fun getLiveFullText(
options: TextOptions,
urlClickListener: UrlClickListener?
): LiveData<Spanned> {
// Always update urlClickListener
fragmentUrlClickListener = urlClickListener
if (this::liveFullText.isInitialized && currentFullTextOptions == options) {
Log.d("FeederItemViewModel", "Requested full text for old options: $options")
return liveFullText
}
Log.d("FeederItemViewModel", "Requested full text for new options: $options")
liveFullText = liveData(context = viewModelScope.coroutineContext) {
val fullTextPresent = fetchFullArticleIfMissing(
itemId = options.itemId
)
if (fullTextPresent) {
loadTextFrom(options) {
blobFullInputStream(
itemId = options.itemId,
filesDir = context.filesDir
)
}
}
}
currentFullTextOptions = options
return liveFullText
}
suspend fun markAsRead(id: Long, unread: Boolean = false) = dao.markAsRead(id = id, unread = unread)
suspend fun markAsReadAndNotified(id: Long) = dao.markAsReadAndNotified(id = id)
private suspend fun LiveDataScope<Spanned>.loadTextFrom(
options: TextOptions,
streamProvider: () -> InputStream
) {
val feedUrl = dao.loadFeedUrlOfFeedItem(id = options.itemId)
?: URL("https://missing.feedurl")
Log.d("FeederItemViewModel", "Loading noImages for $options")
val hasImageSpans = try {
val noImages = withContext(Dispatchers.IO) {
streamProvider().bufferedReader().use { reader ->
toSpannedWithNoImages(
kodein = kodein,
source = reader,
siteUrl = feedUrl,
maxSize = options.maxImageSize,
urlClickListener = { link ->
fragmentUrlClickListener?.invoke(link)
}
)
}
}
emit(noImages)
noImages.getAllImageSpans().isNotEmpty()
} catch (e: Exception) {
Log.e(
"FeederItemViewModel",
"Failed to load text with no images for $options",
e
)
emit(
SpannableString("Could not read blob for item with id [${options.itemId}]")
)
false
}
try {
if (hasImageSpans) {
Log.d("FeederItemViewModel", "Loading withImages for $options")
val withImages = withContext(Dispatchers.IO) {
streamProvider().bufferedReader().use { reader ->
toSpannedWithImages(
kodein = kodein,
source = reader,
siteUrl = feedUrl,
maxSize = options.maxImageSize,
urlClickListener = { link ->
fragmentUrlClickListener?.invoke(link)
}
)
}
}
emit(
withImages
)
}
} catch (e: Exception) {
Log.e(
"FeederItemViewModel",
"Failed to load text with images for $options",
e
)
}
}
private suspend fun LiveDataScope<Spanned>.fetchFullArticleIfMissing(itemId: Long): Boolean {
return if (blobFullFile(itemId, context.filesDir).isFile) {
true
} else {
Log.d("FeederItemViewModel", "Fetching full text for $itemId")
emit(SpannableString(context.getString(R.string.fetching_full_article)))
val item = dao.loadFeedItem(itemId)
if (item == null) {
Log.e("FeederItemViewModel", "No such item: $itemId")
emit(SpannableString(context.getString(R.string.failed_to_fetch_full_article)))
return false
}
val (result, throwable) = parseFullArticle(
item,
okHttpClient,
context.filesDir
)
if (!result) {
var reason = context.getString(R.string.failed_to_fetch_full_article)
if (throwable != null) {
reason += "\n${throwable.message}"
}
emit(SpannableString(reason))
}
return result
}
}
}
internal fun Activity.maxImageSize(): Point {
val size = Point()
windowManager?.defaultDisplay?.getSize(size)
if (TabletUtils.isTablet(this)) {
// Using twice window height since we do scroll vertically
size.set(resources.getDimension(R.dimen.reader_tablet_width).roundToInt(), 2 * size.y)
} else {
// Base it on window size
size.set(size.x - 2 * resources.getDimension(R.dimen.keyline_1).roundToInt(), 2 * size.y)
}
return size
}
private fun Spanned.getAllImageSpans(): Array<out ImageSpan> =
getSpans(0, length, ImageSpan::class.java) ?: emptyArray()
data class TextOptions(
val itemId: Long,
val maxImageSize: Point,
val nightMode: Boolean
)

View file

@ -0,0 +1,136 @@
package com.nononsenseapps.feeder.model
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.DataSource
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.nononsenseapps.feeder.base.KodeinAwareViewModel
import com.nononsenseapps.feeder.db.room.FeedItem
import com.nononsenseapps.feeder.db.room.FeedItemDao
import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
import com.nononsenseapps.feeder.db.room.ID_UNSET
import kotlinx.coroutines.FlowPreview
import org.kodein.di.Kodein
import org.kodein.di.generic.instance
private val PAGE_SIZE = 50
@FlowPreview
class FeedItemsViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
private val dao: FeedItemDao by instance()
private val liveOnlyUnread = MutableLiveData<Boolean>()
private val liveNewestFirst = MutableLiveData<Boolean>()
init {
liveOnlyUnread.value = true
liveNewestFirst.value = true
}
private lateinit var livePagedAll: LiveData<PagedList<PreviewItem>>
private lateinit var livePagedUnread: LiveData<PagedList<PreviewItem>>
private lateinit var livePreviews: LiveData<PagedList<PreviewItem>>
fun getLiveDbPreviews(feedId: Long, tag: String): LiveData<PagedList<PreviewItem>> {
if (!this::livePreviews.isInitialized) {
livePagedAll = Transformations.switchMap(liveNewestFirst) { newestFirst ->
LivePagedListBuilder(
when {
feedId > ID_UNSET -> loadLivePreviews(feedId = feedId, newestFirst = newestFirst)
feedId == ID_ALL_FEEDS -> loadLivePreviews(newestFirst = newestFirst)
tag.isNotEmpty() -> loadLivePreviews(tag = tag, newestFirst = newestFirst)
else -> throw IllegalArgumentException("Tag was empty, but no valid feed id was provided either")
},
PAGE_SIZE
).build()
}
livePagedUnread = Transformations.switchMap(liveNewestFirst) { newestFirst ->
LivePagedListBuilder(
when {
feedId > ID_UNSET -> loadLiveUnreadPreviews(feedId = feedId, newestFirst = newestFirst)
feedId == ID_ALL_FEEDS -> loadLiveUnreadPreviews(newestFirst = newestFirst)
tag.isNotEmpty() -> loadLiveUnreadPreviews(tag = tag, newestFirst = newestFirst)
else -> throw IllegalArgumentException("Tag was empty, but no valid feed id was provided either")
},
PAGE_SIZE
).build()
}
livePreviews = Transformations.switchMap(liveOnlyUnread) { onlyUnread ->
if (onlyUnread) {
livePagedUnread
} else {
livePagedAll
}
}
}
return livePreviews
}
fun setOnlyUnread(onlyUnread: Boolean) {
liveOnlyUnread.value = onlyUnread
}
fun setNewestFirst(newestFirst: Boolean) {
liveNewestFirst.value = newestFirst
}
suspend fun markAllAsRead(feedId: Long, tag: String) {
when {
feedId > ID_UNSET -> dao.markAllAsRead(feedId)
feedId == ID_ALL_FEEDS -> dao.markAllAsRead()
tag.isNotEmpty() -> dao.markAllAsRead(tag)
}
}
suspend fun toggleReadState(feedItem: PreviewItem) {
dao.markAsRead(feedItem.id, unread = !feedItem.unread)
cancelNotification(getApplication(), feedItem.id)
}
suspend fun markAsNotified(feedId: Long, tag: String) = when {
feedId > ID_UNSET -> dao.markAsNotified(feedId)
feedId == ID_ALL_FEEDS -> dao.markAllAsNotified()
tag.isNotEmpty() -> dao.markTagAsNotified(tag)
else -> error("Invalid input for markAsNotified")
}
suspend fun markAsNotified(ids: List<Long>, notified: Boolean = true) =
dao.markAsNotified(ids = ids, notified = notified)
suspend fun markAsRead(ids: List<Long>, unread: Boolean = false) =
dao.markAsRead(ids = ids, unread = unread)
suspend fun markAsRead(id: Long, unread: Boolean = false) =
dao.markAsRead(id = id, unread = unread)
suspend fun loadFeedItemsInFeed(feedId: Long, newestFirst: Boolean): List<FeedItem> {
return if (newestFirst) dao.loadFeedItemsInFeedDesc(feedId) else dao.loadFeedItemsInFeedAsc(feedId)
}
fun loadLivePreviews(feedId: Long, newestFirst: Boolean): DataSource.Factory<Int, PreviewItem> {
return if (newestFirst) dao.loadLivePreviewsDesc(feedId) else dao.loadLivePreviewsAsc(feedId)
}
fun loadLivePreviews(tag: String, newestFirst: Boolean): DataSource.Factory<Int, PreviewItem> {
return if (newestFirst) dao.loadLivePreviewsDesc(tag) else dao.loadLivePreviewsAsc(tag)
}
fun loadLivePreviews(newestFirst: Boolean): DataSource.Factory<Int, PreviewItem> {
return if (newestFirst) dao.loadLivePreviewsDesc() else dao.loadLivePreviewsAsc()
}
fun loadLiveUnreadPreviews(feedId: Long?, unread: Boolean = true, newestFirst: Boolean): DataSource.Factory<Int, PreviewItem> {
return if (newestFirst) dao.loadLiveUnreadPreviewsDesc(feedId, unread) else dao.loadLiveUnreadPreviewsAsc(feedId, unread)
}
fun loadLiveUnreadPreviews(tag: String, unread: Boolean = true, newestFirst: Boolean): DataSource.Factory<Int, PreviewItem> {
return if (newestFirst) dao.loadLiveUnreadPreviewsDesc(tag, unread) else dao.loadLiveUnreadPreviewsAsc(tag, unread)
}
fun loadLiveUnreadPreviews(unread: Boolean = true, newestFirst: Boolean): DataSource.Factory<Int, PreviewItem> {
return if (newestFirst) dao.loadLiveUnreadPreviewsDesc(unread) else dao.loadLiveUnreadPreviewsAsc(unread)
}
}

View file

@ -0,0 +1,53 @@
package com.nononsenseapps.feeder.model
import androidx.collection.ArrayMap
import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import com.nononsenseapps.feeder.base.KodeinAwareViewModel
import com.nononsenseapps.feeder.db.room.FeedDao
import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectLatest
import org.kodein.di.Kodein
import org.kodein.di.generic.instance
import kotlin.collections.set
@ExperimentalCoroutinesApi
class FeedListViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
private val dao: FeedDao by instance()
private val feedsWithUnreadCounts = dao.loadLiveFeedsWithUnreadCounts()
val liveFeedsAndTagsWithUnreadCounts: LiveData<List<FeedUnreadCount>> by lazy {
liveData<List<FeedUnreadCount>>(viewModelScope.coroutineContext + Dispatchers.Default, 5000L) {
feedsWithUnreadCounts.collectLatest { feeds ->
val topTag = FeedUnreadCount(id = ID_ALL_FEEDS)
val tags: MutableMap<String, FeedUnreadCount> = ArrayMap()
val data: MutableList<FeedUnreadCount> = mutableListOf(topTag)
feeds.forEach { feed ->
if (feed.tag.isNotEmpty()) {
if (!tags.contains(feed.tag)) {
val tag = FeedUnreadCount(tag = feed.tag)
data.add(tag)
tags[feed.tag] = tag
}
tags[feed.tag]?.let { tag ->
tag.unreadCount += feed.unreadCount
}
}
topTag.unreadCount += feed.unreadCount
data.add(feed)
}
data.sortWith(Comparator { a, b -> a.compareTo(b) })
emit(data)
}
}
}
}

View file

@ -0,0 +1,378 @@
package com.nononsenseapps.feeder.model
import android.util.Log
import com.nononsenseapps.feeder.util.asFeed
import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute
import com.nononsenseapps.feeder.util.relativeLinkIntoAbsoluteOrThrow
import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
import com.nononsenseapps.jsonfeed.Feed
import com.nononsenseapps.jsonfeed.JsonFeedParser
import com.rometools.rome.io.SyndFeedInput
import com.rometools.rome.io.XmlReader
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.withContext
import okhttp3.Authenticator
import okhttp3.CacheControl
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import okio.Buffer
import okio.GzipSource
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.generic.instance
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
import java.net.URL
import java.net.URLDecoder
import java.nio.charset.Charset
import java.util.concurrent.TimeUnit
val slashPattern = """<\s*slash:comments\s*/>""".toRegex(RegexOption.IGNORE_CASE)
private const val YOUTUBE_CHANNEL_ID_ATTR = "data-channel-external-id"
@FlowPreview
class FeedParser(override val kodein: Kodein) : KodeinAware {
private val client: OkHttpClient by instance()
private val jsonFeedParser: JsonFeedParser by instance()
/**
* Finds the preferred alternate link in the header of an HTML/XML document pointing to feeds.
*/
fun findFeedUrl(
html: String,
preferRss: Boolean = false,
preferAtom: Boolean = false,
preferJSON: Boolean = false
): URL? {
val feedLinks = getAlternateFeedLinksInHtml(html)
.sortedBy {
val t = it.second.toLowerCase()
when {
preferAtom && t.contains("atom") -> "0"
preferRss && t.contains("rss") -> "1"
preferJSON && t.contains("json") -> "2"
else -> t
}
}
.map {
sloppyLinkToStrictURL(it.first) to it.second
}
return feedLinks.firstOrNull()?.first
}
/**
* Returns all alternate links in the header of an HTML/XML document pointing to feeds.
*/
suspend fun getAlternateFeedLinksAtUrl(url: URL): List<Pair<String, String>> {
return try {
val html = curl(url)
when {
html != null -> getAlternateFeedLinksInHtml(html, baseUrl = url)
else -> emptyList()
}
} catch (t: Throwable) {
Log.e("FeedParser", "Error when fetching alternate links", t)
emptyList()
}
}
/**
* Returns all alternate links in the HTML/XML document pointing to feeds.
*/
fun getAlternateFeedLinksInHtml(html: String, baseUrl: URL? = null): List<Pair<String, String>> {
val doc = Jsoup.parse(html.byteInputStream(), "UTF-8", "")
val feeds = doc.getElementsByAttributeValue("rel", "alternate")
?.filter { it.hasAttr("href") && it.hasAttr("type") }
?.filter {
val t = it.attr("type").toLowerCase()
when {
t.contains("application/atom") -> true
t.contains("application/rss") -> true
// Youtube for example has alternate links with application/json+oembed type.
t == "application/json" -> true
else -> false
}
}
?.filter {
val l = it.attr("href").toLowerCase()
try {
if (baseUrl != null) {
relativeLinkIntoAbsoluteOrThrow(base = baseUrl, link = l)
} else {
URL(l)
}
true
} catch (_: MalformedURLException) {
false
}
}
?.map {
when {
baseUrl != null -> relativeLinkIntoAbsolute(base = baseUrl, link = it.attr("href")) to it.attr("type")
else -> sloppyLinkToStrictURL(it.attr("href")).toString() to it.attr("type")
}
} ?: emptyList()
return when {
feeds.isNotEmpty() -> feeds
baseUrl?.host == "www.youtube.com" || baseUrl?.host == "youtube.com" -> findFeedLinksForYoutube(doc)
else -> emptyList()
}
}
private fun findFeedLinksForYoutube(doc: Document): List<Pair<String, String>> {
val channelId: String? = doc.body()?.getElementsByAttribute(YOUTUBE_CHANNEL_ID_ATTR)
?.firstOrNull()
?.attr(YOUTUBE_CHANNEL_ID_ATTR)
return when (channelId) {
null -> emptyList()
else -> listOf("https://www.youtube.com/feeds/videos.xml?channel_id=$channelId" to "atom")
}
}
/**
* @throws IOException if request fails due to network issue for example
*/
private suspend fun curl(url: URL) = client.curl(url)
/**
* @throws IOException if request fails due to network issue for example
*/
private suspend fun curlAndOnResponse(url: URL, block: (suspend (Response) -> Unit)) =
client.curlAndOnResponse(url, block)
@Throws(FeedParsingError::class)
suspend fun parseFeedUrl(url: URL): Feed? {
try {
var result: Feed? = null
curlAndOnResponse(url) {
result = parseFeedResponse(it)
}
// Preserve original URL to maintain authentication data and/or tokens in query params
return result?.copy(feed_url = url.toString())
} catch (e: Throwable) {
throw FeedParsingError(url, e)
}
}
@Throws(FeedParsingError::class)
fun parseFeedResponse(response: Response): Feed? =
response.safeBody()?.let { body ->
parseFeedResponse(response, body)
}
/**
* Takes body as bytes to handle encoding correctly
*/
@Throws(FeedParsingError::class)
fun parseFeedResponse(response: Response, body: ByteArray): Feed {
try {
val feed = when ((response.header("content-type") ?: "").contains("json")) {
true -> jsonFeedParser.parseJsonBytes(body)
false -> parseRssAtomBytes(response.request.url.toUrl(), body)
}
return if (feed.feed_url == null) {
// Nice to return non-null value here
feed.copy(feed_url = response.request.url.toString())
} else {
feed
}
} catch (e: Throwable) {
throw FeedParsingError(response.request.url.toUrl(), e)
}
}
/**
* Takes body as bytes to handle encoding correctly
*/
@Throws(FeedParsingError::class)
suspend fun parseFeedResponseOrFallbackToAlternateLink(response: Response): Feed? =
response.body?.use { responseBody ->
responseBody.bytes().let { body ->
// Encoding is not an issue for reading HTML (probably)
val alternateFeedLink = findFeedUrl(String(body), preferAtom = true)
return if (alternateFeedLink != null) {
parseFeedUrl(alternateFeedLink)
} else {
parseFeedResponse(response, body)
}
}
}
@Throws(FeedParsingError::class)
internal fun parseRssAtomBytes(baseUrl: URL, feedXml: ByteArray): Feed {
try {
feedXml.inputStream().use { return parseFeedInputStream(baseUrl, it) }
} catch (e: NumberFormatException) {
try {
// Try to work around bug in Rome
var encoding: String? = null
val xml: String = slashPattern.replace(
feedXml.inputStream().use {
XmlReader(it).use {
encoding = it.encoding
it.readText()
}
},
""
)
xml.byteInputStream(Charset.forName(encoding ?: "UTF-8")).use {
return parseFeedInputStream(baseUrl, it)
}
} catch (e: Throwable) {
throw FeedParsingError(baseUrl, e)
}
}
}
@Throws(FeedParsingError::class)
internal fun parseFeedInputStream(baseUrl: URL, `is`: InputStream): Feed {
`is`.use {
try {
val feed = XmlReader(`is`).use { SyndFeedInput().build(it) }
return feed.asFeed(baseUrl = baseUrl)
} catch (e: NumberFormatException) {
throw e
} catch (e: Throwable) {
throw FeedParsingError(baseUrl, e)
}
}
}
class FeedParsingError(val url: URL, e: Throwable) : Exception(e.message, e)
}
fun Response.safeBody(): ByteArray? {
return this.body?.use { body ->
if (header("Transfer-Encoding") == "chunked") {
val source =
if (header("Content-Encoding") == "gzip") {
GzipSource(body.source())
} else {
body.source()
}
val buffer = Buffer()
try {
var readBytes: Long = 0
while (readBytes != -1L) {
readBytes = source.read(buffer, Long.MAX_VALUE)
}
} catch (e: EOFException) {
// This is not always fatal - sometimes the server might have sent the wrong
// content-length (I suspect)
Log.e(
"FeedParser",
"Encountered EOF exception while parsing response with headers: $headers",
e
)
}
buffer.readByteArray()
} else {
body.bytes()
}
}
}
suspend fun OkHttpClient.getResponse(url: URL, forceNetwork: Boolean = false): Response {
val request = Request.Builder()
.url(url)
.cacheControl(
CacheControl.Builder()
.let {
if (forceNetwork) {
// Force a cache revalidation
it.maxAge(0, TimeUnit.SECONDS)
} else {
// Do a cache revalidation at most every minute
it.maxAge(1, TimeUnit.MINUTES)
}
}
.build()
)
.build()
val clientToUse = if (url.userInfo?.isNotBlank() == true) {
val parts = url.userInfo.split(':')
val user = parts.first()
val pass = if (parts.size > 1) {
parts[1]
} else {
""
}
val decodedUser = URLDecoder.decode(user, "UTF-8")
val decodedPass = URLDecoder.decode(pass, "UTF-8")
val credentials = Credentials.basic(decodedUser, decodedPass)
newBuilder()
.authenticator(object: Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
return when {
response.request.header("Authorization") != null -> {
null
}
else -> {
response.request.newBuilder()
.header("Authorization", credentials)
.build()
}
}
}
})
.proxyAuthenticator(object: Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
return when {
response.request.header("Proxy-Authorization") != null -> {
null
}
else -> {
response.request.newBuilder()
.header("Proxy-Authorization", credentials)
.build()
}
}
}
})
.build()
} else {
this
}
return withContext(IO) {
clientToUse.newCall(request).execute()
}
}
suspend fun OkHttpClient.curl(url: URL): String? {
var result: String? = null
curlAndOnResponse(url) {
result = it.body?.string()
}
return result
}
suspend fun OkHttpClient.curlAndOnResponse(url: URL, block: (suspend (Response) -> Unit)) {
val response = getResponse(url)
if (!response.isSuccessful) {
throw IOException("Unexpected code $response")
}
response.use {
block(it)
}
}

View file

@ -0,0 +1,168 @@
package com.nononsenseapps.feeder.model
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.nononsenseapps.feeder.db.room.ID_UNSET
import com.nononsenseapps.feeder.di.CURRENTLY_SYNCING_STATE
import com.nononsenseapps.feeder.ui.ARG_FEED_ID
import com.nononsenseapps.feeder.ui.ARG_FEED_TAG
import com.nononsenseapps.feeder.util.Prefs
import com.nononsenseapps.feeder.util.currentlyCharging
import com.nononsenseapps.feeder.util.currentlyConnected
import com.nononsenseapps.feeder.util.currentlyUnmetered
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.closestKodein
import org.kodein.di.generic.instance
import java.util.concurrent.TimeUnit
const val ARG_FORCE_NETWORK = "force_network"
const val UNIQUE_PERIODIC_NAME = "feeder_periodic"
const val PARALLEL_SYNC = "parallel_sync"
const val MIN_FEED_AGE_MINUTES = "min_feed_age_minutes"
const val IGNORE_CONNECTIVITY_SETTINGS = "ignore_connectivity_settings"
fun isOkToSyncAutomatically(context: Context): Boolean {
val kodein: Kodein by closestKodein(context)
val prefs: Prefs by kodein.instance()
return (
currentlyConnected(context) &&
(!prefs.onlySyncWhileCharging || currentlyCharging(context)) &&
(!prefs.onlySyncOnWIfi || currentlyUnmetered(context))
)
}
@FlowPreview
@ExperimentalCoroutinesApi
class FeedSyncer(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams), KodeinAware {
override val kodein: Kodein by closestKodein(context)
private val currentlySyncing: ConflatedBroadcastChannel<Boolean> by instance(tag = CURRENTLY_SYNCING_STATE)
override suspend fun doWork(): Result {
val goParallel = inputData.getBoolean(PARALLEL_SYNC, false)
val ignoreConnectivitySettings = inputData.getBoolean(IGNORE_CONNECTIVITY_SETTINGS, false)
var success = false
if (ignoreConnectivitySettings || isOkToSyncAutomatically(context)) {
if (!currentlySyncing.isClosedForSend) {
currentlySyncing.offer(true)
}
val feedId = inputData.getLong(ARG_FEED_ID, ID_UNSET)
val feedTag = inputData.getString(ARG_FEED_TAG) ?: ""
val forceNetwork = inputData.getBoolean(ARG_FORCE_NETWORK, false)
val minFeedAgeMinutes = inputData.getInt(MIN_FEED_AGE_MINUTES, 15)
success = syncFeeds(
context = applicationContext,
feedId = feedId,
feedTag = feedTag,
forceNetwork = forceNetwork,
parallel = goParallel,
minFeedAgeMinutes = minFeedAgeMinutes
)
// Send notifications for configured feeds
notify(applicationContext)
}
if (!currentlySyncing.isClosedForSend) {
currentlySyncing.offer(false)
}
return when (success) {
true -> Result.success()
false -> Result.failure()
}
}
}
@FlowPreview
@ExperimentalCoroutinesApi
fun requestFeedSync(
kodein: Kodein,
feedId: Long = ID_UNSET,
feedTag: String = "",
ignoreConnectivitySettings: Boolean = false,
forceNetwork: Boolean = false,
parallell: Boolean = false
) {
val workRequest = OneTimeWorkRequestBuilder<FeedSyncer>()
val data = workDataOf(
ARG_FEED_ID to feedId,
ARG_FEED_TAG to feedTag,
PARALLEL_SYNC to parallell,
IGNORE_CONNECTIVITY_SETTINGS to ignoreConnectivitySettings,
ARG_FORCE_NETWORK to forceNetwork
)
workRequest.setInputData(data)
val workManager by kodein.instance<WorkManager>()
workManager.enqueue(workRequest.build())
}
@FlowPreview
@ExperimentalCoroutinesApi
fun configurePeriodicSync(context: Context, forceReplace: Boolean = false) {
val kodein by closestKodein(context)
val workManager: WorkManager by kodein.instance()
val prefs: Prefs by kodein.instance()
val shouldSync = prefs.shouldSync()
if (shouldSync) {
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiresCharging(prefs.onlySyncWhileCharging)
if (prefs.onlySyncOnWIfi) {
constraints.setRequiredNetworkType(NetworkType.UNMETERED)
} else {
constraints.setRequiredNetworkType(NetworkType.CONNECTED)
}
var timeInterval = prefs.synchronizationFrequency
if (timeInterval in 1..12 || timeInterval == 24L) {
// Old value for periodic sync was in hours, convert it to minutes
timeInterval *= 60
prefs.synchronizationFrequency = timeInterval
}
val workRequestBuilder = PeriodicWorkRequestBuilder<FeedSyncer>(
timeInterval, TimeUnit.MINUTES,
timeInterval / 2, TimeUnit.MINUTES
)
val syncWork = workRequestBuilder
.setConstraints(constraints.build())
.addTag("periodic_sync")
.build()
val existingWorkPolicy = if (forceReplace) {
ExistingPeriodicWorkPolicy.REPLACE
} else {
ExistingPeriodicWorkPolicy.KEEP
}
workManager.enqueueUniquePeriodicWork(
UNIQUE_PERIODIC_NAME,
existingWorkPolicy,
syncWork
)
} else {
workManager.cancelUniqueWork(UNIQUE_PERIODIC_NAME)
}
}

View file

@ -0,0 +1,74 @@
package com.nononsenseapps.feeder.model
import androidx.room.ColumnInfo
import androidx.room.Ignore
import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
import com.nononsenseapps.feeder.db.room.ID_UNSET
import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
import java.net.URL
data class FeedUnreadCount @Ignore constructor(
var id: Long = ID_UNSET,
var title: String = "",
var url: URL = sloppyLinkToStrictURLNoThrows(""),
var tag: String = "",
@ColumnInfo(name = "custom_title") var customTitle: String = "",
var notify: Boolean = false,
@ColumnInfo(name = "image_url") var imageUrl: URL? = null,
@ColumnInfo(name = "unread_count") var unreadCount: Int = 0
) {
constructor() : this(id = ID_UNSET)
val displayTitle: String
get() = (if (customTitle.isBlank()) title else customTitle)
val isTop: Boolean
get() = id == ID_ALL_FEEDS
val isTag: Boolean
get() = id < 1 && tag.isNotEmpty()
operator fun compareTo(other: FeedUnreadCount): Int {
return when {
// Top tag is always at the top (implies empty tags)
isTop -> -1
other.isTop -> 1
// Feeds with no tags are always last
isTag && !other.isTag && other.tag.isEmpty() -> -1
!isTag && other.isTag && tag.isEmpty() -> 1
!isTag && !other.isTag && tag.isNotEmpty() && other.tag.isEmpty() -> -1
!isTag && !other.isTag && tag.isEmpty() && other.tag.isNotEmpty() -> 1
// Feeds with identical tags compare by title
tag == other.tag -> displayTitle.compareTo(other.displayTitle, ignoreCase = true)
// In other cases it's just a matter of comparing tags
else -> tag.compareTo(other.tag, ignoreCase = true)
}
}
override fun equals(other: Any?): Boolean {
return when (other) {
null -> false
is FeedUnreadCount -> {
// val f = other as FeedWrapper?
if (isTag && other.isTag) {
// Compare tags
tag == other.tag
} else {
// Compare items
!isTag && !other.isTag && id == other.id
}
}
else -> false
}
}
override fun hashCode(): Int {
return if (isTag) {
// Tag
tag.hashCode()
} else {
// Item
id.hashCode()
}
}
}

View file

@ -0,0 +1,87 @@
package com.nononsenseapps.feeder.model
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
import com.nononsenseapps.feeder.base.KodeinAwareViewModel
import com.nononsenseapps.feeder.db.room.Feed
import com.nononsenseapps.feeder.db.room.FeedDao
import com.nononsenseapps.feeder.db.room.FeedTitle
import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
import com.nononsenseapps.feeder.db.room.ID_UNSET
import com.nononsenseapps.feeder.util.removeDynamicShortcutToFeed
import org.kodein.di.Kodein
import org.kodein.di.generic.instance
class FeedViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
private val dao: FeedDao by instance()
private lateinit var liveFeedsNotify: LiveData<List<Boolean>>
fun getLiveFeedsNotify(id: Long, tag: String): LiveData<List<Boolean>> {
if (!this::liveFeedsNotify.isInitialized) {
liveFeedsNotify = when {
id > ID_UNSET -> dao.loadLiveFeedsNotify(feedId = id)
id == ID_UNSET && tag.isNotEmpty() -> dao.loadLiveFeedsNotify(tag = tag)
else -> dao.loadLiveFeedsNotify()
}.asLiveData()
}
return liveFeedsNotify
}
private lateinit var liveFeed: LiveData<Feed?>
fun getLiveFeed(id: Long): LiveData<Feed?> {
if (!this::liveFeed.isInitialized) {
liveFeed = dao.loadLiveFeed(feedId = id).asLiveData()
}
return liveFeed
}
suspend fun getFeed(id: Long): Feed? {
return dao.loadFeed(feedId = id)
}
suspend fun setNotify(tag: String, notify: Boolean) {
dao.setNotify(tag = tag, notify = notify)
}
suspend fun setNotify(id: Long, notify: Boolean) {
dao.setNotify(id = id, notify = notify)
}
suspend fun setAllNotify(notify: Boolean) {
dao.setAllNotify(notify = notify)
}
suspend fun deleteFeed(id: Long) {
dao.deleteFeedWithId(feedId = id)
val context: Context by instance()
context.removeDynamicShortcutToFeed(id)
}
suspend fun deleteFeeds(ids: List<Long>) {
dao.deleteFeeds(ids)
val context: Context by instance()
for (id in ids) {
context.removeDynamicShortcutToFeed(id)
}
}
suspend fun getVisibleFeeds(id: Long, feedTag: String?): List<FeedTitle> {
return when {
id == ID_UNSET && feedTag?.isNotEmpty() == true -> {
dao.getFeedTitlesWithTag(feedTag = feedTag)
}
id == ID_UNSET || id == ID_ALL_FEEDS -> {
dao.getAllFeedTitles()
}
id > ID_UNSET -> {
dao.getFeedTitle(id)
}
else -> emptyList()
}
}
}

View file

@ -0,0 +1,37 @@
package com.nononsenseapps.feeder.model
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.nononsenseapps.feeder.base.KodeinAwareIntentService
import com.nononsenseapps.feeder.db.room.FeedItemDao
import kotlinx.coroutines.runBlocking
import org.kodein.di.generic.instance
const val ACTION_MARK_AS_UNREAD = "MARK_AS_READ"
class FeederService : KodeinAwareIntentService("FeederService") {
private val dao: FeedItemDao by instance()
override fun onHandleIntent(intent: Intent?) {
when (intent?.action) {
ACTION_MARK_AS_UNREAD -> intent.data?.let { markAsUnread(it) }
}
}
private fun markAsUnread(data: Uri) {
data.lastPathSegment?.toLongOrNull()?.let { id ->
runBlocking {
dao.markAsRead(id = id, unread = true)
}
}
}
companion object {
fun getIntentForId(context: Context, feedItemId: Long): Intent =
Intent(context, FeederService::class.java).apply {
action = ACTION_MARK_AS_UNREAD
data = Uri.parse("com.nononsenseapps.feeder/feeditem/$feedItemId")
}
}
}

Some files were not shown because too many files have changed in this diff Show more