Initall push of the 1.13.5 source
This commit is contained in:
commit
797924598f
555 changed files with 69958 additions and 0 deletions
41
.build.yml
Normal file
41
.build.yml
Normal 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
16
.gitignore
vendored
Normal 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
57
.gitlab-ci.yml
Normal 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
|
14
.gitlab/issue_templates/Bug.md
Normal file
14
.gitlab/issue_templates/Bug.md
Normal 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 -->
|
19
.gitlab/issue_templates/Feature.md
Normal file
19
.gitlab/issue_templates/Feature.md
Normal 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 -->
|
19
.gitlab/merge_request_templates/patch.md
Normal file
19
.gitlab/merge_request_templates/patch.md
Normal 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
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "rome"]
|
||||||
|
path = rome
|
||||||
|
url = https://gitlab.com/spacecowboy/rome.git
|
1083
CHANGELOG.md
Normal file
1083
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
674
LICENSE
Normal file
674
LICENSE
Normal 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
49
README.md
Normal 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
1
app/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/build
|
229
app/build.gradle
Normal file
229
app/build.gradle
Normal 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
38
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
216
app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/7.json
Normal file
216
app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/7.json
Normal 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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
222
app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/8.json
Normal file
222
app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/8.json
Normal 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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
228
app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/9.json
Normal file
228
app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/9.json
Normal 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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
"""
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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=""0"" text=""0"" type="rss" xmlUrl="http://somedomain0.com/rss.xml"/>
|
||||||
|
| <outline title="custom "3"" text="custom "3"" type="rss" xmlUrl="http://somedomain3.com/rss.xml"/>
|
||||||
|
| <outline title="custom "6"" text="custom "6"" type="rss" xmlUrl="http://somedomain6.com/rss.xml"/>
|
||||||
|
| <outline title="custom "9"" text="custom "9"" type="rss" xmlUrl="http://somedomain9.com/rss.xml"/>
|
||||||
|
| <outline title="tag1" text="tag1">
|
||||||
|
| <outline title="custom "1"" text="custom "1"" type="rss" xmlUrl="http://somedomain1.com/rss.xml"/>
|
||||||
|
| <outline title="custom "4"" text="custom "4"" type="rss" xmlUrl="http://somedomain4.com/rss.xml"/>
|
||||||
|
| <outline title="custom "7"" text="custom "7"" type="rss" xmlUrl="http://somedomain7.com/rss.xml"/>
|
||||||
|
| </outline>
|
||||||
|
| <outline title="tag2" text="tag2">
|
||||||
|
| <outline title="custom "2"" text="custom "2"" type="rss" xmlUrl="http://somedomain2.com/rss.xml"/>
|
||||||
|
| <outline title="custom "5"" text="custom "5"" type="rss" xmlUrl="http://somedomain5.com/rss.xml"/>
|
||||||
|
| <outline title="custom "8"" text="custom "8"" 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&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&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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
)
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -0,0 +1 @@
|
||||||
|
package com.nononsenseapps.feeder.ui
|
|
@ -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())
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 it is too large
Load diff
File diff suppressed because one or more lines are too long
|
@ -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>
|
|
@ -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>
|
|
@ -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 & 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;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=&TypeContenu=Actualite&pagename=RSSFeed&site=Mairie6"/>
|
||||||
|
</body>
|
||||||
|
</opml>
|
|
@ -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 & 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;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=&TypeContenu=Actualite&pagename=RSSFeed&site=Mairie6"/>
|
||||||
|
</body>
|
||||||
|
</opml>
|
|
@ -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 28–31 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>
|
||||||
|
We’re 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&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 doesn’t 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 Sander’s master’s
|
||||||
|
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 you’re
|
||||||
|
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 year’s
|
||||||
|
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>. It’s 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 you’re interested in these
|
||||||
|
positions, please have a look at <a href="http://swerl.tudelft.nl/bin/view/Main/PDS">this page</a>,
|
||||||
|
and don’t 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 didn’t 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&r2=9164&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>Here’s 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>Here’s what we’re 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
|
||||||
|
shouldn’t 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
4
app/src/debug/res/values/constants.xml
Normal file
4
app/src/debug/res/values/constants.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">FeederD</string>
|
||||||
|
</resources>
|
139
app/src/main/AndroidManifest.xml
Normal file
139
app/src/main/AndroidManifest.xml
Normal 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>
|
|
@ -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()
|
||||||
|
}
|
147
app/src/main/java/com/nononsenseapps/feeder/FeederApplication.kt
Normal file
147
app/src/main/java/com/nononsenseapps/feeder/FeederApplication.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
30
app/src/main/java/com/nononsenseapps/feeder/blob/Blob.kt
Normal file
30
app/src/main/java/com/nononsenseapps/feeder/blob/Blob.kt
Normal 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())
|
31
app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt
Normal file
31
app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt
Normal 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"
|
16
app/src/main/java/com/nononsenseapps/feeder/db/Uri.kt
Normal file
16
app/src/main/java/com/nononsenseapps/feeder/db/Uri.kt
Normal 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")
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
51
app/src/main/java/com/nononsenseapps/feeder/db/room/Feed.kt
Normal file
51
app/src/main/java/com/nononsenseapps/feeder/db/room/Feed.kt
Normal 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)
|
||||||
|
}
|
134
app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt
Normal file
134
app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
158
app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt
Normal file
158
app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt
Normal 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?
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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) }
|
||||||
|
}
|
|
@ -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"
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
378
app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt
Normal file
378
app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
168
app/src/main/java/com/nononsenseapps/feeder/model/FeedSyncer.kt
Normal file
168
app/src/main/java/com/nononsenseapps/feeder/model/FeedSyncer.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
Loading…
Reference in a new issue